Merge pull request #325 from Tencent/fix/full-page-screenshot-dpi-background

Fix full-page screenshots at fractional DPI and in inactive tabs
This commit is contained in:
Zhang GH
2026-09-23 11:39:01 +08:00
committed by GitHub
7 changed files with 365 additions and 31 deletions
@@ -31,6 +31,22 @@ describe("long screenshot stitching", () => {
const slice = sliceForFrame({ ...metrics, y: 1901, bottomOverlayHeight: 100 }, 2490, 2);
expect(slice).toEqual({ sourceY: 1000, targetY: 4802, height: 200, end: 2501 });
});
it("redraws the final footer when fractional metrics round to the document bottom", () => {
const y = 1797.199951171875;
const viewportHeight = 836.7999877929688;
expect(
sliceForFrame(
{ ...metrics, height: 2634, y, viewportHeight, bottomOverlayHeight: 80 },
y + viewportHeight,
2.5,
),
).toEqual({
sourceY: 1892,
targetY: 6385,
height: 200,
end: y + viewportHeight,
});
});
it.each([
1, 1.25, 1.5, 2,
])("covers every output row once at scale %s, including a clamped final scroll", (scale) => {
@@ -59,7 +75,7 @@ describe("long screenshot stitching", () => {
function harness() {
const controller = new AbortController();
const draw = vi.fn();
let current = { ...metrics, y: 401 };
let current = { ...metrics, dpr: 2, y: 401 };
const commands: PageCommand[] = [];
const page = vi.fn(async (command: PageCommand) => {
commands.push(command);
@@ -90,6 +106,78 @@ function harness() {
}
describe("capture lifecycle", () => {
it.each([
{
dpr: 1.5,
viewportWidth: 1570.6666259765625,
viewportHeight: 1050,
innerWidth: 1586,
innerHeight: 1050,
bitmapWidth: 2379,
bitmapHeight: 1575,
},
{
dpr: 2.5,
viewportWidth: 1250.4000244140625,
viewportHeight: 836.7999877929688,
innerWidth: 1262,
innerHeight: 837,
bitmapWidth: 3156,
bitmapHeight: 2092,
},
])("preserves every device row at DPR $dpr with fractional geometry", async ({
dpr,
viewportWidth,
viewportHeight,
innerWidth,
innerHeight,
bitmapWidth,
bitmapHeight,
}) => {
const h = harness();
const outputWidth = Math.round(viewportWidth * dpr);
let y = 0;
const height = 5001;
h.deps.page.mockImplementation(async (command) => {
h.commands.push(command);
if (command.action === "move")
y = Math.round(Math.min(command.y, height - viewportHeight) * dpr) / dpr;
return {
...metrics,
y,
height,
dpr,
viewportWidth,
viewportHeight,
innerWidth,
innerHeight,
};
});
h.deps.screenshot.mockImplementation(
async () =>
({
width: bitmapWidth,
height: bitmapHeight,
close: vi.fn(),
y,
}) as unknown as ImageBitmap,
);
const pixels: number[] = [];
h.draw.mockImplementation(async (bitmap, width, sourceY, targetY, count) => {
expect(width).toBe(outputWidth);
expect(sourceY).toBeGreaterThanOrEqual(0);
expect(sourceY + count).toBeLessThanOrEqual(bitmap.height);
for (let row = 0; row < count; row++)
pixels[targetY + row] = Math.round(bitmap.y * dpr) + sourceY + row;
});
expect(await capturePage(h.deps)).toEqual({
width: outputWidth,
height: Math.round(height * dpr),
});
expect(pixels).toEqual(Array.from({ length: Math.round(height * dpr) }, (_, i) => i));
expect(h.commands.at(-1)).toEqual({ action: "finish" });
});
it("captures incrementally, crops scrollbars and releases every bitmap", async () => {
const h = harness();
const result = await capturePage(h.deps);
@@ -102,6 +190,25 @@ describe("capture lifecycle", () => {
expect(h.bitmaps.every((bitmap) => bitmap.close.mock.calls.length === 1)).toBe(true);
});
it.each([
{ bitmapWidth: 1630, bitmapHeight: 1200, width: 1600, height: 5002 },
{ bitmapWidth: 652, bitmapHeight: 481, width: 640, height: 2001 },
])("retains scaled viewport captures at $bitmapWidth x $bitmapHeight", async ({
bitmapWidth,
bitmapHeight,
width,
height,
}) => {
const h = harness();
const page = h.deps.page.getMockImplementation()!;
h.deps.page.mockImplementation(async (command) => ({ ...(await page(command)), dpr: 3 }));
h.deps.screenshot.mockImplementation(
async () =>
({ width: bitmapWidth, height: bitmapHeight, close: vi.fn() }) as unknown as ImageBitmap,
);
expect(await capturePage(h.deps)).toEqual({ width, height });
});
it("restores the page if begin mutates it but its response is lost", async () => {
const h = harness();
h.deps.page.mockRejectedValueOnce(new ScreenshotError("unavailable"));
+17 -5
View File
@@ -12,14 +12,16 @@ import {
export function sliceForFrame(metrics: PageMetrics, covered: number, scale: number) {
if (metrics.y > covered + 0.5) throw new ScreenshotError("changed");
const end = Math.min(metrics.y + metrics.viewportHeight, metrics.height);
const bottomPx = Math.round(end * scale);
// A tiny final scroll must still include the whole fixed footer. Redraw its
// overlap rather than chopping it down to the last few uncovered rows.
const footerStart =
metrics.height - Math.min(metrics.viewportHeight, metrics.bottomOverlayHeight ?? 0);
const start =
end === metrics.height ? Math.min(covered, Math.max(metrics.y, footerStart)) : covered;
bottomPx === Math.round(metrics.height * scale)
? Math.min(covered, Math.max(metrics.y, footerStart))
: covered;
const topPx = Math.round(start * scale);
const bottomPx = Math.round(end * scale);
return {
sourceY: topPx - Math.round(metrics.y * scale),
targetY: topPx,
@@ -145,12 +147,22 @@ export async function capturePage(deps: CaptureDeps) {
}
layoutFailures = 0;
if (!frames) {
scale = bitmap.width / metrics.innerWidth;
// Prefer native DPR when it matches the rounded inner dimensions. A
// ratio inferred from those integers accumulates drift at fractional
// zoom. Retain the image ratio for scaled mobile/pinched viewports.
const rounding = (metrics.dpr + 1) / 2;
scale =
Math.abs(bitmap.width - metrics.innerWidth * metrics.dpr) <= rounding &&
Math.abs(bitmap.height - metrics.innerHeight * metrics.dpr) <= rounding
? metrics.dpr
: bitmap.width / metrics.innerWidth;
width = Math.round(metrics.viewportWidth * scale);
}
// inner dimensions round to CSS pixels; the bitmap rounds to device pixels.
const rounding = Math.max(1, (scale + 1) / 2);
if (
Math.abs(bitmap.width - metrics.innerWidth * scale) > 1 ||
Math.abs(bitmap.height - metrics.innerHeight * scale) > 1
Math.abs(bitmap.width - metrics.innerWidth * scale) > rounding ||
Math.abs(bitmap.height - metrics.innerHeight * scale) > rounding
)
throw new ScreenshotError("changed");
const pixels = deps.checkFreshness ? frameSignature(bitmap) : undefined;
@@ -66,15 +66,15 @@ describe("page capture cleanup", () => {
expect(cancel).not.toHaveBeenCalled();
});
it("measures fractional CSS viewport dimensions while retaining scrollbar gutters", async () => {
it("keeps native inner dimensions separate from fractional content dimensions", async () => {
vi.stubGlobal("innerWidth", 815);
vi.stubGlobal("innerHeight", 615);
vi.stubGlobal("visualViewport", { width: 800.4, height: 600.6, scale: 1 });
expect(await send({ action: "probe" })).toMatchObject({
viewportWidth: 800.4,
viewportHeight: 600.6,
innerWidth: 815.4,
innerHeight: 615.6,
innerWidth: 815,
innerHeight: 615,
});
});
@@ -161,8 +161,8 @@ describe("page capture cleanup", () => {
expect(await send({ action: "probe" })).toMatchObject({
viewportWidth: 800.4,
viewportHeight: 600.6,
innerWidth: 800.4,
innerHeight: 600.6,
innerWidth: 800,
innerHeight: 600,
});
await send({ action: "finish" });
expect(root.getAttribute("style")).toBe(beforeStyle);
+4 -2
View File
@@ -405,8 +405,10 @@ export function createPageCapture(onCancel: (id: string, reason: CaptureCancelRe
height: Math.max(scrolling.scrollHeight, viewportHeight),
viewportWidth,
viewportHeight,
innerWidth: viewportWidth + (window.innerWidth - root.clientWidth),
innerHeight: viewportHeight + (window.innerHeight - root.clientHeight),
// Do not add an integer scrollbar gutter to a fractional visual viewport.
// inner/client dimensions are rounded independently.
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
dpr: window.devicePixelRatio,
bottomOverlayHeight: task?.bottomOverlayHeight ?? 0,
};
@@ -24,11 +24,14 @@ import { ChromiumCdp } from '@/browser-driver/chromium-cdp';
import { SessionManager } from '@/session-manager/manager';
import { ScreenshotExports } from '@/long-screenshot/exports';
import { handleFullPageScreenshot } from '@/tools/screenshot-full-page';
globalThis.run = async (url, overlay) => {
globalThis.run = async (url, overlay, active) => {
const cdp = new ChromiumCdp(chrome.debugger);
const currentWindow = await chrome.windows.getCurrent();
// Establish the control window before recording any capture focus/selection events.
await chrome.windows.update(currentWindow.id,{focused:true});
await new Promise(r=>setTimeout(r,500));
const [control] = await chrome.tabs.query({windowId:currentWindow.id,active:true});
const manager = new SessionManager({agentWindow:{create:async()=>currentWindow.id,remove:async()=>{},ensureActiveTab:async()=>control.id}});
const manager = new SessionManager({agentWindow:{create:async()=>({windowId:currentWindow.id,initialTabIds:[control.id]}),remove:async()=>{},ensureActiveTab:async()=>control.id}});
const ctx = await manager.start('regression');
const exports = new ScreenshotExports(id=>manager.has(id));
const activated=[], focused=[], calls=[], trace=[];
@@ -45,7 +48,7 @@ globalThis.run = async (url, overlay) => {
try {
const results=[];
for (const variant of ['rows','other']) {
const tab=await chrome.tabs.create({windowId:currentWindow.id,url:'about:blank',active:false});created.push(tab.id);
const tab=await chrome.tabs.create({windowId:currentWindow.id,url:'about:blank',active});created.push(tab.id);
ctx.agentCreatedTabs.add(tab.id);
await cdp.acquireBackgroundExecution(ctx.sessionId,tab.id);
await chrome.tabs.update(tab.id,{url:url+'/'+variant});
@@ -70,7 +73,7 @@ globalThis.run = async (url, overlay) => {
if(!overlayPixels)throw new Error('Overlay regression requires visible scrollbar pixels before capture');
}
const dpr=await evaluate(tab.id,'devicePixelRatio');
const result=await handleFullPageScreenshot(manager,{session_id:ctx.sessionId,tab_id:tab.id},{cdp,tabsApi:chrome.tabs,exports});
const result=await handleFullPageScreenshot(manager,{session_id:ctx.sessionId,tab_id:tab.id,timeout_ms:60_000},{cdp,tabsApi:chrome.tabs,exports});
if(result.code)throw new Error(JSON.stringify({result,variant,trace,calls}));
const parts=[];let offset=0;
for(;;){const chunk=await exports.read({session_id:ctx.sessionId,capture_id:result.capture_id,offset});
@@ -100,7 +103,7 @@ globalThis.run = async (url, overlay) => {
results.push({result,dpr,bad,edgeBad,overlayPixels,footer,original,restored:await state(tab.id),active:(await chrome.tabs.get(tab.id)).active});
await exports.release({session_id:ctx.sessionId,capture_id:result.capture_id});
}
return {results,activated,focused,calls,control:control.id,selected:(await chrome.tabs.query({windowId:currentWindow.id,active:true}))[0].id};
return {results,activated,focused,calls,created,control:control.id,selected:(await chrome.tabs.query({windowId:currentWindow.id,active:true}))[0].id};
} finally {
chrome.tabs.onActivated.removeListener(onActive);chrome.windows.onFocusChanged.removeListener(onFocus);
await exports.dispose();await cdp.detachAll();cdp.dispose();
@@ -112,9 +115,15 @@ describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)(
"background full-page browser regression",
() => {
it.each([
{ scale: 1, zoom: 1 },
{ scale: 2, zoom: 1.25 },
])("stitches target pixels and restores the page at $scale/$zoom", async ({ scale, zoom }) => {
{ scale: 1, zoom: 1, active: false },
{ scale: 1.5, zoom: 1, active: false },
{ scale: 2, zoom: 1.25, active: false },
{ scale: 1.5, zoom: 1, active: true },
])("stitches target pixels and restores the page at $scale/$zoom (active=$active)", async ({
scale,
zoom,
active,
}) => {
const directory = await mkdtemp(path.join(tmpdir(), "bsk-full-page-extension-"));
const server = createServer((req, res) => {
res.setHeader("Content-Type", "text/html");
@@ -194,8 +203,8 @@ describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)(
deviceScale: scale,
zoom,
extensionPath: directory,
softwareRendering: true,
headless: !process.env.BSK_OVERLAY_SCROLLBAR,
softwareRendering: !process.env.BSK_SCREENSHOT_HEADED,
headless: !process.env.BSK_OVERLAY_SCROLLBAR && !process.env.BSK_SCREENSHOT_HEADED,
},
async (send: Send) => {
let origin = "";
@@ -232,9 +241,11 @@ describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)(
if (reply.exceptionDetails) throw new Error(JSON.stringify(reply.exceptionDetails));
return reply.result.value;
};
await expect.poll(() => evaluate("typeof run==='function'")).toBe(true);
await expect
.poll(() => evaluate("typeof run==='function'"), { timeout: 10_000 })
.toBe(true);
await evaluate(
`run(${JSON.stringify(url)},${!!process.env.BSK_OVERLAY_SCROLLBAR}).then(value=>globalThis.done={value},error=>globalThis.done={error:String(error)});true`,
`run(${JSON.stringify(url)},${!!process.env.BSK_OVERLAY_SCROLLBAR},${active}).then(value=>globalThis.done={value},error=>globalThis.done={error:String(error)});true`,
);
await expect.poll(() => evaluate("!!globalThis.done"), { timeout: 100_000 }).toBe(true);
const done = await evaluate<{
@@ -254,6 +265,7 @@ describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)(
activated: number[];
focused: number[];
calls: string[];
created: number[];
control: number;
selected: number;
};
@@ -270,12 +282,14 @@ describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)(
);
expect(Math.abs(item.result.height - 2634 * item.dpr)).toBeLessThanOrEqual(1);
expect(item.restored).toEqual(item.original);
expect(item.active).toBe(false);
expect(item.active).toBe(active);
}
if (process.env.BSK_OVERLAY_SCROLLBAR)
expect(done.value.results[1].overlayPixels).toBeGreaterThan(0);
expect(done.value.selected).toBe(done.value.control);
expect(done.value.activated).toEqual([]);
expect(done.value.selected).toBe(
active ? done.value.created.at(-1) : done.value.control,
);
expect(done.value.activated).toEqual(active ? done.value.created : []);
expect(done.value.focused).toEqual([]);
expect(done.value.calls).not.toContain("Page.bringToFront");
},
@@ -23,6 +23,7 @@ afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.clearAllMocks();
vi.mocked(capturePage).mockReset().mockResolvedValue({ width: 64, height: 256 });
});
async function setup() {
@@ -40,9 +41,22 @@ async function setup() {
active: true,
url: "https://example.test/",
} as chrome.tabs.Tab;
let onEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
const disposeEvents = vi.fn(() => {
onEvent = undefined;
});
const cdp = {
onEvent: vi.fn((handler: NonNullable<typeof onEvent>) => {
onEvent = handler;
return { dispose: disposeEvents };
}),
getAttachmentId: vi.fn(() => "attachment-one" as string | undefined),
send: vi.fn(async <T>(_id: number, method: string, _params?: object): Promise<T> => {
if (method === "Page.startScreencast") {
onEvent?.({ tabId: 7 }, "Page.screencastFrame", { sessionId: 1 });
return {} as T;
}
if (method === "Page.stopScreencast" || method === "Page.screencastFrameAck") return {} as T;
if (method === "Page.getFrameTree") return {} as T;
if (method === "Page.captureScreenshot") return { data: "cG5n" } as T;
throw new Error(`Unexpected CDP call: ${method}`);
@@ -53,7 +67,7 @@ async function setup() {
tabsApi: { get: vi.fn(async () => tab), query: vi.fn(async () => [tab]) },
exports: new ScreenshotExports((id) => manager.has(id)),
};
return { manager, tab, deps };
return { manager, tab, deps, disposeEvents };
}
describe("full-page screenshot target policy", () => {
@@ -366,6 +380,137 @@ describe("full-page capture interruption", () => {
});
});
async function setupWindowsCapture() {
const context = await setupCapture();
context.tab.active = false;
vi.mocked(chrome.runtime.getPlatformInfo).mockResolvedValue({
os: "win",
} as chrome.runtime.PlatformInfo);
vi.stubGlobal(
"createImageBitmap",
vi.fn(async () => ({ close() {} })),
);
return context;
}
describe("Windows full-page rendering lifetime", () => {
it("starts rendering once if the target becomes inactive between tiles", async () => {
const { manager, tab, deps, disposeEvents } = await setupWindowsCapture();
tab.active = true;
vi.mocked(capturePage).mockImplementationOnce(async (options) => {
await options.screenshot();
expect(deps.cdp.onEvent).not.toHaveBeenCalled();
tab.active = false;
await options.screenshot();
await options.screenshot();
return { width: 64, height: 256 };
});
expect(await handleFullPageScreenshot(manager, { session_id: "one" }, deps)).toMatchObject({
format: "png",
});
expect(
deps.cdp.send.mock.calls.filter(([, method]) => method === "Page.startScreencast"),
).toHaveLength(1);
expect(disposeEvents).toHaveBeenCalledOnce();
await deps.exports.dispose();
});
it("acknowledges only frames from the original target and attachment", async () => {
const { manager, deps, disposeEvents } = await setupWindowsCapture();
vi.mocked(capturePage).mockImplementationOnce(async (options) => {
await options.screenshot();
const frame = deps.cdp.onEvent.mock.calls[0][0];
frame({ tabId: 99 }, "Page.screencastFrame", { sessionId: 2 });
frame({ tabId: 7, sessionId: "child" }, "Page.screencastFrame", { sessionId: 3 });
frame({ tabId: 7 }, "Page.screencastVisibilityChanged", {});
frame({ tabId: 7 }, "Page.screencastFrame", { sessionId: 4 });
deps.cdp.getAttachmentId.mockReturnValue("replacement");
frame({ tabId: 7 }, "Page.screencastFrame", { sessionId: 5 });
return { width: 64, height: 256 };
});
expect(await handleFullPageScreenshot(manager, { session_id: "one" }, deps)).toMatchObject({
code: "cdp_failed",
});
expect(
deps.cdp.send.mock.calls
.filter(([, method]) => method === "Page.screencastFrameAck")
.map(([, , params]) => params),
).toEqual([{ sessionId: 1 }, { sessionId: 4 }]);
expect(disposeEvents).toHaveBeenCalledOnce();
expect(deps.exports.discard).toHaveBeenCalledOnce();
});
it.each([
"success",
"failure",
"cancel",
"attachment",
])("releases rendering on %s without touching replacement attachments", async (outcome) => {
const { manager, deps, disposeEvents } = await setupWindowsCapture();
const controller = new AbortController();
vi.mocked(capturePage).mockImplementationOnce(async (options) => {
await options.screenshot();
expect(deps.cdp.send).toHaveBeenCalledWith(7, "Page.startScreencast", {
format: "png",
maxWidth: 32,
maxHeight: 32,
});
expect(deps.cdp.send.mock.calls.some(([, method]) => method === "Page.stopScreencast")).toBe(
false,
);
if (outcome === "failure") throw new Error("capture failed");
if (outcome === "cancel") controller.abort();
if (outcome === "attachment") deps.cdp.getAttachmentId.mockReturnValue("replacement");
return { width: 64, height: 256 };
});
const result = await handleFullPageScreenshot(
manager,
{ session_id: "one" },
deps,
controller.signal,
);
expect(result).toMatchObject(
outcome === "success"
? { format: "png" }
: { code: outcome === "cancel" ? "cancelled" : "cdp_failed" },
);
expect(
deps.cdp.send.mock.calls.filter(([, method]) => method === "Page.stopScreencast"),
).toHaveLength(outcome === "attachment" ? 0 : 1);
expect(disposeEvents).toHaveBeenCalledOnce();
if (outcome !== "success") expect(deps.exports.discard).toHaveBeenCalledOnce();
await deps.exports.dispose();
});
it.each([
"Page.startScreencast",
"Page.stopScreencast",
"Page.captureScreenshot",
])("bounds %s and still sends rendering cleanup", async (stalled) => {
vi.useFakeTimers();
const { manager, deps } = await setupWindowsCapture();
const send = deps.cdp.send.getMockImplementation()!;
deps.cdp.send.mockImplementation(
async <T>(id: number, method: string, params?: object): Promise<T> => {
if (method === stalled) return new Promise<T>(() => {});
return send(id, method, params) as Promise<T>;
},
);
vi.mocked(capturePage).mockImplementationOnce(async (options) => {
await options.screenshot();
return { width: 64, height: 256 };
});
const result = handleFullPageScreenshot(manager, { session_id: "one", timeout_ms: 50 }, deps);
await vi.advanceTimersByTimeAsync(1100);
expect(await result).toMatchObject({ code: "timeout" });
expect(
deps.cdp.send.mock.calls.filter(([, method]) => method === "Page.stopScreencast"),
).toHaveLength(1);
expect(deps.exports.discard).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
});
describe("full-page scope and diagnostics", () => {
it.each([
"win",
@@ -384,6 +529,10 @@ describe("full-page scope and diagnostics", () => {
loadingTimeoutMs: 30000,
checkFreshness: os === "win",
});
const renderingCalls = deps.cdp.send.mock.calls
.filter(([, method]) => method.endsWith("Screencast"))
.map(([, method]) => method);
expect(renderingCalls).toEqual([]);
await deps.exports.dispose();
});
it.each([
@@ -106,8 +106,11 @@ export async function handleFullPageScreenshot(
if (frame?.documentId !== client.documentId) throw new ScreenshotError("changed");
}
checkOwnership();
return current;
};
const client = createPageClient(target.tabId, id, controller.signal, checkTab);
const client = createPageClient(target.tabId, id, controller.signal, async () => {
await checkTab();
});
const changed = () => controller.abort(new ScreenshotError("changed"));
const navigated = (info: { tabId: number; frameId: number }) => {
if (info.tabId === target.tabId && info.frameId === 0)
@@ -152,6 +155,18 @@ export async function handleFullPageScreenshot(
chrome.runtime.onMessage.addListener(cancelled);
const writer = new TileWriter(id, new URL(target.url).hostname);
let retained = false;
let rendering = false;
let renderingEvents: { dispose(): void } | undefined;
const stopRendering = async () => {
if (!rendering) return;
rendering = false;
renderingEvents?.dispose();
// Cleanup must never attach to a replacement debugger connection.
if (deps.cdp.getAttachmentId?.(target.tabId) !== attachmentId) return;
await waitForReply(deps.cdp.send(target.tabId, "Page.stopScreencast"), undefined, 1000).catch(
() => {},
);
};
let phase = "preparing";
let frames = 0;
let progress = 0;
@@ -167,6 +182,38 @@ export async function handleFullPageScreenshot(
if (!attachmentId) throw new ScreenshotError("changed");
const platform = await waitForReply(chrome.runtime.getPlatformInfo(), controller.signal);
await client.prepare();
const ensureRendering = async (active: boolean) => {
if (platform.os !== "win" || active || rendering) return;
// Focus emulation keeps script execution alive, but Windows can stop
// producing compositor frames on an unselected tab after the first read.
// A tiny screencast holds the renderer awake without selecting/resizing it.
// Its pixels are unused; each tile still comes from captureScreenshot.
checkOwnership();
rendering = true;
renderingEvents = deps.cdp.onEvent?.((source, method, params) => {
if (
source.tabId !== target.tabId ||
source.sessionId ||
method !== "Page.screencastFrame" ||
deps.cdp.getAttachmentId?.(target.tabId) !== attachmentId
)
return;
const frame = params as { sessionId: number };
void deps.cdp
.send(target.tabId, "Page.screencastFrameAck", { sessionId: frame.sessionId })
.catch(() => {});
});
if (!renderingEvents) throw new ScreenshotError("captureFailed");
await waitForReply(
deps.cdp.send(target.tabId, "Page.startScreencast", {
format: "png",
maxWidth: 32,
maxHeight: 32,
}),
controller.signal,
);
};
await ensureRendering((await checkTab()).active);
await withScreenshotOverlayHidden(
target.tabId,
client.documentId!,
@@ -181,7 +228,8 @@ export async function handleFullPageScreenshot(
signal: controller.signal,
screenshot: async () => {
phase = "reading viewport pixels";
await checkTab();
const current = await checkTab();
await ensureRendering(current.active);
checkOwnership();
const shot = await waitForReply(
deps.cdp.send<{ data?: string }>(target.tabId, "Page.captureScreenshot", {
@@ -211,6 +259,7 @@ export async function handleFullPageScreenshot(
});
},
);
await stopRendering();
controller.signal.throwIfAborted();
await writer.finish();
phase = "encoding";
@@ -285,6 +334,7 @@ export async function handleFullPageScreenshot(
reason instanceof Error ? reason.message : String(reason),
);
} finally {
await stopRendering();
clearTimeout(deadline);
signal?.removeEventListener("abort", abort);
chrome.webNavigation.onBeforeNavigate.removeListener(navigated);