From ce372b54bb5628999c33135153dd906ec9d1eeba Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sun, 6 Sep 2026 06:32:42 +0200 Subject: [PATCH 1/2] test: measure preview and PDF export geometry --- .../e2e/specs/preview-export-geometry.spec.ts | 529 ++++++++++++++++++ 1 file changed, 529 insertions(+) create mode 100644 tests/e2e/specs/preview-export-geometry.spec.ts diff --git a/tests/e2e/specs/preview-export-geometry.spec.ts b/tests/e2e/specs/preview-export-geometry.spec.ts new file mode 100644 index 000000000..d0ec7fe71 --- /dev/null +++ b/tests/e2e/specs/preview-export-geometry.spec.ts @@ -0,0 +1,529 @@ +import type { Page, TestInfo } from "@playwright/test"; +import { readFile, writeFile } from "node:fs/promises"; +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 { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +type CapturedPdfWindow = Window & { resumePdfBytes?: number[] }; + +type GeometryScenario = { + format: "a4" | "letter" | "free-form"; + marginY: number; + overflow: boolean; +}; + +type PdfPageGeometry = { + mediaBox: { height: number; width: number }; + inkBounds: { bottomY: number; topY: number } | null; + sentinels: Record; + text: string; +}; + +type PdfGeometry = { + pages: PdfPageGeometry[]; + pageCount: number; +}; + +type PreviewPageGeometry = { + canvas: { cssHeight: number; cssWidth: number; height: number; width: number }; + canvasRect: { height: number; width: number; x: number; y: number }; + wrapper: { clientHeight: number; clientWidth: number; height: number; width: number; x: number; y: number }; +}; + +type PreviewGeometry = { + devicePixelRatio: number; + pages: PreviewPageGeometry[]; + viewportClip: { + clientHeight: number; + clientWidth: number; + height: number; + scrollHeight: number; + scrollWidth: number; + width: number; + x: number; + y: number; + }; + viewport: { height: number; width: number }; +}; + +const FORMATS: GeometryScenario["format"][] = ["a4", "letter", "free-form"]; +const MARGINS = [defaultResumeData.metadata.page.marginY, 15]; +const SCENARIOS: GeometryScenario[] = FORMATS.flatMap((format) => + MARGINS.flatMap((marginY) => [ + { format, marginY, overflow: false }, + { format, marginY, overflow: true }, + ]), +); + +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 { + createCanvas: ( + width: number, + height: number, + ) => { + getContext: (type: "2d") => { + getImageData: (x: number, y: number, width: number, height: number) => { data: Uint8ClampedArray }; + }; + }; +}; + +function dedicatedDatabaseUrl() { + const value = process.env.DATABASE_URL; + if (!value) throw new Error("DATABASE_URL is required for preview/export geometry E2E."); + + const url = new URL(value); + if (!["localhost", "127.0.0.1"].includes(url.hostname) || url.port !== "55432") { + throw new Error( + "Refusing preview/export geometry E2E: DATABASE_URL must point at disposable PostgreSQL port 55432.", + ); + } + + return value; +} + +function scenarioName(scenario: GeometryScenario) { + return `GEOMETRY_${scenario.format.toUpperCase()}_M${scenario.marginY}_${scenario.overflow ? "OVERFLOW" : "FIT"}`; +} + +function createFixture(scenario: GeometryScenario) { + const data = structuredClone(defaultResumeData); + const name = scenarioName(scenario); + const paragraphs = scenario.overflow ? 40 : 4; + + data.picture.hidden = true; + data.picture.url = ""; + data.basics.name = name; + data.basics.headline = "Synthetic Helvetica geometry fixture"; + data.basics.email = ""; + data.basics.phone = ""; + data.basics.location = ""; + data.basics.website = { url: "", label: "" }; + data.basics.customFields = []; + data.summary.title = "Geometry"; + data.summary.content = [ + "

TOP_SENTINEL

", + ...Array.from( + { length: paragraphs }, + (_, index) => `

Geometry line ${index + 1}: deterministic Helvetica content for page measurements.

`, + ), + "

BOTTOM_SENTINEL

", + ].join(""); + data.sections = structuredClone(defaultResumeData.sections); + data.metadata.template = "rhyhorn"; + data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }]; + data.metadata.page = { + ...data.metadata.page, + format: scenario.format, + marginY: scenario.marginY, + }; + data.metadata.typography.body.fontFamily = "Helvetica"; + data.metadata.typography.heading.fontFamily = "Helvetica"; + data.metadata.notes = `preview-export-geometry:${name}`; + + return data; +} + +async function updateFixture(resumeId: string, data: ReturnType) { + 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", + [resumeId], + ); + if (result.rows[0]?.notes !== data.metadata.notes) throw new Error("Synthetic geometry fixture did not persist."); + } finally { + await pool.end(); + } +} + +async function installPreviewCapture(page: Page) { + await page.addInitScript(() => { + (window as CapturedPdfWindow).resumePdfBytes = undefined; + const originalArrayBuffer = Blob.prototype.arrayBuffer; + Blob.prototype.arrayBuffer = async function () { + const buffer = await originalArrayBuffer.call(this); + const bytes = new Uint8Array(buffer); + if (String.fromCharCode(...bytes.subarray(0, 5)) === "%PDF-") { + (window as CapturedPdfWindow).resumePdfBytes = Array.from(bytes); + } + return buffer; + }; + }); + + await page.route("**/__pdf_reference/*", async (route) => { + const worker = new URL(route.request().url()).pathname.endsWith("worker.mjs"); + await route.fulfill({ + contentType: "text/javascript", + path: requireWeb.resolve(`pdfjs-dist/legacy/build/${worker ? "pdf.worker.mjs" : "pdf.mjs"}`), + }); + }); +} + +async function inspectPdf(bytes: Uint8Array): Promise { + const loading = getDocument({ data: bytes.slice(), useSystemFonts: true }); + try { + const document = await loading.promise; + const pages: PdfPageGeometry[] = []; + for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) { + const page = await document.getPage(pageNumber); + const viewport = page.getViewport({ scale: 1 }); + const content = await page.getTextContent(); + const sentinels: PdfPageGeometry["sentinels"] = {}; + for (const item of content.items) { + if (!("str" in item) || !(item.str.includes("TOP_SENTINEL") || item.str.includes("BOTTOM_SENTINEL"))) continue; + const topY = viewport.height - item.transform[5] - item.height; + const bottomY = viewport.height - item.transform[5]; + if (item.str.includes("TOP_SENTINEL")) sentinels.TOP_SENTINEL = { bottomY, topY }; + if (item.str.includes("BOTTOM_SENTINEL")) sentinels.BOTTOM_SENTINEL = { bottomY, topY }; + } + + const width = Math.ceil(viewport.width); + const height = Math.ceil(viewport.height); + const canvas = createCanvas(width, height); + const context = canvas.getContext("2d"); + await page.render({ + canvas: canvas as unknown as HTMLCanvasElement, + canvasContext: context as unknown as CanvasRenderingContext2D, + viewport, + background: "white", + }).promise; + const pixels = context.getImageData(0, 0, width, height).data; + let topY = height; + let bottomY = -1; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const offset = (y * width + x) * 4; + if ( + (pixels[offset] ?? 255) >= 245 && + (pixels[offset + 1] ?? 255) >= 245 && + (pixels[offset + 2] ?? 255) >= 245 + ) { + continue; + } + topY = Math.min(topY, y); + bottomY = Math.max(bottomY, y); + } + } + + pages.push({ + mediaBox: { width: viewport.width, height: viewport.height }, + inkBounds: bottomY >= 0 ? { bottomY, topY } : null, + sentinels, + text: content.items.flatMap((item) => ("str" in item ? [item.str] : [])).join(" "), + }); + page.cleanup(); + } + return { pages, pageCount: document.numPages }; + } finally { + await loading.destroy(); + } +} + +async function capturePreviewBytes(page: Page, expectedText: string) { + let bytes: Uint8Array | undefined; + let geometry: PdfGeometry | undefined; + for (let attempt = 0; attempt < 80; attempt += 1) { + const captured = await page.evaluate(() => (window as CapturedPdfWindow).resumePdfBytes); + if (captured) { + const candidate = Uint8Array.from(captured); + const candidateGeometry = await inspectPdf(candidate); + if (candidateGeometry.pages.some((entry) => entry.text.includes(expectedText))) { + bytes = candidate; + geometry = candidateGeometry; + break; + } + } + await page.waitForTimeout(250); + } + if (!bytes || !geometry) throw new Error(`Active preview PDF did not settle on ${expectedText}.`); + return { bytes, geometry }; +} + +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; + } + if (!viewportElement) throw new Error("Missing preview viewport clip."); + const viewportRect = viewportElement.getBoundingClientRect(); + const pages = [...active.querySelectorAll('canvas[aria-label^="Resume page"]')].map((canvas) => { + const wrapper = canvas.parentElement; + if (!wrapper) throw new Error("Missing preview canvas wrapper."); + const canvasRect = canvas.getBoundingClientRect(); + const wrapperRect = wrapper.getBoundingClientRect(); + return { + canvas: { + cssHeight: Number.parseFloat(canvas.style.height), + cssWidth: Number.parseFloat(canvas.style.width), + height: canvas.height, + width: canvas.width, + }, + canvasRect: { height: canvasRect.height, width: canvasRect.width, x: canvasRect.x, y: canvasRect.y }, + wrapper: { + clientHeight: wrapper.clientHeight, + clientWidth: wrapper.clientWidth, + height: wrapperRect.height, + width: wrapperRect.width, + x: wrapperRect.x, + y: wrapperRect.y, + }, + }; + }); + if (pages.length === 0) throw new Error("Missing active preview canvases."); + return { + devicePixelRatio: window.devicePixelRatio, + pages, + viewportClip: { + clientHeight: viewportElement.clientHeight, + clientWidth: viewportElement.clientWidth, + height: viewportRect.height, + scrollHeight: viewportElement.scrollHeight, + scrollWidth: viewportElement.scrollWidth, + width: viewportRect.width, + x: viewportRect.x, + y: viewportRect.y, + }, + viewport: { height: window.innerHeight, width: window.innerWidth }, + }; + }); +} + +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; + } + } + pages.push({ differentPixels, height: canvas.height, width: canvas.width }); + reference.remove(); + pdfPage.cleanup(); + } + return { + pages, + }; + } finally { + await task.destroy(); + } + }, Array.from(bytes)); +} + +function expectSamePdfGeometry(preview: PdfGeometry, downloaded: PdfGeometry, label: string) { + 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( + previewPage.mediaBox.width, + 2, + ); + expect(downloadedPage.mediaBox.height, `${label}: page ${index + 1} height`).toBeCloseTo( + previewPage.mediaBox.height, + 2, + ); + expect(downloadedPage.inkBounds, `${label}: page ${index + 1} ink`).toEqual(previewPage.inkBounds); + expect(downloadedPage.sentinels, `${label}: page ${index + 1} sentinels`).toEqual(previewPage.sentinels); + } +} + +async function setZoom(page: Page, zoom: 75 | 100 | 115) { + const zoomLevel = page.getByRole("button", { name: "Zoom level", exact: true }); + if (zoom === 75) { + await expect(zoomLevel).toHaveText("75%"); + return; + } + if (zoom === 100) { + await zoomLevel.click(); + await page.getByRole("menuitem", { name: "Actual size (100%)", exact: true }).click(); + await expect(zoomLevel).toHaveText("100%"); + return; + } + await setZoom(page, 100); + await page.getByRole("button", { name: "Zoom in", exact: true }).click(); + await expect(zoomLevel).toHaveText("115%"); +} + +async function runGeometryMatrix(page: Page, testInfo: TestInfo, scenarios: GeometryScenario[], expectedDpr: number) { + dedicatedDatabaseUrl(); + await page.setViewportSize({ width: 1920, height: 1000 }); + await installPreviewCapture(page); + await createSampleResumeFromDashboard(page, testInfo); + 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 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)); + 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); + 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.scrollWidth).toBeGreaterThanOrEqual(1920); + expect(previewGeometry.viewportClip.scrollHeight).toBeGreaterThanOrEqual(1000); + const pending = page.waitForEvent("download"); + await openSidebarSection(page, "Export"); + await page.getByRole("button", { name: "Choose PDF, DOCX, Markdown, or JSON" }).click(); + await page.getByRole("button", { name: "Download PDF", exact: true }).click(); + const download = await pending; + 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); + expectSamePdfGeometry(previewPdf, downloadedPdf, `${scenarioName(scenario)} zoom ${zoom}`); + const sourcePixelDiff = await comparePreviewToPdf(page, previewBytes); + const pixelDiff = await comparePreviewToPdf(page, downloadBytes); + await writeFile( + 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)]; + expect(pdfPage).toBeDefined(); + if (!pdfPage) continue; + expect(pageGeometry.canvas.cssWidth, `${scenarioName(scenario)} CSS width`).toBeCloseTo( + pdfPage.mediaBox.width, + 1, + ); + expect(pageGeometry.canvas.cssHeight, `${scenarioName(scenario)} CSS height`).toBeCloseTo( + pdfPage.mediaBox.height, + 1, + ); + expect( + Math.abs(pageGeometry.canvas.width - pageGeometry.canvas.cssWidth * 4), + `${scenarioName(scenario)} bitmap width`, + ).toBeLessThan(1); + 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, + ); + expect( + pageGeometry.canvasRect.width / pageGeometry.canvas.cssWidth, + `${scenarioName(scenario)} zoom`, + ).toBeCloseTo(zoom / 100, 2); + 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); + } + zoomReports.push({ downloadedPdf, previewGeometry, zoom }); + await page.keyboard.press("Escape"); + } + + const report = { + devicePixelRatio: await page.evaluate(() => window.devicePixelRatio), + format: scenario.format, + marginY: scenario.marginY, + overflow: scenario.overflow, + previewPdf, + zoomReports, + }; + await writeFile(testInfo.outputPath(`${scenarioName(scenario)}.metrics.json`), JSON.stringify(report, null, 2)); + } +} + +test.describe("preview/export geometry at DPR1", () => { + test.use({ deviceScaleFactor: 1 }); + + test("matches active preview and downloaded PDF across synthetic Rhyhorn matrix", async ({ authPage }, testInfo) => { + test.setTimeout(600_000); + await runGeometryMatrix(authPage, testInfo, SCENARIOS, 1); + }); +}); + +test.describe("preview/export geometry at DPR2", () => { + test.use({ deviceScaleFactor: 2 }); + + test("matches active preview and downloaded PDF at higher device pixel ratio", async ({ authPage }, testInfo) => { + test.setTimeout(180_000); + await runGeometryMatrix( + authPage, + testInfo, + [{ format: "a4", marginY: defaultResumeData.metadata.page.marginY, overflow: true }], + 2, + ); + }); +}); From 5f5dca8445f06d7189c8fe95ca1ef5a5c28ea3f2 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sun, 6 Sep 2026 06:59:00 +0200 Subject: [PATCH 2/2] 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"); }