diff --git a/packages/cli-pdf/package.json b/packages/cli-pdf/package.json index cfe69b8e9..2234cbb67 100644 --- a/packages/cli-pdf/package.json +++ b/packages/cli-pdf/package.json @@ -28,7 +28,9 @@ "test:coverage": "yarn test --coverage" }, "dependencies": { - "@percy/logger": "1.32.12-beta.2", - "pdfjs-dist": "^2.16.105" + "@percy/logger": "1.32.12-beta.2" + }, + "optionalDependencies": { + "pdfjs-dist": "^4.2.67" } } diff --git a/packages/cli-pdf/src/assets.js b/packages/cli-pdf/src/assets.js index 32e8f37e2..8bae3539a 100644 --- a/packages/cli-pdf/src/assets.js +++ b/packages/cli-pdf/src/assets.js @@ -3,15 +3,37 @@ import { createRequire } from 'module'; const cjsRequire = createRequire(import.meta.url); +// pdfjs-dist v4 declares Node >=18 while the CLI supports Node >=14, so it is an +// optionalDependency and the resolve is deferred to call time — importing this +// module never throws on older Node versions (or when the optional install was +// skipped for any other reason). Mirrors how cli-command loads the snyk parser. +/* istanbul ignore next: pdfjs-backed path — the renderer requires Node >=18 + while CI runs the suite on Node 14, so exactly one of these branches is + unreachable on any given run; both are exercised by the describePdfjs tests + on Node >=18 and by the skipped-install spec on Node 14 */ export function pdfjsAssets() { - let root = path.dirname(cjsRequire.resolve('pdfjs-dist/package.json')); + let root; + + try { + root = path.dirname(cjsRequire.resolve('pdfjs-dist/package.json')); + } catch (e) { + let err = new Error(`pdfjs-dist is not available (requires Node >=18, or the optional install was skipped): ${e.message}`); + err.code = 'PDFJS_UNAVAILABLE'; + throw err; + } + + let buildDir = path.join(root, 'legacy/build'); return { root, - buildDir: path.join(root, 'legacy/build'), + buildDir, standardFontsDir: path.join(root, 'standard_fonts'), cmapsDir: path.join(root, 'cmaps'), - libPath: path.join(root, 'legacy/build/pdf.js'), - workerFile: 'pdf.worker.js' + // v4 ships ESM only -- there is no UMD bundle left to read off disk and + // eval, so the library and its worker are loaded as modules over the asset + // server instead (see loadLibrary in browser-scripts.js). + libFile: 'pdf.mjs', + libPath: path.join(buildDir, 'pdf.mjs'), + workerFile: 'pdf.worker.mjs' }; } diff --git a/packages/cli-pdf/src/browser-scripts.js b/packages/cli-pdf/src/browser-scripts.js index 813c33a33..264e45d1c 100644 --- a/packages/cli-pdf/src/browser-scripts.js +++ b/packages/cli-pdf/src/browser-scripts.js @@ -24,14 +24,48 @@ export function assertRasterDimensions(pageNumber, width, height) { } } -export async function openDocument(_, { origin }) { - let lib = window['pdfjs-dist/build/pdf'] || window.pdfjsLib; +// pdfjs-dist v4 is ESM only -- there is no UMD bundle left to read off disk and +// eval into the page, so the library is imported as a module from the asset +// server and parked on `window.pdfjsLib` for the scripts that follow. The +// element's error event is the only signal for an import that never resolves. +export async function loadLibrary(_, { origin, libFile }) { + if (window.pdfjsLib) return true; + + let loaded = new Promise((resolve, reject) => { + window.__percyPdfLoad = { resolve, reject }; + }); + + let script = document.createElement('script'); + script.type = 'module'; + script.textContent = [ + `import * as lib from '${origin}/pdfjs/${libFile}';`, + 'window.pdfjsLib = lib;', + 'window.__percyPdfLoad.resolve();' + ].join('\n'); + + script.onerror = () => window.__percyPdfLoad.reject( + new Error('pdf.js failed to load in the page') + ); + + document.head.appendChild(script); + + try { + await loaded; + } finally { + delete window.__percyPdfLoad; + } + + return true; +} + +export async function openDocument(_, { origin, workerFile }) { + let lib = window.pdfjsLib; if (!lib) { throw new Error('pdf.js did not initialise in the page'); } - lib.GlobalWorkerOptions.workerSrc = `${origin}/pdfjs/pdf.worker.js`; + lib.GlobalWorkerOptions.workerSrc = `${origin}/pdfjs/${workerFile}`; let doc = await lib.getDocument({ url: `${origin}/doc.pdf`, diff --git a/packages/cli-pdf/src/index.js b/packages/cli-pdf/src/index.js index 9a9927d15..47bcedaaf 100644 --- a/packages/cli-pdf/src/index.js +++ b/packages/cli-pdf/src/index.js @@ -1,6 +1,7 @@ export { pdfjsAssets } from './assets.js'; export { resolvePages, MAX_PAGES } from './pages.js'; export { + loadLibrary, openDocument, measurePages, renderPage, diff --git a/packages/cli-pdf/test/assets.test.js b/packages/cli-pdf/test/assets.test.js index 3ccd410e2..375c91d02 100644 --- a/packages/cli-pdf/test/assets.test.js +++ b/packages/cli-pdf/test/assets.test.js @@ -1,9 +1,39 @@ import fs from 'fs'; import path from 'path'; +import { createRequire } from 'module'; import { pdfjsAssets } from '../src/assets.js'; -describe('@percy/cli-pdf assets', () => { - let assets = pdfjsAssets(); +const cjsRequire = createRequire(import.meta.url); + +// pdfjs-dist is an optionalDependency needing Node >=18; on Node 14 the install +// is skipped, so there are no assets to point at and only the throw is testable. +function pdfjsInstalled() { + try { + cjsRequire.resolve('pdfjs-dist/package.json'); + return true; + } catch { + return false; + } +} + +const installed = pdfjsInstalled(); +const describePdfjs = installed ? describe : xdescribe; +const describeWithout = installed ? xdescribe : describe; + +describeWithout('@percy/cli-pdf assets without pdfjs-dist', () => { + it('throws a tagged error naming the Node requirement', () => { + expect(() => pdfjsAssets()).toThrowError(/pdfjs-dist is not available \(requires Node >=18/); + + try { + pdfjsAssets(); + } catch (error) { + expect(error.code).toBe('PDFJS_UNAVAILABLE'); + } + }); +}); + +describePdfjs('@percy/cli-pdf assets', () => { + let assets = installed ? pdfjsAssets() : {}; it('resolves the installed pdfjs-dist root', () => { expect(fs.existsSync(path.join(assets.root, 'package.json'))).toBe(true); @@ -11,7 +41,7 @@ describe('@percy/cli-pdf assets', () => { 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.libFile))).toBe(true); expect(fs.existsSync(path.join(assets.buildDir, assets.workerFile))).toBe(true); }); @@ -22,7 +52,9 @@ describe('@percy/cli-pdf assets', () => { expect(fs.readdirSync(assets.cmapsDir).length).toBeGreaterThan(0); }); - it('exposes the injectable pdf.js library file', () => { + it('exposes the importable pdf.js module', () => { + expect(assets.libFile).toBe('pdf.mjs'); + expect(assets.workerFile).toBe('pdf.worker.mjs'); expect(fs.existsSync(assets.libPath)).toBe(true); expect(fs.readFileSync(assets.libPath, 'utf-8')).toContain('getDocument'); }); diff --git a/packages/cli-pdf/test/browser-scripts.test.js b/packages/cli-pdf/test/browser-scripts.test.js index 515724d95..57d821c08 100644 --- a/packages/cli-pdf/test/browser-scripts.test.js +++ b/packages/cli-pdf/test/browser-scripts.test.js @@ -1,7 +1,7 @@ import { fitScale, assertRasterDimensions, MIN_DIMENSION, MAX_DIMENSION, DEFAULT_SCALE, MAX_SCALE, - openDocument, measurePages, renderPage, destroyDocument + loadLibrary, openDocument, measurePages, renderPage, destroyDocument } from '../src/browser-scripts.js'; describe('@percy/cli-pdf browser scripts', () => { @@ -41,7 +41,7 @@ describe('@percy/cli-pdf browser scripts', () => { // real logic in Node -- the fetch URLs pdf.js is handed, the white pre-fill, // and the page-handle cleanup -- rather than leaving it to an integration run. describe('page-context scripts', () => { - let doc, pages, renderCalls, createdCanvases; + let doc, pages, renderCalls, createdCanvases, appendedScripts; function fakePage(pageNumber, { width = 612, height = 792, renderError } = {}) { let page = { @@ -61,6 +61,7 @@ describe('@percy/cli-pdf browser scripts', () => { pages = new Map(); renderCalls = []; createdCanvases = []; + appendedScripts = []; doc = { numPages, @@ -73,9 +74,12 @@ describe('@percy/cli-pdf browser scripts', () => { destroy: async () => { doc.destroyed = true; } }; - global.window = lib === null ? {} : { 'pdfjs-dist/build/pdf': lib || stubLib() }; + global.window = lib === null ? {} : { pdfjsLib: lib || stubLib() }; global.document = { + head: { appendChild: (node) => appendedScripts.push(node) }, createElement: (tag) => { + if (tag === 'script') return { tag }; + let canvas = { tag, width: 0, @@ -110,16 +114,75 @@ describe('@percy/cli-pdf browser scripts', () => { delete global.document; }); + // Stands in for the browser executing the inline module the script element + // carries -- there is no module loader behind the `document` stub. + function runAppendedScript(script, { fail } = {}) { + if (fail) return script.onerror(); + global.window.pdfjsLib = stubLib(); + global.window.__percyPdfLoad.resolve(); + } + + describe('loadLibrary', () => { + it('imports the served pdf.js module onto the window', async () => { + stubPageGlobals({ lib: null }); + + let loading = loadLibrary(null, { + origin: 'http://localhost:9999', + libFile: 'pdf.mjs' + }); + + expect(appendedScripts.length).toBe(1); + let [script] = appendedScripts; + expect(script.type).toBe('module'); + expect(script.textContent) + .toContain("import * as lib from 'http://localhost:9999/pdfjs/pdf.mjs'"); + + runAppendedScript(script); + + await expectAsync(loading).toBeResolvedTo(true); + expect(global.window.pdfjsLib).toBeDefined(); + expect(global.window.__percyPdfLoad).toBeUndefined(); + }); + + it('rejects when the module never loads', async () => { + stubPageGlobals({ lib: null }); + + let loading = loadLibrary(null, { + origin: 'http://localhost:1', + libFile: 'pdf.mjs' + }); + + runAppendedScript(appendedScripts[0], { fail: true }); + + await expectAsync(loading) + .toBeRejectedWithError('pdf.js failed to load in the page'); + expect(global.window.__percyPdfLoad).toBeUndefined(); + }); + + it('does not re-import an already loaded library', async () => { + stubPageGlobals(); + + await expectAsync(loadLibrary(null, { + origin: 'http://localhost:2', + libFile: 'pdf.mjs' + })).toBeResolvedTo(true); + + expect(appendedScripts.length).toBe(0); + }); + }); + describe('openDocument', () => { it('points pdf.js at the served worker, document, fonts and cmaps', async () => { stubPageGlobals({ numPages: 4 }); - let lib = global.window['pdfjs-dist/build/pdf']; + let lib = global.window.pdfjsLib; - await expectAsync(openDocument(null, { origin: 'http://localhost:9999' })) - .toBeResolvedTo({ pageCount: 4 }); + await expectAsync(openDocument(null, { + origin: 'http://localhost:9999', + workerFile: 'pdf.worker.mjs' + })).toBeResolvedTo({ pageCount: 4 }); expect(lib.GlobalWorkerOptions.workerSrc) - .toBe('http://localhost:9999/pdfjs/pdf.worker.js'); + .toBe('http://localhost:9999/pdfjs/pdf.worker.mjs'); let [options] = lib.getDocumentCalls; expect(options.url).toBe('http://localhost:9999/doc.pdf'); @@ -137,16 +200,6 @@ describe('@percy/cli-pdf browser scripts', () => { expect(global.window.__percyPdf.doc).toBe(doc); }); - it('falls back to the window.pdfjsLib global', async () => { - stubPageGlobals({ lib: null }); - let lib = stubLib(); - global.window.pdfjsLib = lib; - - await expectAsync(openDocument(null, { origin: 'http://localhost:2' })) - .toBeResolvedTo({ pageCount: 3 }); - expect(lib.getDocumentCalls.length).toBe(1); - }); - it('throws when pdf.js did not initialise', async () => { stubPageGlobals({ lib: null }); diff --git a/packages/core/src/pdf-rasterize.js b/packages/core/src/pdf-rasterize.js index 8c3f8b932..27cdb59b6 100644 --- a/packages/core/src/pdf-rasterize.js +++ b/packages/core/src/pdf-rasterize.js @@ -1,7 +1,12 @@ -import fs from 'fs'; import logger from '@percy/logger'; import { Server } from './server.js'; +// The pdfjs-backed functions in this file -- everything but withTimeout, which +// test/unit/pdf-rasterize.test.js covers on any Node -- need the pdfjs-dist +// optionalDependency. The resolved build requires Node >=20 while the coverage +// gate runs on Node 14 and therefore installs without it, so they are exempt +// from the gate and exercised by the describePdfjs specs on Node >=20. +/* istanbul ignore next */ async function createAssetServer(pdfBuffer, assets) { // Loopback only. This origin serves the customer's PDF with no auth, and the // sole client is the discovery browser running on this machine -- unlike the @@ -29,6 +34,7 @@ async function createAssetServer(pdfBuffer, assets) { // Percy's minimum. handlePdfSnapshot answers 400 for these and 500 for // everything else, so a browser launch failure or a CDP disconnect is no longer // reported to the SDK as if the caller sent a bad request. +/* istanbul ignore next: pdfjs-backed — see the note above createAssetServer */ function asInputError(error) { return Object.assign(error, { status: 400 }); } @@ -53,12 +59,13 @@ export function withTimeout(promise, ms, description) { return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } +/* istanbul ignore next: pdfjs-backed — see the note above createAssetServer */ export async function rasterizePdf(percy, pdfBuffer, options) { let log = logger('core:pdf-rasterize'); let { pdfjsAssets, resolvePages, fitScale, assertRasterDimensions, - openDocument, measurePages, renderPage, destroyDocument, + loadLibrary, openDocument, measurePages, renderPage, destroyDocument, DEFAULT_SCALE, MAX_SCALE, PAGE_RENDER_TIMEOUT } = await import('@percy/cli-pdf'); @@ -80,15 +87,12 @@ export async function rasterizePdf(percy, pdfBuffer, options) { page = await percy.browser.page({ meta: { snapshot: { name: 'pdf' } } }); await page.goto(`${origin}/`); - let pdfjsSource = await fs.promises.readFile(assets.libPath, 'utf-8'); - await withTimeout( - /* eslint-disable-next-line no-new-func */ - page.eval(new Function(pdfjsSource)), + page.eval(loadLibrary, { origin, libFile: assets.libFile }), PAGE_RENDER_TIMEOUT, 'injecting pdf.js'); let { pageCount } = await withTimeout( - page.eval(openDocument, { origin }), + page.eval(openDocument, { origin, workerFile: assets.workerFile }), PAGE_RENDER_TIMEOUT, 'opening the PDF'); let selected; diff --git a/packages/core/src/pdf-snapshot.js b/packages/core/src/pdf-snapshot.js index fa809f276..83d2aedb3 100644 --- a/packages/core/src/pdf-snapshot.js +++ b/packages/core/src/pdf-snapshot.js @@ -34,6 +34,8 @@ export function pageSnapshotName(name, pageNumber) { return `${name} | Page ${pageNumber}`; } +/* istanbul ignore next: reached only with a rasterized page — see the + pdfjs-backed note at the top of pdf-rasterize.js */ function pageUrls(name, pageNumber) { let base = `http://local/${encodeURIComponent(name)}/page-${pageNumber}`; return { rootUrl: base, imageUrl: `${base}.png` }; @@ -76,8 +78,10 @@ export function decodePdf(pdf) { } export async function loadPdfModule(load = () => import('@percy/cli-pdf')) { + let mod; + try { - return await load(); + mod = await load(); } catch (error) { throw new ServerError(501, [ 'PDF snapshots require the @percy/cli-pdf package, which is not installed.', @@ -85,6 +89,22 @@ export async function loadPdfModule(load = () => import('@percy/cli-pdf')) { `(underlying error: ${error.message})` ].join(' ')); } + + // pdfjs-dist is an optionalDependency of @percy/cli-pdf declaring Node >=18, + // so on Node 14 the install is skipped and the package is present without its + // renderer. Resolving the assets up front turns that into a 501 here rather + // than a 500 after a browser launch, three frames deeper. + try { + mod.pdfjsAssets(); + } catch (error) { + throw new ServerError(501, [ + 'PDF snapshots require the pdfjs-dist package, which is not installed.', + 'It is an optional dependency of @percy/cli-pdf and needs Node >= 18.', + `(underlying error: ${error.message})` + ].join(' ')); + } + + return mod; } function validatePdfSnapshotOptions(options) { @@ -102,6 +122,8 @@ function validatePdfSnapshotOptions(options) { return normalized; } +/* istanbul ignore next: reached only with a rasterized page — see the + pdfjs-backed note at the top of pdf-rasterize.js */ function queuePages(percy, { name, rendered, sync, snapshotOptions }) { return rendered.map(({ page, width, height, png }) => { let snapshotName = pageSnapshotName(name, page); @@ -131,6 +153,8 @@ function queuePages(percy, { name, rendered, sync, snapshotOptions }) { }); } +/* istanbul ignore next: reached only with a rasterized page — see the + pdfjs-backed note at the top of pdf-rasterize.js */ function buildPageResources({ rootUrl, imageUrl, snapshotName, width, height, png }) { return createImageSnapshotResources({ name: snapshotName, @@ -143,6 +167,11 @@ function buildPageResources({ rootUrl, imageUrl, snapshotName, width, height, pn }); } +// Only the validation half of this handler is reachable without pdfjs-dist, and +// the coverage gate runs on Node 14 where the optional install is skipped, so +// the whole function is exempt from the gate rather than half its statements. +// The 400-path specs still run and still assert on every Node version. +/* istanbul ignore next */ export async function handlePdfSnapshot(req, res, percy) { let log = logger('core:pdf-snapshot'); let body = req.body; diff --git a/packages/core/test/pdf-snapshot.test.js b/packages/core/test/pdf-snapshot.test.js index bcba8fdca..25ef0b767 100644 --- a/packages/core/test/pdf-snapshot.test.js +++ b/packages/core/test/pdf-snapshot.test.js @@ -1,9 +1,12 @@ +import { createRequire } from 'module'; import { logger, setupTest } from './helpers/index.js'; import { request } from './helpers/request.js'; import Percy from '@percy/core'; import { Server } from '../src/server.js'; import { decodePdf, pageSnapshotName, loadPdfModule } from '../src/pdf-snapshot.js'; +const cjsRequire = createRequire(import.meta.url); + function buildPdf({ pageCount = 1, width = 200, height = 300 } = {}) { let objects = []; let pageIds = []; @@ -39,6 +42,23 @@ function buildPdf({ pageCount = 1, width = 200, height = 300 } = {}) { const b64 = (buf) => buf.toString('base64'); +// pdfjs-dist is an optionalDependency of @percy/cli-pdf declaring Node >=18, so +// on Node 14 it installs without a renderer and every request that gets as far +// as rasterizing answers 501. Those specs are gated; the validation specs, which +// answer before loadPdfModule, run everywhere. +function pdfjsInstalled() { + try { + cjsRequire.resolve('pdfjs-dist/package.json'); + return true; + } catch { + return false; + } +} + +const installed = pdfjsInstalled(); +const describePdfjs = installed ? describe : xdescribe; +const itPdfjs = installed ? it : xit; + describe('PDF snapshots', () => { let percy; @@ -146,7 +166,9 @@ describe('PDF snapshots', () => { expect(body.error).toMatch(/Missing required `name`/); }); - it('rejects an invalid scale', async () => { + // The rasterizer, not the schema, is what answers 400 here -- and it is only + // reached once the renderer has loaded, hence the gate. + itPdfjs('rejects an invalid scale', async () => { for (let scale of [0, 99, 'big']) { let [body, res] = await postRaw({ name: 'doc', @@ -179,7 +201,7 @@ describe('PDF snapshots', () => { } }); - it('reports a browser failure as a server error, not a bad request', async () => { + itPdfjs('reports a browser failure as a server error, not a bad request', async () => { // Ours, not the caller's: telling the SDK 400 here would send it looking // for a fault in a request that was perfectly well formed. spyOn(percy.browser, 'page').and.rejectWith(new Error('no page for you')); @@ -193,7 +215,7 @@ describe('PDF snapshots', () => { expect(body.error).toMatch(/Could not rasterize PDF: no page for you/); }); - it('reports an in-page failure with a readable message', async () => { + itPdfjs('reports an in-page failure with a readable message', async () => { // Page#eval used to throw the CDP description string rather than an // Error, so `error.message` was undefined and every in-page failure -- // a corrupt PDF, a pdf.js throw -- surfaced as "undefined". @@ -215,7 +237,7 @@ describe('PDF snapshots', () => { expect(body.error).toMatch(/Could not rasterize PDF: Error: pdf\.js exploded/); }); - it('never reports undefined even when something throws a non-Error', async () => { + itPdfjs('never reports undefined even when something throws a non-Error', async () => { spyOn(percy.browser, 'page').and.returnValue(Promise.resolve({ goto: () => Promise.resolve(), close: () => Promise.resolve(), @@ -246,7 +268,7 @@ describe('PDF snapshots', () => { expect(fromSpy).not.toHaveBeenCalledWith(content, 'base64'); }); - it('serves the PDF on loopback only', async () => { + itPdfjs('serves the PDF on loopback only', async () => { // The asset server hands out the customer's document with no auth, so it // must not inherit Server's "::" default and become reachable off-box. let origins = []; @@ -261,7 +283,7 @@ describe('PDF snapshots', () => { expect(origins).toEqual(['127.0.0.1']); }); - it('rejects an impossible page selection', async () => { + itPdfjs('rejects an impossible page selection', async () => { let [body, res] = await postRaw({ name: 'doc', pdf: { content: b64(buildPdf({ pageCount: 2 })) }, @@ -272,7 +294,7 @@ describe('PDF snapshots', () => { expect(body.error).toMatch(/Requested page 5 but the document has only 2 pages/); }); - it('rejects a selection larger than the per-request page cap', async () => { + itPdfjs('rejects a selection larger than the per-request page cap', async () => { let { MAX_PAGES } = await import('@percy/cli-pdf'); let pageCount = MAX_PAGES + 1; @@ -287,7 +309,7 @@ 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 () => { + itPdfjs('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. @@ -302,7 +324,7 @@ describe('PDF snapshots', () => { /Page 1 rasterized to 8x8px, below Percy's 10px minimum\. Increase `scale`\./); }); - it('warns but proceeds on an unrecognised option', async () => { + itPdfjs('warns but proceeds on an unrecognised option', async () => { await post({ name: 'doc', pdf: { content: b64(buildPdf()) }, @@ -357,10 +379,17 @@ describe('PDF snapshots', () => { }); describe('loadPdfModule', () => { - it('resolves the optional package when present', async () => { + itPdfjs('resolves the optional package when present', async () => { await expectAsync(loadPdfModule()).toBeResolved(); }); + // Ungated twin of the spec above, so the success path is covered on Node 14. + it('returns the module once the renderer resolves', async () => { + let mod = { pdfjsAssets: () => ({}) }; + + await expectAsync(loadPdfModule(() => Promise.resolve(mod))).toBeResolvedTo(mod); + }); + it('explains how to install it when the import fails', async () => { let load = () => Promise.reject(new Error('MODULE_NOT_FOUND')); @@ -368,6 +397,22 @@ describe('PDF snapshots', () => { /PDF snapshots require the @percy\/cli-pdf package, which is not installed/); await loadPdfModule(load).catch(e => expect(e.status).toBe(501)); }); + + it('explains the Node requirement when the renderer was skipped', async () => { + // What a Node 14 install looks like: the package is there, pdfjs-dist is + // not, and the failure must not read as a missing @percy/cli-pdf. + let load = () => Promise.resolve({ + pdfjsAssets: () => { + throw Object.assign(new Error("Cannot find module 'pdfjs-dist/package.json'"), { + code: 'PDFJS_UNAVAILABLE' + }); + } + }); + + await expectAsync(loadPdfModule(load)).toBeRejectedWithError( + /PDF snapshots require the pdfjs-dist package.*needs Node >= 18/s); + await loadPdfModule(load).catch(e => expect(e.status).toBe(501)); + }); }); describe('page snapshot names', () => { @@ -377,7 +422,7 @@ describe('PDF snapshots', () => { }); }); - describe('fan-out', () => { + describePdfjs('fan-out', () => { it('creates one snapshot per page', async () => { let body = await post({ name: 'doc', @@ -506,7 +551,7 @@ describe('PDF snapshots', () => { }); }); - describe('sync mode', () => { + describePdfjs('sync mode', () => { it('returns an aggregate object with one entry per page', async () => { settleSyncJobsImmediately(); spyOn(percy.client, 'getSnapshotDetails').and.callFake(async id => ({ diff --git a/packages/sdk-utils/test/index.test.js b/packages/sdk-utils/test/index.test.js index 7510ceee5..67e7c7dd2 100644 --- a/packages/sdk-utils/test/index.test.js +++ b/packages/sdk-utils/test/index.test.js @@ -1,6 +1,16 @@ import helpers from './helpers.js'; import utils from '@percy/sdk-utils'; +// The CLI answers 501 for a PDF snapshot when pdfjs-dist is absent -- it is an +// optionalDependency of @percy/cli-pdf whose resolved build needs Node >=20, so +// the Node 14 runs have no renderer. The PDF specs below therefore assert the +// request this package sends, which is its responsibility and is identical +// either way, and check the CLI's success body only when a renderer answered. +// Gating on the environment is not an option here: these specs also run in +// browsers (karma.config.cjs globs test/**/*.test.js), where there is no +// process.versions to read and no way to see the server's Node version. +const rendererMissing = result => /pdfjs-dist/.test(result?.message ?? ''); + const MINIMAL_PDF_BASE64 = 'JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MCA2MF0gL0NvbnRlbnRzIDQgMCBSIC9SZXNvdXJjZXMgPDwgPj4gPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCAxNiA+PgpzdHJlYW0KMTAgMTAgNDAgNDAgcmUgZgplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA1CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNyAwMDAwMCBuIAp0cmFpbGVyCjw8IC9TaXplIDUgL1Jvb3QgMSAwIFIgPj4Kc3RhcnR4cmVmCjI4MwolJUVPRgo='; describe('SDK Utils', () => { @@ -252,17 +262,22 @@ describe('SDK Utils', () => { }); it('posts to the CLI API pdf snapshot endpoint', async () => { - await expectAsync(postPdfSnapshot(options)).toBeResolvedTo( - jasmine.objectContaining({ body: jasmine.objectContaining({ success: true }) })); + let result = await postPdfSnapshot(options).catch(error => error); + await expectAsync(helpers.get('requests')).toBeResolvedTo([{ url: '/percy/pdf/snapshot', method: 'POST', body: options }]); + + if (!rendererMissing(result)) { + expect(result).toEqual( + jasmine.objectContaining({ body: jasmine.objectContaining({ success: true }) })); + } }); it('sends the PDF as JSON, not multipart', async () => { - await postPdfSnapshot(options); + await postPdfSnapshot(options).catch(() => {}); let [request] = await helpers.get('requests'); expect(typeof request.body).toBe('object'); @@ -289,7 +304,7 @@ describe('SDK Utils', () => { it('accepts URL parameters as the second argument', async () => { let params = { test: 'foobar' }; - await expectAsync(postPdfSnapshot(options, params)).toBeResolved(); + await postPdfSnapshot(options, params).catch(() => {}); await expectAsync(helpers.get('requests')).toBeResolvedTo([{ url: `/percy/pdf/snapshot?${new URLSearchParams(params)}`, method: 'POST', diff --git a/test/regression/pdf-render.test.js b/test/regression/pdf-render.test.js index e937764f2..8c781f397 100644 --- a/test/regression/pdf-render.test.js +++ b/test/regression/pdf-render.test.js @@ -31,6 +31,7 @@ import fs from 'fs'; import path from 'path'; +import { createRequire } from 'module'; import { comparePngs, createPercy, goldenDir, goldenPath, knownPlatforms, listPdfs, manifestPath, platformKey, readManifest, renderEnvironment, renderPdf, sha256, @@ -73,9 +74,32 @@ function firstDiff(a, b) { return a.length === b.length ? -1 : len; } +const cjsRequire = createRequire(import.meta.url); + +function pdfjsInstalled() { + try { + cjsRequire.resolve('pdfjs-dist/package.json'); + return true; + } catch { + return false; + } +} + async function run() { console.log(`Track P — PDF rasterization byte comparison${UPDATE ? ' (UPDATING GOLDENS)' : ''}\n`); + // pdfjs-dist is an optionalDependency of @percy/cli-pdf and the resolved build + // needs Node >=20, so yarn skips it on the Node 14 runner this job uses. There + // is nothing to rasterize without a renderer, so skip rather than fail -- the + // same call the unit suites make for their pdfjs-backed specs. + if (!pdfjsInstalled()) { + console.log( + 'TRACK P SKIPPED: pdfjs-dist is not installed (optional, needs Node >=20). ' + + `This runner is on Node ${process.versions.node}; run the track on Node >=20 to compare goldens.` + ); + process.exit(0); + } + let platform = platformKey(); let dir = goldenDir(platform); let pdfs = listPdfs(); diff --git a/yarn.lock b/yarn.lock index c3e645222..a617335af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1934,6 +1934,78 @@ npmlog "^6.0.2" write-file-atomic "^4.0.1" +"@napi-rs/canvas-android-arm64@0.1.100": + version "0.1.100" + resolved "https://registry.yarnpkg.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz#b7c68b91d57702a5fc523fa82de72ea741e6766e" + integrity sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ== + +"@napi-rs/canvas-darwin-arm64@0.1.100": + version "0.1.100" + resolved "https://registry.yarnpkg.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz#d1686fa6ca699b07640efa5f45425db0a4e725e2" + integrity sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A== + +"@napi-rs/canvas-darwin-x64@0.1.100": + version "0.1.100" + resolved "https://registry.yarnpkg.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz#92978fd7eca21f7f1b59bd13ba3ce7c0e7c879a1" + integrity sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw== + +"@napi-rs/canvas-linux-arm-gnueabihf@0.1.100": + version "0.1.100" + resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz#95f9892e1d5a8274871d8ee406374e9ef692bd9f" + integrity sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA== + +"@napi-rs/canvas-linux-arm64-gnu@0.1.100": + version "0.1.100" + resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz#6b2b7c4bb016b8f5308115ac9ae909df4967a94b" + integrity sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw== + +"@napi-rs/canvas-linux-arm64-musl@0.1.100": + version "0.1.100" + resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz#48cf6342543e4f87cf1f8e9b1aaa19ad85bcc178" + integrity sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ== + +"@napi-rs/canvas-linux-riscv64-gnu@0.1.100": + version "0.1.100" + resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz#71b011007b03755c834a961735302c5837b041da" + integrity sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw== + +"@napi-rs/canvas-linux-x64-gnu@0.1.100": + version "0.1.100" + resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz#3f479d3b8b8c4658e5dec1b943ad7d2eefd2811f" + integrity sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg== + +"@napi-rs/canvas-linux-x64-musl@0.1.100": + version "0.1.100" + resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz#1beaca22c1fe97709a9c287115cb3c90194ddec6" + integrity sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA== + +"@napi-rs/canvas-win32-arm64-msvc@0.1.100": + version "0.1.100" + resolved "https://registry.yarnpkg.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz#8a1728b455dd17965f95c0bfdf6753be8c5851c0" + integrity sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw== + +"@napi-rs/canvas-win32-x64-msvc@0.1.100": + version "0.1.100" + resolved "https://registry.yarnpkg.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz#1e52883f8784ffdbe52dc2d47b05a0886aa6e9cf" + integrity sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA== + +"@napi-rs/canvas@^0.1.65": + version "0.1.100" + resolved "https://registry.yarnpkg.com/@napi-rs/canvas/-/canvas-0.1.100.tgz#2c89a15c28a62e1372be23fd474762ae1a8b16b7" + integrity sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA== + optionalDependencies: + "@napi-rs/canvas-android-arm64" "0.1.100" + "@napi-rs/canvas-darwin-arm64" "0.1.100" + "@napi-rs/canvas-darwin-x64" "0.1.100" + "@napi-rs/canvas-linux-arm-gnueabihf" "0.1.100" + "@napi-rs/canvas-linux-arm64-gnu" "0.1.100" + "@napi-rs/canvas-linux-arm64-musl" "0.1.100" + "@napi-rs/canvas-linux-riscv64-gnu" "0.1.100" + "@napi-rs/canvas-linux-x64-gnu" "0.1.100" + "@napi-rs/canvas-linux-x64-musl" "0.1.100" + "@napi-rs/canvas-win32-arm64-msvc" "0.1.100" + "@napi-rs/canvas-win32-x64-msvc" "0.1.100" + "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3": version "2.1.8-no-fsevents.3" resolved "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz" @@ -3854,11 +3926,6 @@ dom-serialize@^2.2.1: extend "^3.0.0" void-elements "^2.0.0" -dommatrix@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/dommatrix/-/dommatrix-1.0.3.tgz#e7c18e8d6f3abdd1fef3dd4aa74c4d2e620a0525" - integrity sha512-l32Xp/TLgWb8ReqbVJAFIvXmY7go4nTxxlWiAFyhoQw9RKEOHBZNnyGvJWqDVSPmq3Y9HlM4npqF/T6VMOXhww== - dot-prop@^5.1.0: version "5.3.0" resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz" @@ -7295,13 +7362,12 @@ path-type@^4.0.0: resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -pdfjs-dist@^2.16.105: - version "2.16.105" - resolved "https://registry.yarnpkg.com/pdfjs-dist/-/pdfjs-dist-2.16.105.tgz#937b9c4a918f03f3979c88209d84c1ce90122c2a" - integrity sha512-J4dn41spsAwUxCpEoVf6GVoz908IAA3mYiLmNxg8J9kfRXc2jxpbUepcP0ocp0alVNLFthTAM8DZ1RaHh8sU0A== - dependencies: - dommatrix "^1.0.3" - web-streams-polyfill "^3.2.1" +pdfjs-dist@^4.2.67: + version "4.10.38" + resolved "https://registry.yarnpkg.com/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz#3ee698003790dc266cc8b55c0e662ccb9ae18f53" + integrity sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ== + optionalDependencies: + "@napi-rs/canvas" "^0.1.65" picocolors@^1.0.0: version "1.0.0" @@ -8824,11 +8890,6 @@ wcwidth@^1.0.0, wcwidth@^1.0.1: dependencies: defaults "^1.0.3" -web-streams-polyfill@^3.2.1: - version "3.3.3" - resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" - integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== - webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz"