fix(web): render thumbnails at displayed pixel density (#3454)

* fix(web): render thumbnails at displayed pixel density

* fix: cancel obsolete thumbnail raster work
This commit is contained in:
Amruth Pillai
2026-09-05 18:31:21 -07:00
committed by GitHub
parent a4bdc54b2c
commit 2e711fd14c
7 changed files with 449 additions and 74 deletions
@@ -1,5 +1,6 @@
import type { PreviewPageSize } from "./preview.shared.utils";
import { getResumeThumbnailRenderSize, RESUME_THUMBNAIL_TARGET_WIDTH } from "./resume-thumbnail.shared";
import type { ResumeThumbnailSize } from "./resume-thumbnail.shared";
import { getResumeThumbnailRenderSize } from "./resume-thumbnail.shared";
const canvasToBlob = (canvas: HTMLCanvasElement) =>
new Promise<Blob>((resolve, reject) => {
@@ -13,26 +14,35 @@ const canvasToBlob = (canvas: HTMLCanvasElement) =>
}, "image/png");
});
export const createPdfFirstPageImageUrl = async (file: Blob) => {
export const createPdfFirstPageImageUrl = async (file: Blob, targetSize: ResumeThumbnailSize, signal?: AbortSignal) => {
signal?.throwIfAborted();
const { AnnotationMode, GlobalWorkerOptions, getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
const arrayBuffer = await file.arrayBuffer();
signal?.throwIfAborted();
const loadingTask = getDocument({ data: new Uint8Array(arrayBuffer) });
let pdfDocument: Awaited<typeof loadingTask.promise> | undefined;
let renderTask: { cancel: () => void } | undefined;
let destruction: Promise<void> | undefined;
const destroy = () => (destruction ??= loadingTask.destroy());
const abort = () => {
renderTask?.cancel();
void destroy();
};
signal?.addEventListener("abort", abort, { once: true });
try {
signal?.throwIfAborted();
pdfDocument = await loadingTask.promise;
signal?.throwIfAborted();
const page = await pdfDocument.getPage(1);
try {
signal?.throwIfAborted();
const baseViewport = page.getViewport({ scale: 1 });
const pageSize: PreviewPageSize = { height: baseViewport.height, width: baseViewport.width };
const renderSize = getResumeThumbnailRenderSize(
pageSize,
RESUME_THUMBNAIL_TARGET_WIDTH,
window.devicePixelRatio || 1,
);
const renderSize = getResumeThumbnailRenderSize(pageSize, targetSize);
const canvas = document.createElement("canvas");
const canvasContext = canvas.getContext("2d");
@@ -43,22 +53,29 @@ export const createPdfFirstPageImageUrl = async (file: Blob) => {
canvas.width = renderSize.width;
const viewport = page.getViewport({ scale: renderSize.scale });
const renderTask = page.render({
const task = page.render({
canvas,
canvasContext,
viewport,
annotationMode: AnnotationMode.DISABLE,
background: "white",
});
renderTask = task;
await renderTask.promise;
await task.promise;
signal?.throwIfAborted();
const image = await canvasToBlob(canvas);
signal?.throwIfAborted();
return URL.createObjectURL(image);
} finally {
page.cleanup();
}
} catch (error) {
if (signal?.aborted) throw new DOMException("Thumbnail generation aborted.", "AbortError");
throw error;
} finally {
void loadingTask.destroy();
signal?.removeEventListener("abort", abort);
void destroy();
}
};
@@ -6,7 +6,7 @@ const pdfjsMock = vi.hoisted(() => {
const page = {
cleanup: vi.fn(),
getViewport: vi.fn(({ scale }: { scale: number }) => ({ height: 200 * scale, width: 100 * scale })),
render: vi.fn(() => ({ promise: Promise.resolve() })),
render: vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() })),
};
const pdfDocument = {
@@ -15,7 +15,7 @@ const pdfjsMock = vi.hoisted(() => {
};
const loadingTask = {
destroy: vi.fn(),
destroy: vi.fn(async () => {}),
promise: Promise.resolve(pdfDocument),
};
@@ -85,9 +85,9 @@ describe("PDF.js browser entrypoints", () => {
const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail");
await expect(createPdfFirstPageImageUrl(new Blob(["%PDF"], { type: "application/pdf" }))).resolves.toBe(
"blob:thumbnail",
);
await expect(
createPdfFirstPageImageUrl(new Blob(["%PDF"], { type: "application/pdf" }), { width: 600, height: 900 }),
).resolves.toBe("blob:thumbnail");
expect(pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc).toContain(
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
@@ -100,4 +100,39 @@ describe("PDF.js browser entrypoints", () => {
);
expect(pdfjsMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
});
it("cancels obsolete thumbnail rasterization and releases the PDF loading task", async () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as CanvasRenderingContext2D);
vi.spyOn(URL, "createObjectURL").mockImplementation(pdfjsMock.createObjectURL);
let rejectRender!: (error: Error) => void;
const promise = new Promise<never>((_resolve, reject) => {
rejectRender = reject;
});
const cancel = vi.fn(() => rejectRender(new pdfjsMock.legacyModule.RenderingCancelledException("cancelled")));
pdfjsMock.page.render.mockReturnValueOnce({ promise, cancel });
const controller = new AbortController();
const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail");
const pending = createPdfFirstPageImageUrl(new Blob(["%PDF"]), { width: 600, height: 900 }, controller.signal);
const rejected = expect(pending).rejects.toMatchObject({ name: "AbortError" });
await vi.waitFor(() => expect(pdfjsMock.page.render).toHaveBeenCalledTimes(1));
try {
controller.abort();
expect(cancel).toHaveBeenCalledTimes(1);
} finally {
rejectRender(new DOMException("cancelled", "AbortError"));
await rejected;
}
expect(pdfjsMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
expect(pdfjsMock.createObjectURL).not.toHaveBeenCalled();
});
it("does not load a PDF for an already aborted thumbnail request", async () => {
const controller = new AbortController();
controller.abort();
const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail");
await expect(
createPdfFirstPageImageUrl(new Blob(["%PDF"]), { width: 600, height: 900 }, controller.signal),
).rejects.toMatchObject({ name: "AbortError" });
expect(pdfjsMock.legacyModule.getDocument).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import {
getResumeThumbnailCacheKey,
getResumeThumbnailRenderSize,
RESUME_THUMBNAIL_TARGET_WIDTH,
getResumeThumbnailSize,
} from "./resume-thumbnail.shared";
describe("getResumeThumbnailCacheKey", () => {
@@ -20,45 +20,40 @@ describe("getResumeThumbnailCacheKey", () => {
});
describe("getResumeThumbnailRenderSize", () => {
it("uses the default target width when not provided", () => {
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 });
expect(size.width).toBe(RESUME_THUMBNAIL_TARGET_WIDTH);
// Scale = 420/800 = 0.525 → height rounds to 630
expect(size.height).toBe(630);
expect(size.scale).toBeCloseTo(0.525, 5);
it("covers the measured 607 CSS pixel card at DPR 3", () => {
const target = getResumeThumbnailSize({ width: 607, height: 858.47 }, 3);
const size = getResumeThumbnailRenderSize({ width: 595.28, height: 841.89 }, target);
expect(size.width).toBeGreaterThanOrEqual(1821);
expect(size.height).toBeGreaterThanOrEqual(2575);
});
it("scales relative to the provided target width", () => {
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400);
expect(size.width).toBe(400);
expect(size.scale).toBeCloseTo(0.5, 5);
expect(size.height).toBe(600);
it("fits landscape pages within the measured container", () => {
const size = getResumeThumbnailRenderSize({ width: 1200, height: 800 }, { width: 600, height: 900 });
expect(size).toEqual({ width: 600, height: 400, scale: 0.5 });
});
it("clamps pixelRatio to a minimum of 1", () => {
const a = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 0.5);
const b = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 1);
it("fits tall unpaginated pages by height rather than allocating their full width", () => {
const size = getResumeThumbnailRenderSize({ width: 600, height: 12000 }, { width: 600, height: 900 });
expect(size).toEqual({ width: 45, height: 900, scale: 0.075 });
});
it("bounds extreme zoom and viewport sizes without stretching the page", () => {
const target = getResumeThumbnailSize({ width: 5000, height: 7071 }, 8);
const size = getResumeThumbnailRenderSize({ width: 595.28, height: 841.89 }, target);
expect(size.width * size.height).toBeLessThanOrEqual(2048 * 3072);
expect(Math.max(size.width, size.height)).toBeLessThanOrEqual(3072);
expect(size.width / size.height).toBeCloseTo(595.28 / 841.89, 2);
});
it("enforces the canvas budget when retained width and height came from different size buckets", () => {
const size = getResumeThumbnailRenderSize({ width: 595.28, height: 841.89 }, { width: 2112, height: 2985 });
expect(size.width * size.height).toBeLessThanOrEqual(2048 * 3072);
});
it("groups small fractional layout changes into the same physical size", () => {
const a = getResumeThumbnailSize({ width: 295.5, height: 417.93 }, 2);
const b = getResumeThumbnailSize({ width: 296, height: 418.64 }, 2);
expect(a).toEqual(b);
});
it("clamps pixelRatio to a maximum of 2", () => {
const a = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 3);
const b = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 2);
expect(a).toEqual(b);
});
it("multiplies width/height by pixelRatio when within bounds", () => {
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 2);
// pageScale = 0.5, outputScale = 2 → width = 800, height = 1200
expect(size.width).toBe(800);
expect(size.height).toBe(1200);
expect(size.scale).toBeCloseTo(1, 5);
});
it("rounds the output dimensions to integers", () => {
const size = getResumeThumbnailRenderSize({ width: 793, height: 1123 }, 421, 1.5);
expect(Number.isInteger(size.width)).toBe(true);
expect(Number.isInteger(size.height)).toBe(true);
expect(a.width).toBeGreaterThanOrEqual(592);
});
});
@@ -1,26 +1,45 @@
type PageSize = {
export type ResumeThumbnailSize = {
height: number;
width: number;
};
export const RESUME_THUMBNAIL_TARGET_WIDTH = 420;
const MAX_THUMBNAIL_PIXEL_RATIO = 2;
// A single-column Grid card is at most 608 CSS px before the sm breakpoint.
// Its A4 container at DPR 3 needs 1824 x 2580 px. This budget accommodates it
// with room for size buckets, while limiting extreme zoom/ultrawide canvases
// to 24 MiB of RGBA pixels and 3072 px on either side.
const MAX_THUMBNAIL_PIXELS = 2048 * 3072;
const MAX_THUMBNAIL_DIMENSION = 3072;
const THUMBNAIL_SIZE_STEP = 64;
export const getResumeThumbnailCacheKey = (resumeId: string, updatedAt: Date) => {
return `${resumeId}:${updatedAt.getTime()}`;
};
export const getResumeThumbnailRenderSize = (
pageSize: PageSize,
targetWidth = RESUME_THUMBNAIL_TARGET_WIDTH,
pixelRatio = 1,
) => {
const outputScale = Math.min(Math.max(pixelRatio, 1), MAX_THUMBNAIL_PIXEL_RATIO);
const pageScale = targetWidth / pageSize.width;
const limitThumbnailSize = ({ width, height }: ResumeThumbnailSize): ResumeThumbnailSize => {
const scale = Math.min(
1,
MAX_THUMBNAIL_DIMENSION / Math.max(width, height),
Math.sqrt(MAX_THUMBNAIL_PIXELS / (width * height)),
);
return { width: Math.floor(width * scale), height: Math.floor(height * scale) };
};
export const getResumeThumbnailSize = (container: ResumeThumbnailSize, pixelRatio: number): ResumeThumbnailSize => {
const ratio = Number.isFinite(pixelRatio) && pixelRatio > 0 ? pixelRatio : 1;
return limitThumbnailSize({
width: Math.ceil((container.width * ratio) / THUMBNAIL_SIZE_STEP) * THUMBNAIL_SIZE_STEP,
height: Math.ceil((container.height * ratio) / THUMBNAIL_SIZE_STEP) * THUMBNAIL_SIZE_STEP,
});
};
export const getResumeThumbnailRenderSize = (pageSize: ResumeThumbnailSize, target: ResumeThumbnailSize) => {
const bounds = limitThumbnailSize(target);
// Match background-size: contain, including landscape and unpaginated PDFs.
const scale = Math.min(bounds.width / pageSize.width, bounds.height / pageSize.height);
return {
height: Math.round(pageSize.height * pageScale * outputScale),
scale: pageScale * outputScale,
width: Math.round(targetWidth * outputScale),
height: Math.min(bounds.height, Math.ceil(pageSize.height * scale)),
scale,
width: Math.min(bounds.width, Math.ceil(pageSize.width * scale)),
};
};
@@ -0,0 +1,157 @@
// @vitest-environment happy-dom
import type { ComponentProps } from "react";
import { act, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { ResumeThumbnail } from "./resume-thumbnail";
const mocks = vi.hoisted(() => ({
inView: true,
toPdf: vi.fn(async () => new Blob(["pdf"])),
toImage: vi.fn(async (_pdf: Blob, size?: { width: number; height: number }) => `blob:${size?.width}x${size?.height}`),
}));
vi.mock("motion/react", () => ({ useInView: () => mocks.inView }));
vi.mock("@/features/resume/export/pdf-document", () => ({ createResumePdfBlob: mocks.toPdf }));
vi.mock("@/features/resume/preview/pdf-thumbnail", () => ({ createPdfFirstPageImageUrl: mocks.toImage }));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
getById: {
queryOptions: () => ({
queryKey: ["resume-data"],
queryFn: async () => ({ data: sampleResumeData }),
}),
},
},
},
}));
const resume: ComponentProps<typeof ResumeThumbnail>["resume"] = {
id: "thumbnail-test",
name: "Resume",
slug: "resume",
tags: [],
isLocked: false,
isPublic: false,
showDownloadButtons: true,
createdAt: new Date(0),
updatedAt: new Date(0),
};
let resize: () => void;
let media: EventTarget;
let width = 270;
let height = 382;
beforeEach(() => {
mocks.inView = true;
mocks.toPdf.mockClear();
mocks.toImage.mockClear();
width = 270;
height = 382;
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(() => width);
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(() => height);
vi.stubGlobal("devicePixelRatio", 2);
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: () => void) {
resize = callback;
}
observe() {}
disconnect() {}
},
);
vi.spyOn(window, "matchMedia").mockImplementation(() => {
media = new EventTarget();
return media as MediaQueryList;
});
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function setup() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
});
const ui = () => (
<QueryClientProvider client={client}>
<ResumeThumbnail resume={resume} isLocked={false} />
</QueryClientProvider>
);
const result = render(ui());
const image = () => result.container.querySelector<HTMLElement>("[style*='background-image']")?.style.backgroundImage;
return { ...result, image, refresh: () => result.rerender(ui()) };
}
it("upgrades a cached image after growth and keeps it visible until replacement is ready", async () => {
const view = setup();
await waitFor(() => expect(view.image()).toContain("blob:576x768"));
let finish!: (url: string) => void;
mocks.toImage.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
finish = resolve;
}),
);
act(() => {
width = 607;
height = 859;
resize();
});
await waitFor(() => expect(mocks.toImage).toHaveBeenCalledTimes(2));
expect(view.image()).toContain("blob:576x768");
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
expect(mocks.toImage.mock.calls[1]?.[1]).toEqual({ width: 1216, height: 1728 });
await act(async () => finish("blob:1216x1728"));
await waitFor(() => expect(view.image()).toContain("blob:1216x1728"));
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:576x768");
act(() => {
width = 295;
height = 418;
resize();
});
await new Promise((resolve) => setTimeout(resolve, 250));
expect(view.image()).toContain("blob:1216x1728");
expect(mocks.toImage).toHaveBeenCalledTimes(2);
view.unmount();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:1216x1728");
});
it("upgrades after a DPR change even when CSS dimensions do not change", async () => {
const view = setup();
await waitFor(() => expect(view.image()).toContain("blob:576x768"));
act(() => {
vi.stubGlobal("devicePixelRatio", 3);
media.dispatchEvent(new Event("change"));
});
await waitFor(() => expect(view.image()).toContain("blob:832x1152"));
});
it("does not generate thumbnails before an offscreen card enters view", async () => {
mocks.inView = false;
const view = setup();
await act(async () => {});
expect(view.image()).toBeUndefined();
expect(mocks.toPdf).not.toHaveBeenCalled();
mocks.inView = true;
view.refresh();
await waitFor(() => expect(view.image()).toContain("blob:576x768"));
mocks.inView = false;
view.refresh();
act(() => {
width = 607;
height = 859;
});
await new Promise((resolve) => setTimeout(resolve, 250));
expect(mocks.toImage).toHaveBeenCalledTimes(1);
mocks.inView = true;
view.refresh();
await waitFor(() => expect(view.image()).toContain("blob:1216x1728"));
});
@@ -1,14 +1,16 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { RefObject } from "react";
import type { ResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared";
import type { RouterOutput } from "@/libs/orpc/client";
import { FileTextIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useInView } from "motion/react";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { createPdfFirstPageImageUrl } from "@/features/resume/preview/pdf-thumbnail";
import { getResumeThumbnailCacheKey } from "@/features/resume/preview/resume-thumbnail.shared";
import { getResumeThumbnailCacheKey, getResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared";
import { orpc } from "@/libs/orpc/client";
type ResumeListItem = RouterOutput["resume"]["list"][number];
@@ -24,11 +26,11 @@ const throwIfAborted = (signal: AbortSignal) => {
if (signal.aborted) throw new DOMException("Thumbnail generation aborted.", "AbortError");
};
const createResumeThumbnailUrl = async (data: ResumeData, signal: AbortSignal) => {
const createResumeThumbnailUrl = async (data: ResumeData, size: ResumeThumbnailSize, signal: AbortSignal) => {
const pdf = await createResumePdfBlob(data);
throwIfAborted(signal);
const url = await createPdfFirstPageImageUrl(pdf);
const url = await createPdfFirstPageImageUrl(pdf, size, signal);
if (signal.aborted) {
URL.revokeObjectURL(url);
@@ -38,18 +40,69 @@ const createResumeThumbnailUrl = async (data: ResumeData, signal: AbortSignal) =
return url;
};
function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | undefined): ThumbnailState {
function useThumbnailSize(containerRef: RefObject<HTMLDivElement | null>, enabled: boolean) {
const [size, setSize] = useState<ResumeThumbnailSize>({ width: 0, height: 0 });
useEffect(() => {
const container = containerRef.current;
if (!enabled || !container) return;
let measured = { width: container.clientWidth, height: container.clientHeight };
let timeout: ReturnType<typeof setTimeout>;
let media: MediaQueryList;
const syncSize = () => {
clearTimeout(timeout);
// Coalesce sidebar animations and continuous viewport resizing.
timeout = setTimeout(() => {
if (measured.width <= 0 || measured.height <= 0) return;
const next = getResumeThumbnailSize(measured, window.devicePixelRatio);
setSize((current) => {
if (current.width >= next.width && current.height >= next.height) return current;
return { width: Math.max(current.width, next.width), height: Math.max(current.height, next.height) };
});
}, 150);
};
const watchPixelRatio = () => {
media?.removeEventListener("change", watchPixelRatio);
media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
media.addEventListener("change", watchPixelRatio);
syncSize();
};
const observer = new ResizeObserver((entries) => {
measured = entries?.[0]?.contentRect ?? { width: container.clientWidth, height: container.clientHeight };
syncSize();
});
observer.observe(container);
watchPixelRatio();
return () => {
clearTimeout(timeout);
observer.disconnect();
media.removeEventListener("change", watchPixelRatio);
};
}, [containerRef, enabled]);
return size;
}
function useResumeThumbnail(
data: ResumeData | undefined,
cacheKey: string,
size: ResumeThumbnailSize,
enabled: boolean,
): ThumbnailState {
const {
data: thumbnailData,
error: thumbnailError,
isError: thumbnailIsError,
} = useQuery({
queryKey: ["resume-thumbnail", cacheKey],
queryKey: ["resume-thumbnail", cacheKey, size.width, size.height],
queryFn: ({ signal }) => {
if (!data) throw new Error("Resume data is required to generate a thumbnail.");
return createResumeThumbnailUrl(data, signal);
return createResumeThumbnailUrl(data, size, signal);
},
enabled: Boolean(data && cacheKey),
enabled: Boolean(enabled && data && size.width && size.height),
placeholderData: (previous, query) => (query?.queryKey[1] === cacheKey ? previous : undefined),
staleTime: Number.POSITIVE_INFINITY,
gcTime: 0,
});
@@ -74,14 +127,17 @@ function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | und
export function ResumeThumbnail({ isLocked, resume }: ResumeThumbnailProps) {
const containerRef = useRef<HTMLDivElement>(null);
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px", once: true });
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px" });
const size = useThumbnailSize(containerRef, isInView);
const { data: resumeData, isError: resumeIsError } = useQuery({
...orpc.resume.getById.queryOptions({ input: { id: resume.id } }),
enabled: isInView,
});
const thumbnail = useResumeThumbnail(
resumeData?.data,
isInView ? getResumeThumbnailCacheKey(resume.id, resume.updatedAt) : undefined,
getResumeThumbnailCacheKey(resume.id, resume.updatedAt),
size,
isInView,
);
const hasFailed = resumeIsError || thumbnail.status === "error";
@@ -0,0 +1,96 @@
import type { Page } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import { createSampleResumeFromDashboard } from "../fixtures/resume";
import { expect, test } from "../fixtures/test";
async function measureThumbnail(page: Page) {
const image = page.locator('a[href^="/builder/"] [style*="background-image"]').first();
await expect(image).toBeVisible({ timeout: 60_000 });
return image.evaluate(async (element) => {
const style = getComputedStyle(element);
const url = style.backgroundImage.slice(5, -2);
const blob = await (await fetch(url)).blob();
const bitmap = await createImageBitmap(blob);
const rect = element.getBoundingClientRect();
const scale = Math.min(rect.width / bitmap.width, rect.height / bitmap.height);
const result = {
url,
viewport: innerWidth,
dpr: devicePixelRatio,
cssWidth: rect.width,
cssHeight: rect.height,
pngWidth: bitmap.width,
pngHeight: bitmap.height,
coverage: 1 / (scale * devicePixelRatio),
};
bitmap.close();
return result;
});
}
async function expectSharpThumbnail(page: Page) {
await page.mouse.move(0, 0);
await expect
.poll(async () => (await measureThumbnail(page)).coverage, { timeout: 60_000 })
.toBeGreaterThanOrEqual(0.99);
return measureThumbnail(page);
}
test("dashboard thumbnails cover physical pixels in fresh and cached Grid and Compact views", async ({
authPage,
browser,
}, info) => {
test.setTimeout(180_000);
await createSampleResumeFromDashboard(authPage, info);
const storageState = await authPage.context().storageState();
const measurements: unknown[] = [];
for (const deviceScaleFactor of [1, 2, 3]) {
const context = await browser.newContext({
baseURL: String(info.project.use.baseURL),
storageState,
deviceScaleFactor,
viewport: { width: 1440, height: 1000 },
});
try {
const page = await context.newPage();
await page.goto("/dashboard/resumes?view=grid");
const desktop = await expectSharpThumbnail(page);
measurements.push({ scenario: "fresh-desktop-grid", ...desktop });
// Immediately below sm, one Grid column is 607 CSS px wide.
await page.setViewportSize({ width: 639, height: 1000 });
const grown = await expectSharpThumbnail(page);
expect(grown.pngWidth).toBeGreaterThan(desktop.pngWidth);
measurements.push({ scenario: "grown-cached-grid", ...grown });
await page.screenshot({ path: info.outputPath(`grid-dpr${deviceScaleFactor}.png`) });
await page.getByRole("tab", { name: "Compact", exact: true }).click();
const compact = await expectSharpThumbnail(page);
expect(compact.url).toBe(grown.url);
measurements.push({ scenario: "cached-compact", ...compact });
await page.reload();
measurements.push({ scenario: "fresh-compact", ...(await expectSharpThumbnail(page)) });
await page.getByRole("tab", { name: "Grid", exact: true }).click();
measurements.push({ scenario: "compact-to-grid", ...(await expectSharpThumbnail(page)) });
await page.reload();
measurements.push({ scenario: "fresh-mobile-grid", ...(await expectSharpThumbnail(page)) });
await page.setViewportSize({ width: 390, height: 1000 });
const phone = await expectSharpThumbnail(page);
measurements.push({ scenario: "phone-grid", ...phone });
if (deviceScaleFactor === 1) {
const cdp = await context.newCDPSession(page);
await cdp.send("Emulation.setDeviceMetricsOverride", {
width: 390,
height: 1000,
deviceScaleFactor: 3,
mobile: false,
});
await expect.poll(() => page.evaluate(() => devicePixelRatio)).toBe(3);
const higherDpr = await expectSharpThumbnail(page);
expect(higherDpr.pngWidth).toBeGreaterThan(phone.pngWidth);
measurements.push({ scenario: "changed-dpr-grid", ...higherDpr });
}
} finally {
await context.close();
}
}
await writeFile(info.outputPath("measurements.json"), JSON.stringify(measurements, null, 2));
});