fix(cli): preserve CSS priority during full-page capture (#5316)

This commit is contained in:
Abdullah M. Nazif
2026-10-24 07:31:52 +00:00
committed by GitHub
parent 94a265e486
commit 0e4a0ffbab
2 changed files with 144 additions and 5 deletions
@@ -0,0 +1,137 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runInNewContext } from "node:vm";
import { Window } from "happy-dom";
import type { Page, ScreenshotOptions } from "puppeteer-core";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { captureFullPagePlate, MAX_PLATE_HEIGHT_PX } from "./screenshotCapture.js";
function fixture(css: string, inline = "", height = 640) {
const window = new Window({ settings: { disableJavaScriptEvaluation: true } });
window.document.head.innerHTML = `<style>.nav{position:fixed}${css}</style>`;
window.document.body.innerHTML = `<nav class="nav" style="${inline}"></nav>`;
const nav = window.document.querySelector("nav");
if (!nav) throw new Error("Missing fixture navigation");
const state = () => ({
computed: window.getComputedStyle(nav).position,
value: nav.style.getPropertyValue("position"),
priority: nav.style.getPropertyPriority("position"),
});
const original = state();
const evaluate: Page["evaluate"] = async (script, ...args) => {
if (String(script).includes("scrollHeight")) return height;
return runInNewContext(
typeof script === "string" ? script : `(${script.toString()})(...args)`,
{
document: window.document,
getComputedStyle: window.getComputedStyle.bind(window),
args,
},
);
};
const buffer = Buffer.alloc(24);
buffer.write("IHDR", 12, "ascii");
buffer.writeUInt32BE(height, 20);
let captured: ReturnType<typeof state> | undefined;
const screenshot = vi.fn(async (_options?: Readonly<ScreenshotOptions>) => {
captured = state();
return buffer;
});
function takeScreenshot(
options: Readonly<ScreenshotOptions> & { encoding: "base64" },
): Promise<string>;
function takeScreenshot(options?: Readonly<ScreenshotOptions>): Promise<Uint8Array>;
async function takeScreenshot(
options?: Readonly<ScreenshotOptions>,
): Promise<string | Uint8Array> {
const bytes = await screenshot(options);
return options?.encoding === "base64" ? bytes.toString("base64") : bytes;
}
const page = { evaluate, screenshot: takeScreenshot };
const dir = mkdtempSync(join(tmpdir(), "hf-plate-priority-"));
onTestFinished(async () => {
await window.happyDOM.close();
rmSync(dir, { recursive: true, force: true });
});
return {
page,
dir,
nav,
screenshot,
captured: () => captured,
expectRestored: () => {
expect(state()).toEqual(original);
expect(
Array.from(nav.attributes).filter((attribute) =>
attribute.name.startsWith("data-hf-plate"),
),
).toEqual([]);
},
};
}
describe("full-page capture CSS priority", () => {
it.each([
["important fixed stylesheet", ".nav{position:fixed!important}", ""],
["important sticky stylesheet", ".nav{position:sticky!important}", ""],
[
"important inline fixed position",
".nav{position:relative!important}",
"position:fixed!important",
],
[
"important inline sticky position",
".nav{position:relative!important}",
"position:sticky!important",
],
])("neutralizes %s and restores its original declaration", async (_name, css, inline) => {
const f = fixture(css, inline);
expect(await captureFullPagePlate(f.page, f.dir)).toEqual({
kind: "captured",
file: "screenshots/full-page.png",
});
expect(f.captured()?.computed).toBe("static");
f.expectRestored();
});
it("restores priority when the screenshot fails", async () => {
const f = fixture(".nav{position:relative!important}", "position:fixed!important");
f.screenshot.mockRejectedValue(new Error("Screenshot failed"));
await expect(captureFullPagePlate(f.page, f.dir)).rejects.toThrow("Screenshot failed");
f.expectRestored();
});
it.each([
["height-limit", MAX_PLATE_HEIGHT_PX + 1, 1000],
["budget-exhausted", 640, 0],
])("restores priority when the plate is omitted for %s", async (reason, height, remainingMs) => {
const f = fixture(".nav{position:relative!important}", "position:fixed!important", height);
expect(await captureFullPagePlate(f.page, f.dir, { remainingMs: () => remainingMs })).toEqual({
kind: "omitted",
reason,
});
expect(f.screenshot).not.toHaveBeenCalled();
f.expectRestored();
});
it("restores an ordinary inline position and leaves static elements untouched", async () => {
const f = fixture("", "position:sticky");
const staticElement = f.nav.ownerDocument.createElement("aside");
staticElement.style.position = "relative";
f.nav.after(staticElement);
await captureFullPagePlate(f.page, f.dir);
expect(f.captured()?.computed).toBe("static");
f.expectRestored();
expect(staticElement.style.position).toBe("relative");
expect(staticElement.attributes.length).toBe(1);
});
});
@@ -97,18 +97,18 @@ export function pngHeight(buf: Uint8Array): number | null {
* Returns the captured path or the cause for omitting the plate.
*/
export async function captureFullPagePlate(
page: Page,
page: Pick<Page, "evaluate" | "screenshot">,
screenshotsDir: string,
budget: { remainingMs?: () => number; files?: string[] } = {},
): Promise<PlateCaptureResult> {
// Record the inline value before overwriting so the page is handed back unchanged — the
// caller keeps using it (asset extraction, DOM reads) after this returns.
// Preserve the inline value and priority: asset and DOM extraction reuse this page afterward.
await page.evaluate(
`document.querySelectorAll('*').forEach((el) => {
const p = getComputedStyle(el).position;
if (p === 'fixed' || p === 'sticky') {
el.setAttribute('data-hf-plate-position', el.style.position || '');
el.style.position = 'static';
el.setAttribute('data-hf-plate-position-priority', el.style.getPropertyPriority('position'));
el.style.setProperty('position', 'static', 'important');
}
})`,
);
@@ -141,8 +141,10 @@ export async function captureFullPagePlate(
try {
await page.evaluate(
`document.querySelectorAll('[data-hf-plate-position]').forEach((el) => {
el.style.position = el.getAttribute('data-hf-plate-position');
el.style.setProperty('position', el.getAttribute('data-hf-plate-position') || '',
el.getAttribute('data-hf-plate-position-priority') || '');
el.removeAttribute('data-hf-plate-position');
el.removeAttribute('data-hf-plate-position-priority');
})`,
);
} catch {