From 5f5dca8445f06d7189c8fe95ca1ef5a5c28ea3f2 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sun, 6 Sep 2026 06:59:00 +0200 Subject: [PATCH] test: harden preview export geometry diagnostics --- .../e2e/specs/preview-export-geometry.spec.ts | 424 +++++++++++++----- 1 file changed, 308 insertions(+), 116 deletions(-) diff --git a/tests/e2e/specs/preview-export-geometry.spec.ts b/tests/e2e/specs/preview-export-geometry.spec.ts index d0ec7fe71..180275ba1 100644 --- a/tests/e2e/specs/preview-export-geometry.spec.ts +++ b/tests/e2e/specs/preview-export-geometry.spec.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; import { Pool } from "pg"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { generateFilename } from "@reactive-resume/utils/file"; import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; import { expect, test } from "../fixtures/test"; @@ -36,12 +37,15 @@ type PreviewPageGeometry = { type PreviewGeometry = { devicePixelRatio: number; pages: PreviewPageGeometry[]; + transform: { matrix: string; scaleX: number; scaleY: number }; viewportClip: { clientHeight: number; clientWidth: number; height: number; scrollHeight: number; scrollWidth: number; + overflowX: string; + overflowY: string; width: number; x: number; y: number; @@ -49,6 +53,11 @@ type PreviewGeometry = { viewport: { height: number; width: number }; }; +type PersistedFixture = { + data: ReturnType; + revision: string; +}; + const FORMATS: GeometryScenario["format"][] = ["a4", "letter", "free-form"]; const MARGINS = [defaultResumeData.metadata.page.marginY, 15]; const SCENARIOS: GeometryScenario[] = FORMATS.flatMap((format) => @@ -58,6 +67,18 @@ const SCENARIOS: GeometryScenario[] = FORMATS.flatMap((format) => ]), ); +// Policy: PDF coordinates allow 0.05pt layout rounding; raster comparisons allow a 32-level +// per-channel antialiasing delta and at most 2% (capped at 200,000) mismatching pixels per page. +// Page count, ink presence, sentinels, and export target remain strict. +const PDF_COORDINATE_TOLERANCE = 0.05; +const DOM_LAYOUT_TOLERANCE = 1; +const TRANSFORM_SCALE_TOLERANCE = 0.005; +const RASTER_CHANNEL_TOLERANCE = 32; +const RASTER_MISMATCH_RATIO_LIMIT = 0.02; +const RASTER_MISMATCH_PIXEL_LIMIT = 200_000; +const GEOMETRY_E2E_OPT_IN = "PREVIEW_EXPORT_GEOMETRY_E2E"; +const DISPOSABLE_DATABASE_NAME = "reactive_resume_preview_export_geometry_e2e"; + const requirePdf = createRequire(`${process.cwd()}/packages/pdf/package.json`); const requireWeb = createRequire(`${process.cwd()}/apps/web/package.json`); const { createCanvas } = requirePdf("@napi-rs/canvas") as { @@ -74,11 +95,20 @@ const { createCanvas } = requirePdf("@napi-rs/canvas") as { function dedicatedDatabaseUrl() { const value = process.env.DATABASE_URL; if (!value) throw new Error("DATABASE_URL is required for preview/export geometry E2E."); + if (process.env[GEOMETRY_E2E_OPT_IN] !== "1") { + throw new Error( + `Refusing preview/export geometry E2E: set ${GEOMETRY_E2E_OPT_IN}=1 to opt in; this suite mutates and deletes fixture data.`, + ); + } const url = new URL(value); - if (!["localhost", "127.0.0.1"].includes(url.hostname) || url.port !== "55432") { + if ( + !["localhost", "127.0.0.1"].includes(url.hostname) || + url.port !== "55432" || + decodeURIComponent(url.pathname.slice(1)) !== DISPOSABLE_DATABASE_NAME + ) { throw new Error( - "Refusing preview/export geometry E2E: DATABASE_URL must point at disposable PostgreSQL port 55432.", + `Refusing preview/export geometry E2E: DATABASE_URL must be loopback port 55432 database ${DISPOSABLE_DATABASE_NAME}.`, ); } @@ -127,15 +157,41 @@ function createFixture(scenario: GeometryScenario) { return data; } -async function updateFixture(resumeId: string, data: ReturnType) { +async function updateFixture(resumeId: string, data: ReturnType): Promise { const pool = new Pool({ connectionString: dedicatedDatabaseUrl() }); try { - await pool.query('update "resume" set data = $2, updated_at = now() where id = $1', [resumeId, data]); - const result = await pool.query<{ notes: string }>( - "select data -> 'metadata' ->> 'notes' as notes from \"resume\" where id = $1", + const before = await pool.query<{ revision: string }>( + 'select updated_at::text as revision from "resume" where id = $1', [resumeId], ); - if (result.rows[0]?.notes !== data.metadata.notes) throw new Error("Synthetic geometry fixture did not persist."); + const previousRevision = before.rows[0]?.revision; + await pool.query('update "resume" set data = $2, updated_at = now() where id = $1', [resumeId, data]); + const result = await pool.query<{ data: ReturnType; revision: string }>( + 'select data, updated_at::text as revision from "resume" where id = $1', + [resumeId], + ); + const persisted = result.rows[0]; + if (!persisted) throw new Error("Synthetic geometry fixture row disappeared during persistence."); + expect(persisted.data, "Synthetic geometry fixture full source JSON").toEqual(data); + if (!persisted.revision || persisted.revision === previousRevision) { + throw new Error("Synthetic geometry fixture revision did not advance."); + } + return persisted; + } finally { + await pool.end(); + } +} + +async function readPersistedFixture(resumeId: string): Promise { + const pool = new Pool({ connectionString: dedicatedDatabaseUrl() }); + try { + const result = await pool.query<{ data: ReturnType; revision: string }>( + 'select data, updated_at::text as revision from "resume" where id = $1', + [resumeId], + ); + const persisted = result.rows[0]; + if (!persisted) throw new Error("Synthetic geometry fixture row is missing."); + return persisted; } finally { await pool.end(); } @@ -224,6 +280,26 @@ async function inspectPdf(bytes: Uint8Array): Promise { } } +function assertPdfSurface(pdf: PdfGeometry, expectedText: string, label: string) { + expect(pdf.pageCount, `${label}: page count`).toBeGreaterThan(0); + expect(pdf.pages, `${label}: page list`).toHaveLength(pdf.pageCount); + expect( + pdf.pages.some((page) => page.text.includes(expectedText)), + `${label}: fixture text`, + ).toBe(true); + expect( + pdf.pages.some((page) => page.sentinels.TOP_SENTINEL), + `${label}: TOP_SENTINEL presence`, + ).toBe(true); + expect( + pdf.pages.some((page) => page.sentinels.BOTTOM_SENTINEL), + `${label}: BOTTOM_SENTINEL presence`, + ).toBe(true); + for (const [index, page] of pdf.pages.entries()) { + expect(page.inkBounds, `${label}: page ${index + 1} must contain ink`).not.toBeNull(); + } +} + async function capturePreviewBytes(page: Page, expectedText: string) { let bytes: Uint8Array | undefined; let geometry: PdfGeometry | undefined; @@ -241,22 +317,69 @@ async function capturePreviewBytes(page: Page, expectedText: string) { await page.waitForTimeout(250); } if (!bytes || !geometry) throw new Error(`Active preview PDF did not settle on ${expectedText}.`); + assertPdfSurface(geometry, expectedText, "Active preview PDF"); return { bytes, geometry }; } +async function waitForStablePreview(page: Page, expectedZoom: number) { + await page.waitForFunction( + ({ expectedZoom: targetZoom }) => { + const wrapper = document.querySelector(".react-transform-wrapper"); + const content = document.querySelector(".react-transform-component"); + const active = document.querySelector( + '[aria-hidden="false"][data-resume-preview-template="rhyhorn"]', + ); + const canvases = [...(active?.querySelectorAll('canvas[aria-label^="Resume page"]') ?? [])]; + if (!wrapper || !content || canvases.length === 0) return false; + + const transform = getComputedStyle(content).transform; + const matrix = transform === "none" ? new DOMMatrixReadOnly() : new DOMMatrixReadOnly(transform); + const signature = JSON.stringify([ + matrix.a, + matrix.b, + matrix.c, + matrix.d, + matrix.e, + matrix.f, + ...canvases.flatMap((canvas) => { + const rect = canvas.getBoundingClientRect(); + return [rect.x, rect.y, rect.width, rect.height, canvas.width, canvas.height, canvas.toDataURL("image/png")]; + }), + ]); + const stateKey = "__previewGeometryStability"; + const previous = (window as unknown as Record)[stateKey] as + | { count: number; signature: string } + | undefined; + const count = previous?.signature === signature ? previous.count + 1 : 1; + (window as unknown as Record)[stateKey] = { count, signature }; + + return ( + Math.abs(matrix.a - targetZoom) < 0.005 && + Math.abs(matrix.d - targetZoom) < 0.005 && + matrix.b === 0 && + matrix.c === 0 && + wrapper.clientWidth > 0 && + wrapper.clientHeight > 0 && + count >= 3 + ); + }, + { expectedZoom }, + { polling: "raf", timeout: 15_000 }, + ); +} + function capturePreviewGeometry(page: Page): Promise { return page.evaluate(() => { const active = document.querySelector('[aria-hidden="false"][data-resume-preview-template="rhyhorn"]'); if (!active) throw new Error("Missing active Rhyhorn preview layer."); - let viewportElement: HTMLElement | null = active; - while ( - viewportElement && - (viewportElement.clientWidth < window.innerWidth || viewportElement.clientHeight < window.innerHeight) - ) { - viewportElement = viewportElement.parentElement; + const viewportElement = document.querySelector(".react-transform-wrapper"); + const transformElement = document.querySelector(".react-transform-component"); + if (!viewportElement || !transformElement || !viewportElement.contains(active)) { + throw new Error("Missing preview transform wrapper or clipped active layer."); } - if (!viewportElement) throw new Error("Missing preview viewport clip."); const viewportRect = viewportElement.getBoundingClientRect(); + const transform = getComputedStyle(transformElement).transform; + const matrix = transform === "none" ? new DOMMatrixReadOnly() : new DOMMatrixReadOnly(transform); const pages = [...active.querySelectorAll('canvas[aria-label^="Resume page"]')].map((canvas) => { const wrapper = canvas.parentElement; if (!wrapper) throw new Error("Missing preview canvas wrapper."); @@ -284,12 +407,15 @@ function capturePreviewGeometry(page: Page): Promise { return { devicePixelRatio: window.devicePixelRatio, pages, + transform: { matrix: transform, scaleX: matrix.a, scaleY: matrix.d }, viewportClip: { clientHeight: viewportElement.clientHeight, clientWidth: viewportElement.clientWidth, height: viewportRect.height, scrollHeight: viewportElement.scrollHeight, scrollWidth: viewportElement.scrollWidth, + overflowX: getComputedStyle(viewportElement).overflowX, + overflowY: getComputedStyle(viewportElement).overflowY, width: viewportRect.width, x: viewportRect.x, y: viewportRect.y, @@ -300,79 +426,120 @@ function capturePreviewGeometry(page: Page): Promise { } function comparePreviewToPdf(page: Page, bytes: Uint8Array) { - return page.evaluate(async (serializedBytes) => { - const active = document.querySelector('[aria-hidden="false"][data-resume-preview-template="rhyhorn"]'); - const canvases = [...(active?.querySelectorAll('canvas[aria-label^="Resume page"]') ?? [])]; - if (canvases.length === 0) throw new Error("Missing active preview canvases for pixel comparison."); - const moduleUrl = `${location.origin}/__pdf_reference/pdf.mjs`; - const pdfjs: typeof import("pdfjs-dist/legacy/build/pdf.mjs") = await import(moduleUrl); - pdfjs.GlobalWorkerOptions.workerSrc = `${location.origin}/__pdf_reference/worker.mjs`; - const task = pdfjs.getDocument({ data: Uint8Array.from(serializedBytes) }); - try { - const pdfDocument = await task.promise; - if (pdfDocument.numPages !== canvases.length) throw new Error("Preview and downloaded PDF page counts differ."); - const pages = []; - for (const [index, canvas] of canvases.entries()) { - const actual = canvas.getContext("2d")?.getImageData(0, 0, canvas.width, canvas.height); - if (!actual) throw new Error("Missing active preview pixels."); - const pdfPage = await pdfDocument.getPage(index + 1); - const baseViewport = pdfPage.getViewport({ scale: 1 }); - const reference = globalThis.document.createElement("canvas"); - reference.width = canvas.width; - reference.height = canvas.height; - canvas.parentElement?.append(reference); - const context = reference.getContext("2d"); - if (!context) throw new Error("Missing reference canvas context."); - context.direction = "ltr"; - await pdfPage.render({ - canvas: reference, - canvasContext: context, - viewport: baseViewport, - transform: [4, 0, 0, 4, 0, 0], - annotationMode: pdfjs.AnnotationMode.DISABLE, - background: "white", - }).promise; - const expected = context.getImageData(0, 0, reference.width, reference.height); - let differentPixels = 0; - for (let offset = 0; offset < actual.data.length; offset += 4) { - if ( - actual.data[offset] !== expected.data[offset] || - actual.data[offset + 1] !== expected.data[offset + 1] || - actual.data[offset + 2] !== expected.data[offset + 2] || - actual.data[offset + 3] !== expected.data[offset + 3] - ) { - differentPixels += 1; + return page.evaluate( + async ({ channelTolerance, serializedBytes }) => { + const active = document.querySelector( + '[aria-hidden="false"][data-resume-preview-template="rhyhorn"]', + ); + const canvases = [...(active?.querySelectorAll('canvas[aria-label^="Resume page"]') ?? [])]; + if (canvases.length === 0) throw new Error("Missing active preview canvases for pixel comparison."); + const moduleUrl = `${location.origin}/__pdf_reference/pdf.mjs`; + const pdfjs: typeof import("pdfjs-dist/legacy/build/pdf.mjs") = await import(moduleUrl); + pdfjs.GlobalWorkerOptions.workerSrc = `${location.origin}/__pdf_reference/worker.mjs`; + const task = pdfjs.getDocument({ data: Uint8Array.from(serializedBytes) }); + try { + const pdfDocument = await task.promise; + if (pdfDocument.numPages !== canvases.length) throw new Error("Preview and downloaded PDF page counts differ."); + const pages = []; + for (const [index, canvas] of canvases.entries()) { + const actual = canvas.getContext("2d")?.getImageData(0, 0, canvas.width, canvas.height); + if (!actual) throw new Error("Missing active preview pixels."); + const pdfPage = await pdfDocument.getPage(index + 1); + const baseViewport = pdfPage.getViewport({ scale: 1 }); + const renderScale = canvas.width / baseViewport.width; + if (!Number.isFinite(renderScale) || renderScale <= 0) throw new Error("Missing preview render scale."); + const reference = globalThis.document.createElement("canvas"); + reference.width = canvas.width; + reference.height = canvas.height; + canvas.parentElement?.append(reference); + const context = reference.getContext("2d"); + if (!context) throw new Error("Missing reference canvas context."); + context.direction = "ltr"; + await pdfPage.render({ + canvas: reference, + canvasContext: context, + viewport: baseViewport, + transform: [renderScale, 0, 0, renderScale, 0, 0], + annotationMode: pdfjs.AnnotationMode.DISABLE, + background: "white", + }).promise; + const expected = context.getImageData(0, 0, reference.width, reference.height); + let differentPixels = 0; + let maxChannelDelta = 0; + for (let offset = 0; offset < actual.data.length; offset += 4) { + const redDelta = Math.abs(actual.data[offset] - expected.data[offset]); + const greenDelta = Math.abs(actual.data[offset + 1] - expected.data[offset + 1]); + const blueDelta = Math.abs(actual.data[offset + 2] - expected.data[offset + 2]); + const alphaDelta = Math.abs(actual.data[offset + 3] - expected.data[offset + 3]); + const channelDelta = Math.max(redDelta, greenDelta, blueDelta, alphaDelta); + maxChannelDelta = Math.max(maxChannelDelta, channelDelta); + if (channelDelta > channelTolerance) { + differentPixels += 1; + } } + pages.push({ + differentPixels, + differentPixelRatio: differentPixels / (canvas.width * canvas.height), + height: canvas.height, + maxChannelDelta, + width: canvas.width, + }); + reference.remove(); + pdfPage.cleanup(); } - pages.push({ differentPixels, height: canvas.height, width: canvas.width }); - reference.remove(); - pdfPage.cleanup(); + return { + pages, + }; + } finally { + await task.destroy(); } - return { - pages, - }; - } finally { - await task.destroy(); - } - }, Array.from(bytes)); + }, + { channelTolerance: RASTER_CHANNEL_TOLERANCE, serializedBytes: Array.from(bytes) }, + ); } function expectSamePdfGeometry(preview: PdfGeometry, downloaded: PdfGeometry, label: string) { + const expectWithinTolerance = (actual: number, expected: number, message: string) => { + expect(Math.abs(actual - expected), message).toBeLessThanOrEqual(PDF_COORDINATE_TOLERANCE); + }; + expect(downloaded.pageCount, `${label}: page count`).toBe(preview.pageCount); for (const [index, previewPage] of preview.pages.entries()) { const downloadedPage = downloaded.pages[index]; expect(downloadedPage, `${label}: page ${index + 1}`).toBeDefined(); if (!downloadedPage) continue; - expect(downloadedPage.mediaBox.width, `${label}: page ${index + 1} width`).toBeCloseTo( + expectWithinTolerance( + downloadedPage.mediaBox.width, previewPage.mediaBox.width, - 2, + `${label}: page ${index + 1} width`, ); - expect(downloadedPage.mediaBox.height, `${label}: page ${index + 1} height`).toBeCloseTo( + expectWithinTolerance( + downloadedPage.mediaBox.height, previewPage.mediaBox.height, - 2, + `${label}: page ${index + 1} height`, ); - expect(downloadedPage.inkBounds, `${label}: page ${index + 1} ink`).toEqual(previewPage.inkBounds); - expect(downloadedPage.sentinels, `${label}: page ${index + 1} sentinels`).toEqual(previewPage.sentinels); + expect(downloadedPage.inkBounds, `${label}: page ${index + 1} ink`).not.toBeNull(); + expect(previewPage.inkBounds, `${label}: preview page ${index + 1} ink`).not.toBeNull(); + for (const sentinel of ["TOP_SENTINEL", "BOTTOM_SENTINEL"]) { + const expected = previewPage.sentinels[sentinel]; + const actual = downloadedPage.sentinels[sentinel]; + expect(Boolean(actual), `${label}: page ${index + 1} ${sentinel} presence`).toBe(Boolean(expected)); + if (!expected || !actual) continue; + expectWithinTolerance(actual.bottomY, expected.bottomY, `${label}: page ${index + 1} ${sentinel} bottom`); + expectWithinTolerance(actual.topY, expected.topY, `${label}: page ${index + 1} ${sentinel} top`); + } + if (previewPage.inkBounds && downloadedPage.inkBounds) { + expectWithinTolerance( + downloadedPage.inkBounds.bottomY, + previewPage.inkBounds.bottomY, + `${label}: page ${index + 1} ink bottom`, + ); + expectWithinTolerance( + downloadedPage.inkBounds.topY, + previewPage.inkBounds.topY, + `${label}: page ${index + 1} ink top`, + ); + } } } @@ -397,40 +564,59 @@ async function runGeometryMatrix(page: Page, testInfo: TestInfo, scenarios: Geom dedicatedDatabaseUrl(); await page.setViewportSize({ width: 1920, height: 1000 }); await installPreviewCapture(page); - await createSampleResumeFromDashboard(page, testInfo); + const resumeName = await createSampleResumeFromDashboard(page, testInfo); + const expectedExportFilename = generateFilename(resumeName, "pdf"); const resumeId = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1]; if (!resumeId) throw new Error("Missing synthetic geometry resume id."); for (const scenario of scenarios) { const data = createFixture(scenario); - await updateFixture(resumeId, data); + const persistedFixture = await updateFixture(resumeId, data); const sourcePath = testInfo.outputPath(`${scenarioName(scenario)}.source.json`); await writeFile(sourcePath, JSON.stringify(data, null, 2)); await page.goto(`/builder/${resumeId}`); await expect(page.locator('section[aria-label="Resume content"]')).toContainText(scenarioName(scenario)); const { bytes: previewBytes, geometry: previewPdf } = await capturePreviewBytes(page, scenarioName(scenario)); + const loadedFixture = await readPersistedFixture(resumeId); + expect(loadedFixture.data, `${scenarioName(scenario)} full source JSON`).toEqual(data); + expect(loadedFixture.revision, `${scenarioName(scenario)} source revision`).toBe(persistedFixture.revision); await writeFile(testInfo.outputPath(`${scenarioName(scenario)}.preview.pdf`), previewBytes); const zoomReports: Array<{ previewGeometry: PreviewGeometry; downloadedPdf: PdfGeometry; zoom: number }> = []; for (const zoom of [75, 100, 115] as const) { await setZoom(page, zoom); - await page.waitForTimeout(500); + await waitForStablePreview(page, zoom / 100); const previewGeometry = await capturePreviewGeometry(page); expect(previewGeometry.devicePixelRatio).toBe(expectedDpr); expect(previewGeometry.viewport).toEqual({ height: 1000, width: 1920 }); - expect(previewGeometry.viewportClip.clientWidth).toBeGreaterThanOrEqual(1920); - expect(previewGeometry.viewportClip.clientHeight).toBeGreaterThanOrEqual(1000); + expect(previewGeometry.viewportClip.clientWidth).toBe(1920); + expect(previewGeometry.viewportClip.clientHeight).toBe(1000); + expect(previewGeometry.viewportClip.overflowX).toBe("hidden"); + expect(previewGeometry.viewportClip.overflowY).toBe("hidden"); expect(previewGeometry.viewportClip.scrollWidth).toBeGreaterThanOrEqual(1920); expect(previewGeometry.viewportClip.scrollHeight).toBeGreaterThanOrEqual(1000); + expect(Math.abs(previewGeometry.transform.scaleX - zoom / 100)).toBeLessThanOrEqual(TRANSFORM_SCALE_TOLERANCE); + expect(Math.abs(previewGeometry.transform.scaleY - zoom / 100)).toBeLessThanOrEqual(TRANSFORM_SCALE_TOLERANCE); + expect(previewGeometry.transform.matrix).toMatch(/^matrix/); + const currentFixture = await readPersistedFixture(resumeId); + expect(currentFixture.data, `${scenarioName(scenario)} zoom ${zoom} persisted source JSON`).toEqual(data); + expect(currentFixture.revision, `${scenarioName(scenario)} zoom ${zoom} persisted source revision`).toBe( + persistedFixture.revision, + ); const pending = page.waitForEvent("download"); await openSidebarSection(page, "Export"); await page.getByRole("button", { name: "Choose PDF, DOCX, Markdown, or JSON" }).click(); + await expect(page.getByRole("tab", { name: "Resume", exact: true })).toHaveAttribute("aria-selected", "true"); await page.getByRole("button", { name: "Download PDF", exact: true }).click(); const download = await pending; + expect(download.suggestedFilename(), `${scenarioName(scenario)} zoom ${zoom} export target`).toBe( + expectedExportFilename, + ); const downloadPath = testInfo.outputPath(`${scenarioName(scenario)}.zoom-${zoom}.pdf`); await download.saveAs(downloadPath); const downloadBytes = new Uint8Array(await readFile(downloadPath)); const downloadedPdf = await inspectPdf(downloadBytes); + assertPdfSurface(downloadedPdf, scenarioName(scenario), `${scenarioName(scenario)} downloaded PDF`); expectSamePdfGeometry(previewPdf, downloadedPdf, `${scenarioName(scenario)} zoom ${zoom}`); const sourcePixelDiff = await comparePreviewToPdf(page, previewBytes); const pixelDiff = await comparePreviewToPdf(page, downloadBytes); @@ -438,57 +624,63 @@ async function runGeometryMatrix(page: Page, testInfo: TestInfo, scenarios: Geom testInfo.outputPath(`${scenarioName(scenario)}.zoom-${zoom}.pixel-diff.json`), JSON.stringify({ downloaded: pixelDiff.pages, preview: sourcePixelDiff.pages }, null, 2), ); - expect(sourcePixelDiff.pages, `${scenarioName(scenario)} zoom ${zoom} active preview vs captured source`).toEqual( - previewGeometry.pages.map((page) => ({ - differentPixels: 0, - height: page.canvas.height, - width: page.canvas.width, - })), - ); - expect(pixelDiff.pages, `${scenarioName(scenario)} zoom ${zoom} active preview vs download`).toEqual( - previewGeometry.pages.map((page) => ({ - differentPixels: 0, - height: page.canvas.height, - width: page.canvas.width, - })), - ); - for (const pageGeometry of previewGeometry.pages) { - const pdfPage = previewPdf.pages[previewGeometry.pages.indexOf(pageGeometry)]; + for (const [index, pageGeometry] of previewGeometry.pages.entries()) { + const pdfPage = previewPdf.pages[index]; expect(pdfPage).toBeDefined(); if (!pdfPage) continue; - expect(pageGeometry.canvas.cssWidth, `${scenarioName(scenario)} CSS width`).toBeCloseTo( - pdfPage.mediaBox.width, - 1, + const renderScale = pageGeometry.canvas.width / pageGeometry.canvas.cssWidth; + expect(Math.abs(pageGeometry.canvas.cssWidth - pdfPage.mediaBox.width)).toBeLessThanOrEqual( + PDF_COORDINATE_TOLERANCE, ); - expect(pageGeometry.canvas.cssHeight, `${scenarioName(scenario)} CSS height`).toBeCloseTo( - pdfPage.mediaBox.height, - 1, + expect(Math.abs(pageGeometry.canvas.cssHeight - pdfPage.mediaBox.height)).toBeLessThanOrEqual( + PDF_COORDINATE_TOLERANCE, + ); + expect(renderScale, `${scenarioName(scenario)} measured render scale`).toBeGreaterThan(0); + expect(Math.abs(pageGeometry.canvas.width - pageGeometry.canvas.cssWidth * renderScale)).toBeLessThanOrEqual( + DOM_LAYOUT_TOLERANCE, + ); + expect(Math.abs(pageGeometry.canvas.height - pageGeometry.canvas.cssHeight * renderScale)).toBeLessThanOrEqual( + DOM_LAYOUT_TOLERANCE, ); expect( - Math.abs(pageGeometry.canvas.width - pageGeometry.canvas.cssWidth * 4), - `${scenarioName(scenario)} bitmap width`, - ).toBeLessThan(1); + Math.abs(pageGeometry.canvasRect.width - pdfPage.mediaBox.width * previewGeometry.transform.scaleX), + ).toBeLessThanOrEqual(DOM_LAYOUT_TOLERANCE); expect( - Math.abs(pageGeometry.canvas.height - pageGeometry.canvas.cssHeight * 4), - `${scenarioName(scenario)} bitmap height`, - ).toBeLessThan(1); - expect(pageGeometry.wrapper.width, `${scenarioName(scenario)} wrapper/canvas width`).toBeCloseTo( - pageGeometry.canvasRect.width, - 1, - ); - expect(pageGeometry.wrapper.height, `${scenarioName(scenario)} wrapper/canvas height`).toBeCloseTo( - pageGeometry.canvasRect.height, - 1, - ); + Math.abs(pageGeometry.canvasRect.height - pdfPage.mediaBox.height * previewGeometry.transform.scaleY), + ).toBeLessThanOrEqual(DOM_LAYOUT_TOLERANCE); expect( - pageGeometry.canvasRect.width / pageGeometry.canvas.cssWidth, - `${scenarioName(scenario)} zoom`, - ).toBeCloseTo(zoom / 100, 2); + Math.abs(pageGeometry.canvasRect.width / pageGeometry.canvas.cssWidth - previewGeometry.transform.scaleX), + ).toBeLessThanOrEqual(TRANSFORM_SCALE_TOLERANCE); expect(pageGeometry.wrapper.width, `${scenarioName(scenario)} wrapper width`).toBeGreaterThan(0); expect(pageGeometry.wrapper.height, `${scenarioName(scenario)} wrapper height`).toBeGreaterThan(0); expect(pageGeometry.canvas.width, `${scenarioName(scenario)} canvas bitmap width`).toBeGreaterThan(0); expect(pageGeometry.canvas.height, `${scenarioName(scenario)} canvas bitmap height`).toBeGreaterThan(0); } + for (const [label, diff] of [ + ["active preview vs captured source", sourcePixelDiff.pages], + ["active preview vs download", pixelDiff.pages], + ] as const) { + expect(diff, `${scenarioName(scenario)} zoom ${zoom} ${label} page count`).toHaveLength( + previewGeometry.pages.length, + ); + for (const [index, page] of diff.entries()) { + const maxPixels = Math.min( + RASTER_MISMATCH_PIXEL_LIMIT, + Math.ceil( + previewGeometry.pages[index].canvas.width * + previewGeometry.pages[index].canvas.height * + RASTER_MISMATCH_RATIO_LIMIT, + ), + ); + expect( + page.differentPixels, + `${scenarioName(scenario)} zoom ${zoom} ${label} page ${index + 1}`, + ).toBeLessThanOrEqual(maxPixels); + expect(page.differentPixelRatio, `${scenarioName(scenario)} zoom ${zoom} ${label} ratio`).toBeLessThanOrEqual( + RASTER_MISMATCH_RATIO_LIMIT, + ); + } + } zoomReports.push({ downloadedPdf, previewGeometry, zoom }); await page.keyboard.press("Escape"); }