diff --git a/apps/extension/src/entrypoints/long-screenshot-page.content.ts b/apps/extension/src/entrypoints/long-screenshot-page.content.ts
index 44c9e591..08686c42 100644
--- a/apps/extension/src/entrypoints/long-screenshot-page.content.ts
+++ b/apps/extension/src/entrypoints/long-screenshot-page.content.ts
@@ -1,14 +1,19 @@
import { createPageCapture, pageError } from "@/long-screenshot/page";
-import { LONG_SCREENSHOT, LONG_SCREENSHOT_PAGE, type PageRequest } from "@/long-screenshot/types";
+import {
+ LONG_SCREENSHOT,
+ LONG_SCREENSHOT_PAGE,
+ type PageRequest,
+ ScreenshotError,
+} from "@/long-screenshot/types";
export default defineContentScript({
matches: ["http://*/*", "https://*/*"],
runAt: "document_end",
allFrames: false,
main(ctx) {
- const capture = createPageCapture((id) => {
+ const capture = createPageCapture((id, reason) => {
void chrome.runtime
- .sendMessage({ type: LONG_SCREENSHOT, action: "cancel", id })
+ .sendMessage({ type: LONG_SCREENSHOT, action: "cancel", id, reason })
.catch(() => {});
});
const listener = (
@@ -21,7 +26,12 @@ export default defineContentScript({
if (request.type !== LONG_SCREENSHOT_PAGE || typeof request.id !== "string") return;
void capture.handle(request).then(
(metrics) => respond({ ok: true, metrics }),
- (error) => respond({ ok: false, error: pageError(error) }),
+ (error) =>
+ respond({
+ ok: false,
+ error: pageError(error),
+ ...(error instanceof ScreenshotError && error.reason ? { reason: error.reason } : {}),
+ }),
);
return true;
};
diff --git a/apps/extension/src/long-screenshot/agent.browser.test.ts b/apps/extension/src/long-screenshot/agent.browser.test.ts
index 718ef535..f6535ccd 100644
--- a/apps/extension/src/long-screenshot/agent.browser.test.ts
+++ b/apps/extension/src/long-screenshot/agent.browser.test.ts
@@ -314,6 +314,33 @@ describe.skipIf(!process.env.BSK_LONG_SCREENSHOT_CHROME || !process.env.BSK_LONG
120_000,
);
+ it(
+ "captures the current range when the loading indicator never clears",
+ () =>
+ withAgent(async (h) => {
+ const original = await h.restored();
+ await h.onPage(
+ `document.body.insertAdjacentHTML('beforeend', '加载中...')`,
+ );
+ const out = path.join(h.directory, "current.png");
+ const reply = await h.ok([
+ "screenshot",
+ "--session",
+ h.session,
+ "--full-page",
+ "--scope",
+ "current",
+ "--out",
+ out,
+ ]);
+ expect(reply.scope).toBe("current");
+ verifyPng(await readFile(out), 2634, 1);
+ expect(await h.restored()).toEqual(original);
+ expect(await h.scratch()).toEqual([]);
+ }),
+ 120_000,
+ );
+
it(
"restores the page on timeout and Ctrl-C without replacing an existing output",
() =>
diff --git a/apps/extension/src/long-screenshot/capture.test.ts b/apps/extension/src/long-screenshot/capture.test.ts
index 2fcb0be7..beaf9d7d 100644
--- a/apps/extension/src/long-screenshot/capture.test.ts
+++ b/apps/extension/src/long-screenshot/capture.test.ts
@@ -1,7 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { capturePage, sameLayout, sliceForFrame } from "./capture";
+import { frameSignature, isStaleFrame } from "./frame-freshness";
import { type PageCommand, type PageMetrics, ScreenshotError } from "./types";
+vi.mock("./frame-freshness", () => ({
+ frameSignature: vi.fn(() => ({})),
+ isStaleFrame: vi.fn(() => false),
+}));
+
const metrics: PageMetrics = {
x: 0,
y: 0,
@@ -14,7 +20,11 @@ const metrics: PageMetrics = {
dpr: 1,
};
-afterEach(() => vi.unstubAllGlobals());
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.mocked(isStaleFrame).mockReset();
+ vi.mocked(frameSignature).mockClear();
+});
describe("long screenshot stitching", () => {
it("preserves a complete fixed footer even when the final scroll adds only a few rows", () => {
@@ -71,6 +81,7 @@ function harness() {
screenshot,
write: draw,
signal: controller.signal,
+ checkFreshness: true,
progress: vi.fn(),
label: "Capture",
cancelLabel: "Cancel",
@@ -190,3 +201,82 @@ describe("capture lifecycle", () => {
expect(pixels.slice(2300)).toEqual(Array(700).fill(-1));
});
});
+
+describe("capture recovery", () => {
+ it("keeps frame validation opt-in for existing callers", async () => {
+ const h = harness();
+ vi.mocked(isStaleFrame).mockReturnValue(true);
+ const { checkFreshness: _, ...deps } = h.deps;
+ await expect(capturePage(deps)).resolves.toEqual({ width: 1600, height: 5002 });
+ expect(frameSignature).not.toHaveBeenCalled();
+ expect(isStaleFrame).not.toHaveBeenCalled();
+ });
+ it.each([
+ { mismatches: [2, 3], stale: 1 },
+ { mismatches: [4], stale: 2 },
+ ])("does not combine layout mismatches $mismatches with $stale stale exposures", async ({
+ mismatches,
+ stale,
+ }) => {
+ const h = harness();
+ const page = h.deps.page;
+ let inspections = 0;
+ h.deps.page = vi.fn(async (command) => {
+ const metrics = await page(command);
+ if (command.action === "inspect" && mismatches.includes(++inspections))
+ return { ...metrics, x: metrics.x + 1 };
+ return metrics;
+ });
+ for (let i = 0; i < stale; i++) vi.mocked(isStaleFrame).mockReturnValueOnce(true);
+ vi.mocked(isStaleFrame).mockReturnValue(false);
+ await expect(capturePage(h.deps)).resolves.toEqual({ width: 1600, height: 5002 });
+ expect(h.commands.at(-1)).toEqual({ action: "finish" });
+ });
+
+ it("never writes a stale frame and stops after three exposures", async () => {
+ const h = harness();
+ vi.mocked(isStaleFrame).mockReturnValue(true);
+ await expect(capturePage(h.deps)).rejects.toMatchObject({ reason: "stale_frame" });
+ expect(h.draw).toHaveBeenCalledTimes(1);
+ expect(h.bitmaps).toHaveLength(4);
+ expect(h.commands.at(-1)).toEqual({ action: "finish" });
+ });
+ it("retries a stale exposure at the same position without adding rows", async () => {
+ const h = harness();
+ vi.mocked(isStaleFrame).mockReturnValueOnce(true).mockReturnValue(false);
+ await expect(capturePage(h.deps)).resolves.toEqual({ width: 1600, height: 5002 });
+ const moves = h.commands.filter((c) => c.action === "move");
+ expect(moves[1]).toEqual(moves[2]);
+ });
+ it("does not count a quiet wait before the loading indicator appears as stalled loading", async () => {
+ const h = harness();
+ const page = h.deps.page;
+ let waits = 0;
+ vi.stubGlobal("performance", { now: () => waits * 10_000 });
+ h.deps.page = vi.fn(async (command) => {
+ if (command.action === "move" && command.final) waits++;
+ return {
+ ...(await page(command)),
+ loading: waits === 3,
+ bottomReady: waits >= 4,
+ };
+ });
+ await expect(capturePage({ ...h.deps, loadingTimeoutMs: 20_000 })).resolves.toEqual({
+ width: 1600,
+ height: 5002,
+ });
+ });
+ it("reports a stalled loading bottom and restores the page", async () => {
+ const h = harness();
+ const page = h.deps.page;
+ h.deps.page = vi.fn(async (command) => ({
+ ...(await page(command)),
+ loading: true,
+ bottomReady: false,
+ }));
+ await expect(capturePage({ ...h.deps, loadingTimeoutMs: 0 })).rejects.toMatchObject({
+ reason: "loading_stalled",
+ });
+ expect(h.commands.at(-1)).toEqual({ action: "finish" });
+ });
+});
diff --git a/apps/extension/src/long-screenshot/capture.ts b/apps/extension/src/long-screenshot/capture.ts
index 3c028130..2a7d8107 100644
--- a/apps/extension/src/long-screenshot/capture.ts
+++ b/apps/extension/src/long-screenshot/capture.ts
@@ -1,4 +1,12 @@
-import { type CapturePhase, type PageCommand, type PageMetrics, ScreenshotError } from "./types";
+import { frameSignature, isStaleFrame } from "./frame-freshness";
+import { type FrameSignature } from "./manual";
+import {
+ type CapturePhase,
+ type CaptureScope,
+ type PageCommand,
+ type PageMetrics,
+ ScreenshotError,
+} from "./types";
/** Round document boundaries once, so fractional zoom cannot accumulate seams. */
export function sliceForFrame(metrics: PageMetrics, covered: number, scale: number) {
@@ -48,6 +56,9 @@ export interface CaptureDeps {
checkpoint?(): Promise;
prepared?(): void;
finished?(): boolean;
+ scope?: CaptureScope;
+ loadingTimeoutMs?: number;
+ checkFreshness?: boolean;
label: string;
cancelLabel: string;
}
@@ -60,6 +71,8 @@ export async function capturePage(deps: CaptureDeps) {
let width = 0;
let scale = 1;
let frames = 0;
+ let previousFrame: { y: number; pixels: FrameSignature } | undefined;
+ let bottomWait: { height: number; since: number } | undefined;
let baseline: PageMetrics | undefined;
const checkpoint = async () => {
signal.throwIfAborted();
@@ -68,12 +81,18 @@ export async function capturePage(deps: CaptureDeps) {
};
try {
await checkpoint();
- let metrics = await page({ action: "begin", label: deps.label, cancelLabel: deps.cancelLabel });
+ let metrics = await page({
+ action: "begin",
+ label: deps.label,
+ cancelLabel: deps.cancelLabel,
+ ...(deps.scope ? { scope: deps.scope } : {}),
+ });
let previous = metrics;
let repairThrough = 0;
deps.prepared?.();
let y = 0;
- let failures = 0;
+ let layoutFailures = 0;
+ let staleFailures = 0;
let final = false;
while (true) {
await checkpoint();
@@ -100,17 +119,31 @@ export async function capturePage(deps: CaptureDeps) {
}
// A loading indicator or recent layout/content changes keeps the bottom
// provisional. Checkpoints still allow pause, Finish and cancellation.
- if (final && metrics.bottomReady === false && covered >= metrics.height - 0.5) continue;
+ if (final && metrics.bottomReady === false && covered >= metrics.height - 0.5) {
+ if (!metrics.loading) bottomWait = undefined;
+ else {
+ if (!bottomWait || bottomWait.height !== metrics.height)
+ bottomWait = { height: metrics.height, since: performance.now() };
+ if (
+ deps.loadingTimeoutMs !== undefined &&
+ performance.now() - bottomWait.since >= deps.loadingTimeoutMs
+ )
+ throw new ScreenshotError("timeout", "loading_stalled");
+ }
+ continue;
+ }
+ bottomWait = undefined;
const bitmap = await deps.screenshot();
try {
signal.throwIfAborted();
const after = await page({ action: "inspect" });
if (final && after.bottomReady === false) continue;
if (!sameLayout(metrics, after)) {
- if (++failures >= 3) throw new ScreenshotError("changed");
+ staleFailures = 0;
+ if (++layoutFailures >= 3) throw new ScreenshotError("changed");
continue;
}
- failures = 0;
+ layoutFailures = 0;
if (!frames) {
scale = bitmap.width / metrics.innerWidth;
width = Math.round(metrics.viewportWidth * scale);
@@ -120,6 +153,20 @@ export async function capturePage(deps: CaptureDeps) {
Math.abs(bitmap.height - metrics.innerHeight * scale) > 1
)
throw new ScreenshotError("changed");
+ const pixels = deps.checkFreshness ? frameSignature(bitmap) : undefined;
+ if (
+ pixels &&
+ previousFrame &&
+ isStaleFrame(
+ previousFrame.pixels,
+ pixels,
+ Math.round((metrics.y - previousFrame.y) * scale),
+ )
+ ) {
+ if (++staleFailures >= 3) throw new ScreenshotError("captureFailed", "stale_frame");
+ continue;
+ }
+ staleFailures = 0;
const slice = sliceForFrame(metrics, covered, scale);
if (slice.sourceY < 0 || slice.sourceY + slice.height > bitmap.height || slice.height < 0)
throw new ScreenshotError("changed");
@@ -128,6 +175,7 @@ export async function capturePage(deps: CaptureDeps) {
covered = slice.end;
frames++;
}
+ if (pixels) previousFrame = { y: metrics.y, pixels };
progress("capturing", Math.min(99, Math.round((covered / metrics.height) * 100)), frames);
} finally {
bitmap.close();
diff --git a/apps/extension/src/long-screenshot/frame-freshness.test.ts b/apps/extension/src/long-screenshot/frame-freshness.test.ts
new file mode 100644
index 00000000..71618320
--- /dev/null
+++ b/apps/extension/src/long-screenshot/frame-freshness.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import { isStaleFrame } from "./frame-freshness";
+import type { FrameSignature } from "./manual";
+
+function frame(offset = 0, blank = false): FrameSignature {
+ const width = 96,
+ height = 600,
+ pixels = new Uint8ClampedArray(width * height * 4);
+ for (let y = 0; y < height; y++)
+ for (let x = 0; x < width; x++)
+ for (let c = 0; c < 3; c++)
+ pixels[(y * width + x) * 4 + c] = blank ? 240 : ((y + offset) * 31 + x * 17 + c * 57) % 251;
+ return { width, height, pixels };
+}
+describe("automatic frame freshness", () => {
+ it("rejects an unchanged textured image at a different document offset", () => {
+ expect(isStaleFrame(frame(), frame(), 450)).toBe(true);
+ });
+ it("accepts pixels that moved by the measured displacement", () => {
+ expect(isStaleFrame(frame(), frame(450), 450)).toBe(false);
+ });
+ it("does not reject blank, repeated, same-position or rewound exposures", () => {
+ expect(isStaleFrame(frame(0, true), frame(0, true), 450)).toBe(false);
+ expect(isStaleFrame(frame(), frame(), 251)).toBe(false);
+ expect(isStaleFrame(frame(), frame(), 0)).toBe(false);
+ expect(isStaleFrame(frame(), frame(), -450)).toBe(false);
+ });
+ it("tolerates stationary sidebars when article content scrolls", () => {
+ const a = frame(),
+ b = frame(450);
+ for (let y = 0; y < 600; y++)
+ for (let x = 0; x < 96; x++)
+ if (x < 32 || x > 63)
+ b.pixels.set(a.pixels.subarray((y * 96 + x) * 4, (y * 96 + x) * 4 + 4), (y * 96 + x) * 4);
+ expect(isStaleFrame(a, b, 450)).toBe(false);
+ });
+});
diff --git a/apps/extension/src/long-screenshot/frame-freshness.ts b/apps/extension/src/long-screenshot/frame-freshness.ts
new file mode 100644
index 00000000..62b9ee43
--- /dev/null
+++ b/apps/extension/src/long-screenshot/frame-freshness.ts
@@ -0,0 +1,52 @@
+import type { FrameSignature } from "./manual";
+import { ScreenshotError } from "./types";
+
+export function frameSignature(bitmap: ImageBitmap): FrameSignature {
+ const pixels = new Uint8ClampedArray(96 * bitmap.height * 4);
+ const canvas = new OffscreenCanvas(96, Math.min(512, bitmap.height));
+ try {
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
+ if (!ctx) throw new ScreenshotError("captureFailed");
+ for (let y = 0; y < bitmap.height; y += canvas.height) {
+ const height = Math.min(canvas.height, bitmap.height - y);
+ ctx.drawImage(bitmap, 0, y, bitmap.width, height, 0, 0, 96, height);
+ pixels.set(ctx.getImageData(0, 0, 96, height).data, y * 96 * 4);
+ }
+ return { width: bitmap.width, height: bitmap.height, pixels };
+ } finally {
+ canvas.width = canvas.height = 1;
+ }
+}
+
+/** Reject positive evidence of a stale exposure, not ambiguous/blank content.
+ * Compare the known document displacement against stationary textured patches.
+ * Keep signatures only; no full-image buffers or offset search are needed. */
+export function isStaleFrame(a: FrameSignature, b: FrameSignature, offset: number): boolean {
+ if (a.width !== b.width || a.height !== b.height || offset < 4 || offset > a.height - 32)
+ return false;
+ let stale = 0;
+ let scrolling = 0;
+ const bands = new Set();
+ const error = (ay: number, by: number, x: number) => {
+ let sum = 0;
+ for (let dx = 0; dx < 8; dx++)
+ for (let c = 0; c < 3; c++)
+ sum += Math.abs(
+ a.pixels[(ay * 96 + x + dx) * 4 + c] - b.pixels[(by * 96 + x + dx) * 4 + c],
+ );
+ return sum / 24;
+ };
+ for (let y = 8; y < a.height - offset - 8; y += 4) {
+ // Ignore edge chrome and tolerate stationary sidebars/local animation.
+ for (let x = 24; x <= 64; x += 8) {
+ const stationary = error(y, y, x);
+ const shifted = error(y + offset, y, x);
+ if (shifted < 3 && stationary > 12) scrolling++;
+ else if (stationary < 0.5 && shifted > 12) {
+ stale++;
+ bands.add(x);
+ }
+ }
+ }
+ return stale >= 24 && bands.size >= 2 && scrolling === 0;
+}
diff --git a/apps/extension/src/long-screenshot/page-client.ts b/apps/extension/src/long-screenshot/page-client.ts
index b5ee2cce..1ddc4bd6 100644
--- a/apps/extension/src/long-screenshot/page-client.ts
+++ b/apps/extension/src/long-screenshot/page-client.ts
@@ -26,7 +26,11 @@ export function createPageClient(
if (error instanceof ScreenshotError || signal.aborted) throw error;
throw new ScreenshotError("unavailable");
}
- if (!response?.ok) throw new ScreenshotError(response?.error ?? "unavailable");
+ if (!response?.ok)
+ throw new ScreenshotError(
+ response?.error ?? "unavailable",
+ response?.ok === false ? response.reason : undefined,
+ );
return response.metrics;
};
return {
diff --git a/apps/extension/src/long-screenshot/page.test.ts b/apps/extension/src/long-screenshot/page.test.ts
index 0edb8e65..7eb1d322 100644
--- a/apps/extension/src/long-screenshot/page.test.ts
+++ b/apps/extension/src/long-screenshot/page.test.ts
@@ -92,7 +92,7 @@ describe("page capture cleanup", () => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", cancelable: true }));
await rejected;
await send({ action: "finish" });
- expect(cancel).toHaveBeenCalledExactlyOnceWith("one");
+ expect(cancel).toHaveBeenCalledExactlyOnceWith("one", "user_cancelled");
expect(document.querySelector("header")!.style.position).toBe("sticky");
expect(window.scrollY).toBe(350);
});
@@ -100,7 +100,7 @@ describe("page capture cleanup", () => {
it("restores automatically when the background disappears", async () => {
await send({ action: "begin", label: "Capture", cancelLabel: "Cancel" });
await vi.advanceTimersByTimeAsync(15_001);
- expect(cancel).toHaveBeenCalledExactlyOnceWith("one");
+ expect(cancel).toHaveBeenCalledExactlyOnceWith("one", "watchdog_timeout");
expect(window.scrollY).toBe(350);
await expect(send({ action: "inspect" })).rejects.toBeDefined();
});
@@ -208,4 +208,58 @@ describe("page capture cleanup", () => {
await first;
expect(await send({ action: "inspect" })).toMatchObject({ bottomReady: true });
});
+ it("caps begin metrics even when preparation changes the document height", async () => {
+ let reads = 0;
+ Object.defineProperty(document.documentElement, "scrollHeight", {
+ configurable: true,
+ get: () => (reads++ === 0 ? 2400 : 3400),
+ });
+ const begin = await send({
+ action: "begin",
+ scope: "current",
+ label: "Capture",
+ cancelLabel: "Cancel",
+ });
+ expect(document.documentElement.scrollHeight).toBe(3400);
+ expect(begin.height).toBe(2400);
+ expect((await send({ action: "inspect" })).height).toBe(begin.height);
+ });
+ it("captures the initial range despite appended height and a persistent loader", async () => {
+ const loader = document.createElement("span");
+ loader.textContent = "加载中...";
+ document.body.append(loader);
+ vi.spyOn(loader, "getBoundingClientRect").mockReturnValue({
+ top: 500,
+ bottom: 530,
+ width: 100,
+ height: 30,
+ } as DOMRect);
+ const initial = await send({
+ action: "begin",
+ label: "Capture",
+ cancelLabel: "Cancel",
+ scope: "current",
+ });
+ Object.defineProperty(document.documentElement, "scrollHeight", {
+ configurable: true,
+ value: initial.height + 1000,
+ });
+ const move = send({ action: "move", y: initial.height - 600, capture: true, final: true });
+ await vi.advanceTimersByTimeAsync(2500);
+ await move;
+ expect(await send({ action: "inspect" })).toMatchObject({
+ height: initial.height,
+ bottomReady: true,
+ loading: true,
+ });
+ await send({ action: "finish" });
+ expect(loader.textContent).toBe("加载中...");
+ });
+ it("distinguishes hiding the page from cancelling by user input", async () => {
+ await send({ action: "begin", label: "Capture", cancelLabel: "Cancel" });
+ const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
+ document.dispatchEvent(new Event("visibilitychange"));
+ expect(cancel).toHaveBeenCalledExactlyOnceWith("one", "page_hidden");
+ hidden.mockRestore();
+ });
});
diff --git a/apps/extension/src/long-screenshot/page.ts b/apps/extension/src/long-screenshot/page.ts
index b064a9a0..20f5dd18 100644
--- a/apps/extension/src/long-screenshot/page.ts
+++ b/apps/extension/src/long-screenshot/page.ts
@@ -1,9 +1,17 @@
-import { type CaptureError, type PageMetrics, type PageRequest, ScreenshotError } from "./types";
+import {
+ type CaptureCancelReason,
+ type CaptureError,
+ type CaptureScope,
+ type PageMetrics,
+ type PageRequest,
+ ScreenshotError,
+} from "./types";
-export function createPageCapture(onCancel: (id: string) => void) {
+export function createPageCapture(onCancel: (id: string, reason: CaptureCancelReason) => void) {
let task: ReturnType | undefined;
- function prepare(id: string, label: string, cancelLabel: string) {
+ function prepare(id: string, label: string, cancelLabel: string, scope: CaptureScope = "follow") {
+ const limit = scope === "current" ? measure().height : Infinity;
const controller = new AbortController();
const original = { x: window.scrollX, y: window.scrollY };
const changes: (() => void)[] = [];
@@ -167,7 +175,7 @@ export function createPageCapture(onCancel: (id: string) => void) {
window.removeEventListener("keydown", keydown, true);
window.removeEventListener("wheel", interaction, true);
window.removeEventListener("touchstart", interaction, true);
- window.removeEventListener("pagehide", cancel);
+ window.removeEventListener("pagehide", navigated);
document.removeEventListener("visibilitychange", visibilityChanged);
host.remove();
for (const restore of changes.reverse()) restore();
@@ -178,9 +186,10 @@ export function createPageCapture(onCancel: (id: string) => void) {
style.remove();
}
- function cancel() {
+ function cancel(reason: CaptureCancelReason = "user_cancelled") {
+ controller.abort(new ScreenshotError("interrupted", reason));
finish();
- onCancel(id);
+ onCancel(id, reason);
}
function interaction() {
if (!paused) cancel();
@@ -200,18 +209,19 @@ export function createPageCapture(onCancel: (id: string) => void) {
}
}
function visibilityChanged() {
- if (document.hidden) cancel();
+ if (document.hidden) cancel("page_hidden");
}
- button.addEventListener("click", cancel);
+ const navigated = () => cancel("navigation");
+ button.addEventListener("click", () => cancel());
window.addEventListener("keydown", keydown, true);
window.addEventListener("wheel", interaction, { capture: true, passive: true });
window.addEventListener("touchstart", interaction, { capture: true, passive: true });
- window.addEventListener("pagehide", cancel);
+ window.addEventListener("pagehide", navigated);
document.addEventListener("visibilitychange", visibilityChanged);
function touch() {
clearTimeout(watchdog);
- watchdog = setTimeout(cancel, 15_000);
+ watchdog = setTimeout(() => cancel("watchdog_timeout"), 15_000);
}
touch();
@@ -220,7 +230,7 @@ export function createPageCapture(onCancel: (id: string) => void) {
controller.signal.throwIfAborted();
const abort = () => {
clearTimeout(timer);
- reject(new ScreenshotError("interrupted"));
+ reject(controller.signal.reason ?? new ScreenshotError("interrupted"));
};
const timer = setTimeout(() => {
controller.signal.removeEventListener("abort", abort);
@@ -229,8 +239,13 @@ export function createPageCapture(onCancel: (id: string) => void) {
controller.signal.addEventListener("abort", abort, { once: true });
});
- function inspect(): PageMetrics {
+ function measureRange(): PageMetrics {
const metrics = measure();
+ return { ...metrics, height: Math.min(metrics.height, limit) };
+ }
+
+ function inspect(): PageMetrics {
+ const metrics = measureRange();
const atBottom = metrics.y + metrics.viewportHeight >= metrics.height - 0.5;
const now = performance.now();
if (!atBottom || metrics.height !== lastHeight) bottomSince = now;
@@ -276,9 +291,10 @@ export function createPageCapture(onCancel: (id: string) => void) {
tailStart,
// A clock or live widget can mutate forever without moving any rows.
// Bound that quiet wait; an actual loading indicator still keeps waiting.
+ loading: busy,
bottomReady:
atBottom &&
- !busy &&
+ (scope === "current" || !busy) &&
now - bottomSince >= 1500 &&
(now - lastMutation >= 600 || now - bottomSince >= 5000),
};
@@ -343,6 +359,7 @@ export function createPageCapture(onCancel: (id: string) => void) {
id,
finish,
move,
+ measure: measureRange,
inspect,
touch,
signal: controller.signal,
@@ -379,8 +396,8 @@ export function createPageCapture(onCancel: (id: string) => void) {
if (request.action === "probe") return measure();
if (request.action === "begin") {
if (task && !task.signal.aborted) throw new ScreenshotError("busy");
- task = prepare(request.id, request.label, request.cancelLabel);
- return measure();
+ task = prepare(request.id, request.label, request.cancelLabel, request.scope);
+ return task.measure();
}
if (!task || task.id !== request.id) throw new ScreenshotError("interrupted");
if (request.action === "finish") {
diff --git a/apps/extension/src/long-screenshot/renderer.browser.test.ts b/apps/extension/src/long-screenshot/renderer.browser.test.ts
index 5f6ca41a..10509936 100644
--- a/apps/extension/src/long-screenshot/renderer.browser.test.ts
+++ b/apps/extension/src/long-screenshot/renderer.browser.test.ts
@@ -24,14 +24,16 @@ describe.skipIf(!process.env.BSK_LONG_SCREENSHOT_RENDERER)(
{ scale: 2, height: 2603, lazy: false },
{ scale: 1, height: 2190, lazy: false },
{ scale: 1, height: 488, lazy: false },
+ { scale: 1, height: 2603, lazy: false, current: true },
{ scale: 1, height: 2603, lazy: true },
{ scale: 1.25, height: 2603, lazy: false, delayedBatches: 2 },
{ scale: 2, height: 50_000, lazy: false },
- ])("captures exact pixels and previews at scale $scale, height $height, lazy $lazy", async ({
+ ])("captures exact pixels and previews at scale $scale, height $height, lazy $lazy, current $current", async ({
scale,
height,
lazy,
delayedBatches = 0,
+ current = false,
}) => {
const totalRows = height + (lazy ? 400 : 0) + delayedBatches * 900;
const require = createRequire(import.meta.resolve("wxt"));
@@ -56,7 +58,7 @@ describe.skipIf(!process.env.BSK_LONG_SCREENSHOT_RENDERER)(
globalThis.nextShot = null;
globalThis.captureTrace = [];
globalThis.runCapture = () => {
- capturePage({signal:abort.signal,label:'Capturing',cancelLabel:'Cancel',progress:()=>{},
+ capturePage({checkFreshness:true,scope:${JSON.stringify(current ? "current" : "follow")},signal:abort.signal,label:'Capturing',cancelLabel:'Cancel',progress:()=>{},
write:(...args)=>writer.write(...args),
page:async command=>{const metrics=await page.handle({type:'bsk/long-screenshot-page',id:'pixel-test',...command});
captureTrace.push({command,metrics});if(captureTrace.length>8)captureTrace.shift();return metrics;},
@@ -79,7 +81,12 @@ describe.skipIf(!process.env.BSK_LONG_SCREENSHOT_RENDERER)(
const url = new URL(req.url || "/", "http://localhost");
if (url.pathname === "/") {
res.setHeader("Content-Type", "text/html");
- res.end(fixture(height, lazy, delayedBatches));
+ res.end(
+ fixture(height, lazy, delayedBatches) +
+ (current
+ ? `加载中...`
+ : ""),
+ );
return;
}
const root = path.resolve("dist/chrome-mv3");
diff --git a/apps/extension/src/long-screenshot/source.test.ts b/apps/extension/src/long-screenshot/source.test.ts
index d96aee45..e87d29df 100644
--- a/apps/extension/src/long-screenshot/source.test.ts
+++ b/apps/extension/src/long-screenshot/source.test.ts
@@ -5,12 +5,15 @@ const native = vi.fn();
const attach = vi.fn();
const detach = vi.fn();
const sendCommand = vi.fn();
+const platform = vi.fn();
beforeEach(() => {
+ platform.mockReset().mockResolvedValue({ os: "linux" });
native.mockReset().mockResolvedValue("data:image/png;base64,native");
attach.mockReset().mockResolvedValue(undefined);
detach.mockReset().mockResolvedValue(undefined);
sendCommand.mockReset().mockResolvedValue({ data: "renderer" });
vi.stubGlobal("chrome", {
+ runtime: { getPlatformInfo: platform },
tabs: { captureVisibleTab: native },
debugger: { attach, detach, sendCommand },
});
@@ -21,7 +24,8 @@ afterEach(() => {
});
describe("screenshot backends", () => {
- it("keeps a working window capture free of debugger attachments", async () => {
+ it.each(["win", "mac", "linux"])("keeps popup surface capture unchanged on %s", async (os) => {
+ platform.mockResolvedValue({ os });
vi.useFakeTimers();
const source = await openScreenshotSource(4, 1, new AbortController().signal, async () => {});
const shot = source.capture();
@@ -30,6 +34,8 @@ describe("screenshot backends", () => {
await source.close();
expect(attach).not.toHaveBeenCalled();
expect(detach).not.toHaveBeenCalled();
+ expect(platform).not.toHaveBeenCalled();
+ expect(source.checkFreshness).toBeUndefined();
});
it("falls back before page measurement and releases its own attachment", async () => {
@@ -92,3 +98,49 @@ describe("screenshot backends", () => {
await vi.waitFor(() => expect(detach).toHaveBeenCalledExactlyOnceWith({ tabId: 4 }));
});
});
+
+describe("agent screenshot source", () => {
+ it.each(["mac", "linux"])("retains a working surface source on %s", async (os) => {
+ platform.mockResolvedValue({ os });
+ vi.useFakeTimers();
+ const owned = vi.fn(async () => ({ capture: async () => "renderer", close: async () => {} }));
+ const source = await openScreenshotSource(
+ 4,
+ 1,
+ new AbortController().signal,
+ async () => {},
+ true,
+ owned,
+ );
+ const pending = source.capture();
+ await vi.advanceTimersByTimeAsync(600);
+ expect(await pending).toContain("native");
+ expect(native).toHaveBeenCalledTimes(2);
+ expect(owned).not.toHaveBeenCalled();
+ expect(attach).not.toHaveBeenCalled();
+ expect(source.checkFreshness).toBeUndefined();
+ await source.close();
+ });
+
+ it("uses the owned renderer and frame validation on Windows", async () => {
+ platform.mockResolvedValue({ os: "win" });
+ native.mockResolvedValue("stale");
+ const capture = vi.fn(async () => "current");
+ const close = vi.fn(async () => {});
+ const check = vi.fn(async () => {});
+ const source = await openScreenshotSource(
+ 1,
+ 2,
+ new AbortController().signal,
+ check,
+ true,
+ async () => ({ capture, close }),
+ );
+ expect(await source.capture()).toBe("current");
+ expect(native).not.toHaveBeenCalled();
+ expect(source.checkFreshness).toBe(true);
+ expect(check).toHaveBeenCalledOnce();
+ await source.close();
+ expect(close).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/extension/src/long-screenshot/source.ts b/apps/extension/src/long-screenshot/source.ts
index 61f1f113..99702a71 100644
--- a/apps/extension/src/long-screenshot/source.ts
+++ b/apps/extension/src/long-screenshot/source.ts
@@ -1,6 +1,13 @@
import { ScreenshotError } from "./types";
import { waitForReply } from "./wait";
+export interface ScreenshotSource {
+ capture(): Promise;
+ close(): Promise;
+ /** Validate exposures for the Windows Agent renderer workaround. */
+ checkFreshness?: boolean;
+}
+
/** Select a capture backend before measuring the page: attaching a debugger can
* change the viewport through Chrome's infobar. Never switch midway through a PNG. */
export async function openScreenshotSource(
@@ -11,8 +18,17 @@ export async function openScreenshotSource(
allowDebugger = true,
// Agent requests can reuse their session's debugger instead of attaching a
// second owner. Popup captures keep the standalone attachment below.
- fallback?: () => Promise<{ capture(): Promise; close(): Promise }>,
-) {
+ ownedSource?: () => Promise,
+): Promise {
+ // Limit this workaround to Windows Agent captures. Other platforms and
+ // popup captures retain the working surface source and its fallback policy.
+ if (allowDebugger && ownedSource) {
+ const platform = await waitForReply(chrome.runtime.getPlatformInfo(), signal);
+ if (platform.os === "win") {
+ await checkTab();
+ return { ...(await ownedSource()), checkFreshness: true };
+ }
+ }
let lastShot = Date.now();
try {
// A short probe keeps ordinary captures free of debugger attachments, while
@@ -33,7 +49,7 @@ export async function openScreenshotSource(
if (!allowDebugger) throw new ScreenshotError("unavailable");
}
await checkTab();
- if (fallback) return fallback();
+ if (ownedSource) return ownedSource();
const target = { tabId };
const attaching = chrome.debugger.attach(target, "1.3");
try {
diff --git a/apps/extension/src/long-screenshot/types.ts b/apps/extension/src/long-screenshot/types.ts
index 881bdf18..83664488 100644
--- a/apps/extension/src/long-screenshot/types.ts
+++ b/apps/extension/src/long-screenshot/types.ts
@@ -2,6 +2,14 @@ export const LONG_SCREENSHOT = "bsk/long-screenshot";
export const LONG_SCREENSHOT_PAGE = "bsk/long-screenshot-page";
export const LONG_SCREENSHOT_STATE = "longScreenshotState";
+export type CaptureScope = "follow" | "current";
+export type CaptureCancelReason =
+ | "user_cancelled"
+ | "page_hidden"
+ | "navigation"
+ | "watchdog_timeout";
+export type CaptureFailureReason = CaptureCancelReason | "stale_frame" | "loading_stalled";
+
export type CapturePhase =
| "preparing"
| "capturing"
@@ -61,18 +69,21 @@ export interface PageMetrics {
tailStart?: number;
/** False while the current bottom is loading or has not settled yet. */
bottomReady?: boolean;
+ loading?: boolean;
}
export type PageCommand =
| { action: "probe" }
- | { action: "begin"; label: string; cancelLabel: string }
+ | { action: "begin"; label: string; cancelLabel: string; scope?: CaptureScope }
| { action: "move"; y: number; capture: boolean; final?: boolean }
| { action: "inspect" }
| { action: "pause"; paused: boolean }
| { action: "finish" };
export type PageRequest = PageCommand & { type: typeof LONG_SCREENSHOT_PAGE; id: string };
-export type PageReply = { ok: true; metrics: PageMetrics } | { ok: false; error: CaptureError };
+export type PageReply =
+ | { ok: true; metrics: PageMetrics }
+ | { ok: false; error: CaptureError; reason?: CaptureFailureReason };
export type CaptureMode = "auto" | "manual" | "visible";
@@ -88,7 +99,10 @@ export type CaptureReply =
| { ok: false; error: CaptureError };
export class ScreenshotError extends Error {
- constructor(public readonly code: CaptureError) {
+ constructor(
+ public readonly code: CaptureError,
+ public readonly reason?: CaptureFailureReason,
+ ) {
super(code);
}
}
diff --git a/apps/extension/src/tools/__tests__/screenshot-full-page.test.ts b/apps/extension/src/tools/__tests__/screenshot-full-page.test.ts
index c824687e..e0378a1f 100644
--- a/apps/extension/src/tools/__tests__/screenshot-full-page.test.ts
+++ b/apps/extension/src/tools/__tests__/screenshot-full-page.test.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { capturePage } from "@/long-screenshot/capture";
import { ScreenshotExports } from "@/long-screenshot/exports";
import { exportPng } from "@/long-screenshot/png";
+import { openScreenshotSource } from "@/long-screenshot/source";
import { SessionManager } from "@/session-manager/manager";
import { handleFullPageScreenshot } from "../screenshot-full-page";
@@ -10,7 +11,7 @@ vi.mock("@/long-screenshot/page-client", () => ({
}));
vi.mock("@/long-screenshot/capture", () => ({ capturePage: vi.fn(async () => {}) }));
vi.mock("@/long-screenshot/source", () => ({
- openScreenshotSource: async () => ({ capture: vi.fn(), close: async () => {} }),
+ openScreenshotSource: vi.fn(async () => ({ capture: vi.fn(), close: async () => {} })),
}));
vi.mock("@/long-screenshot/tiles", () => ({
TileWriter: class {
@@ -179,3 +180,41 @@ describe("full-page screenshot overlay cleanup", () => {
await deps.exports.dispose();
});
});
+
+describe("full-page scope and diagnostics", () => {
+ it("acknowledges the selected range and forwards source-specific validation", async () => {
+ const { manager, deps } = await setupCapture();
+ vi.mocked(openScreenshotSource).mockResolvedValueOnce({
+ capture: vi.fn(),
+ close: async () => {},
+ checkFreshness: true,
+ });
+ expect(
+ await handleFullPageScreenshot(manager, { session_id: "one", scope: "current" }, deps),
+ ).toMatchObject({ scope: "current" });
+ expect(vi.mocked(capturePage).mock.calls.at(-1)?.[0]).toMatchObject({
+ scope: "current",
+ loadingTimeoutMs: 30000,
+ checkFreshness: true,
+ });
+ await deps.exports.dispose();
+ });
+ it.each([
+ "page_hidden",
+ "watchdog_timeout",
+ "stale_frame",
+ "loading_stalled",
+ ] as const)("preserves %s and partial progress without exporting", async (reason) => {
+ const { manager, deps } = await setupCapture();
+ const { ScreenshotError } = await import("@/long-screenshot/types");
+ vi.mocked(capturePage).mockImplementationOnce(async (d) => {
+ d.progress("capturing", 50, 3);
+ throw new ScreenshotError("interrupted", reason);
+ });
+ expect(await handleFullPageScreenshot(manager, { session_id: "one" }, deps)).toMatchObject({
+ data: { reason, frames: 3, progress: 50 },
+ });
+ expect(exportPng).not.toHaveBeenCalled();
+ expect(deps.exports.discard).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/extension/src/tools/screenshot-full-page.ts b/apps/extension/src/tools/screenshot-full-page.ts
index 08818ed8..097abedb 100644
--- a/apps/extension/src/tools/screenshot-full-page.ts
+++ b/apps/extension/src/tools/screenshot-full-page.ts
@@ -6,7 +6,11 @@ import { createPageClient } from "@/long-screenshot/page-client";
import { exportPng } from "@/long-screenshot/png";
import { openScreenshotSource } from "@/long-screenshot/source";
import { TileWriter } from "@/long-screenshot/tiles";
-import { LONG_SCREENSHOT, ScreenshotError } from "@/long-screenshot/types";
+import {
+ type CaptureCancelReason,
+ LONG_SCREENSHOT,
+ ScreenshotError,
+} from "@/long-screenshot/types";
import { waitForReply } from "@/long-screenshot/wait";
import { isAgentControlledTab, type SessionManager } from "@/session-manager/manager";
import type {
@@ -42,6 +46,8 @@ export async function handleFullPageScreenshot(
): Promise {
if (signal?.aborted) return { code: "cancelled", message: "Screenshot cancelled" };
const timeout = params.timeout_ms ?? 120_000;
+ if (params.scope !== undefined && params.scope !== "current" && params.scope !== "follow")
+ return { code: "invalid_params", message: "scope must be current or follow" };
if (!Number.isInteger(timeout) || timeout <= 0 || timeout > 0xffffffff)
return { code: "invalid_params", message: "timeout_ms must be a positive u32" };
const ctx = lookupSession(manager, params, "screenshot --full-page");
@@ -99,7 +105,8 @@ export async function handleFullPageScreenshot(
const client = createPageClient(target.tabId, id, controller.signal, checkTab);
const changed = () => controller.abort(new ScreenshotError("changed"));
const navigated = (info: { tabId: number; frameId: number }) => {
- if (info.tabId === target.tabId && info.frameId === 0) changed();
+ if (info.tabId === target.tabId && info.frameId === 0)
+ controller.abort(new ScreenshotError("interrupted", "navigation"));
};
const removed = (tabId: number) => {
if (tabId === target.tabId) changed();
@@ -117,9 +124,24 @@ export async function handleFullPageScreenshot(
typeof message !== "object"
)
return;
- const request = message as { type?: string; action?: string; id?: string };
+ const request = message as {
+ type?: string;
+ action?: string;
+ id?: string;
+ reason?: CaptureCancelReason;
+ };
if (request.type === LONG_SCREENSHOT && request.action === "cancel" && request.id === id)
- controller.abort();
+ controller.abort(
+ new ScreenshotError(
+ "interrupted",
+ request.reason &&
+ ["user_cancelled", "page_hidden", "navigation", "watchdog_timeout"].includes(
+ request.reason,
+ )
+ ? request.reason
+ : "user_cancelled",
+ ),
+ );
};
chrome.webNavigation.onBeforeNavigate.addListener(navigated);
chrome.webNavigation.onCommitted.addListener(navigated);
@@ -130,6 +152,8 @@ export async function handleFullPageScreenshot(
const writer = new TileWriter(id, new URL(target.url).hostname);
let retained = false;
let phase = "preparing";
+ let frames = 0;
+ let progress = 0;
let source: Awaited> | undefined;
const cursor = markDialogCursor(deps.cdp, target.tabId);
try {
@@ -182,7 +206,13 @@ export async function handleFullPageScreenshot(
return createImageBitmap(await (await fetch(data)).blob());
},
write: (...args) => writer.write(...args, controller.signal),
- progress: () => {},
+ scope: params.scope,
+ loadingTimeoutMs: 30_000,
+ checkFreshness: source.checkFreshness,
+ progress: (_phase, value, count) => {
+ progress = value;
+ frames = count;
+ },
label: i18n.t("longScreenshot.pageProgress", { ns: "extension" }),
cancelLabel: i18n.t("longScreenshot.cancel", { ns: "extension" }),
});
@@ -196,6 +226,7 @@ export async function handleFullPageScreenshot(
deps.exports.put(ctx.sessionId, id, file);
retained = true;
return attachDialogs(deps.cdp, target.tabId, cursor, {
+ scope: params.scope ?? "follow",
capture_id: id,
width: writer.shot.width,
height: writer.shot.height,
@@ -207,14 +238,37 @@ export async function handleFullPageScreenshot(
const reason = controller.signal.aborted ? controller.signal.reason : error;
if (controller.signal.aborted && !(reason instanceof ScreenshotError))
return { code: "cancelled", message: "Full-page screenshot cancelled; no image was saved" };
+ const details = { phase, frames, progress, captured_height: writer.shot.height };
if (reason instanceof ScreenshotError) {
+ if (reason.reason) {
+ const messages = {
+ user_cancelled: "Full-page screenshot cancelled by user input",
+ page_hidden:
+ "Full-page screenshot stopped because the page became hidden; keep the capture tab visible",
+ navigation: "Full-page screenshot stopped because the page navigated",
+ watchdog_timeout: "Full-page screenshot lost contact with the page",
+ stale_frame: "Screenshot pixels did not update after scrolling; no image was saved",
+ loading_stalled:
+ "Page height stopped growing for 30s while its loading indicator remained; use --scope current to capture the current document range",
+ };
+ return {
+ code:
+ reason.reason === "user_cancelled"
+ ? "cancelled"
+ : reason.reason === "watchdog_timeout" || reason.reason === "loading_stalled"
+ ? "timeout"
+ : "cdp_failed",
+ message: messages[reason.reason],
+ data: { ...details, reason: reason.reason },
+ };
+ }
if (reason.code === "timeout")
return {
code: "timeout",
message: controller.signal.aborted
? "Full-page screenshot timed out; increase --timeout for longer pages"
: `Full-page screenshot: browser operation timed out (${phase})`,
- data: { phase },
+ data: details,
};
if (reason.code === "autoUnavailable" || reason.code === "unavailable")
return {
diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts
index 40a20cad..b7836b99 100644
--- a/apps/extension/src/transport/types.ts
+++ b/apps/extension/src/transport/types.ts
@@ -50,6 +50,12 @@ export type RpcErrorReason =
| "confirmation_ui_unavailable"
| "borrow_outcome_unknown"
| "screenshot_capture_failed"
+ | "user_cancelled"
+ | "page_hidden"
+ | "navigation"
+ | "watchdog_timeout"
+ | "stale_frame"
+ | "loading_stalled"
| "file_input_probe_failed"
| "file_input_not_activated"
| "set_file_input_failed"
@@ -344,11 +350,13 @@ export interface ScreenshotResult {
}
export interface ScreenshotFullPageParams {
+ scope?: "follow" | "current";
session_id: string;
tab_id?: number;
timeout_ms?: number;
}
export interface ScreenshotFullPageResult {
+ scope?: "follow" | "current";
capture_id: string;
width: number;
height: number;
diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md
index f5fc6bff..9a08643e 100644
--- a/crates/bsk-cli/skill/SKILL.md
+++ b/crates/bsk-cli/skill/SKILL.md
@@ -165,6 +165,7 @@ On an unrecoverable failure, report the blocker and stop the owned session.
bsk screenshot --session --out viewport.png
bsk screenshot --session --ref @e3 --out element.png --json
bsk screenshot --session --full-page --out page.png
+bsk screenshot --session --full-page --scope current --out loaded.png
```
Screenshots return a local PNG path; view the image to interpret it. `--out`
@@ -172,6 +173,10 @@ replaces an existing file; omitting it uses a temporary path. `--json` includes
dimensions and byte size. `--ref` and `--full-page` cannot be combined.
Full-page mode scrolls an ordinary webpage and restores its position/styles.
+The default `--scope follow` follows appended content. Use `--scope current` when
+capturing the currently loaded range is requested: it stops at the initial document
+height, even if a loading indicator remains. Later content below that boundary is
+excluded; report this range rather than claiming all feed entries were loaded.
Use a selected, session-controlled tab and stable viewport; `--tab-id` targets a
tab without selecting it. Internal browser pages, the Web Store, nested scrolling
panels and virtualized lists are unsupported. Capture/encoding defaults to 2m;
@@ -179,7 +184,12 @@ panels and virtualized lists are unsupported. Capture/encoding defaults to 2m;
capture plus transfer. Respect cancellation; do not blindly retry endless pages
or substitute a viewport image when an older extension rejects full-page capture.
Use matching CLI/extension builds. Ctrl-C cancels; failed full-page captures save
-no partial image.
+no partial image. A `loading_stalled` error means the bottom kept a loading
+indicator without height growth for 30s; do not simply increase the deadline.
+Choose `current` only when that range satisfies the request. Keep the capture tab
+visible: `page_hidden` is an environment interruption, while `user_cancelled`
+means user input stopped capture. For other failures follow the returned reason
+and hint; do not work around them by editing the page or stitching screenshots.
For `@eN canvas [visual:screenshot]`, observe returns text, not pixels. Screenshot
that ref when its contents matter; never infer Canvas controls or names from
diff --git a/crates/bsk-cli/src/cli/render_error.rs b/crates/bsk-cli/src/cli/render_error.rs
index ca65a766..32787bab 100644
--- a/crates/bsk-cli/src/cli/render_error.rs
+++ b/crates/bsk-cli/src/cli/render_error.rs
@@ -58,6 +58,12 @@ pub mod reason {
pub const CDP_EXTENSION_ACCESS_DENIED: &str = "cdp_extension_access_denied";
pub const BORROW_CONFLICT: &str = "borrow_conflict";
pub const SCREENSHOT_CAPTURE_FAILED: &str = "screenshot_capture_failed";
+ pub const SCREENSHOT_USER_CANCELLED: &str = "user_cancelled";
+ pub const SCREENSHOT_PAGE_HIDDEN: &str = "page_hidden";
+ pub const SCREENSHOT_NAVIGATION: &str = "navigation";
+ pub const SCREENSHOT_WATCHDOG_TIMEOUT: &str = "watchdog_timeout";
+ pub const SCREENSHOT_STALE_FRAME: &str = "stale_frame";
+ pub const SCREENSHOT_LOADING_STALLED: &str = "loading_stalled";
pub const FILE_INPUT_PROBE_FAILED: &str = "file_input_probe_failed";
pub const FILE_INPUT_NOT_ACTIVATED: &str = "file_input_not_activated";
pub const SET_FILE_INPUT_FAILED: &str = "set_file_input_failed";
@@ -403,6 +409,40 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
),
exit_code: base.exit_code,
},
+ (ErrorCode::Cancelled, reason::SCREENSHOT_USER_CANCELLED) => RenderInfo {
+ summary: "full-page screenshot cancelled by user input",
+ hint: Some("respect the interruption; do not automatically retry"),
+ exit_code: 2,
+ },
+ (ErrorCode::CdpFailed, reason::SCREENSHOT_PAGE_HIDDEN) => RenderInfo {
+ summary: "screenshot page became hidden",
+ hint: Some("keep the capture tab visible before starting another screenshot"),
+ exit_code: 3,
+ },
+ (ErrorCode::CdpFailed, reason::SCREENSHOT_NAVIGATION) => RenderInfo {
+ summary: "screenshot page navigated",
+ hint: Some("inspect the current page before starting another screenshot"),
+ exit_code: 3,
+ },
+ (ErrorCode::Timeout, reason::SCREENSHOT_WATCHDOG_TIMEOUT) => RenderInfo {
+ summary: "screenshot lost contact with the page",
+ hint: Some(
+ "check that the browser is responsive; increasing the total deadline does not repair page communication",
+ ),
+ exit_code: 4,
+ },
+ (ErrorCode::CdpFailed, reason::SCREENSHOT_STALE_FRAME) => RenderInfo {
+ summary: "screenshot pixels did not update after scrolling",
+ hint: Some("keep the capture window visible; do not stitch repeated viewport images"),
+ exit_code: 3,
+ },
+ (ErrorCode::Timeout, reason::SCREENSHOT_LOADING_STALLED) => RenderInfo {
+ summary: "page loading stalled at the bottom",
+ hint: Some(
+ "use --full-page --scope current only if the currently loaded document range satisfies the request",
+ ),
+ exit_code: 4,
+ },
(ErrorCode::CdpFailed, reason::SCREENSHOT_CAPTURE_FAILED) => RenderInfo {
summary: "the browser could not capture the tab image",
hint: Some(
@@ -675,6 +715,25 @@ mod tests {
assert_eq!(info.exit_code, 3);
}
+ #[test]
+ fn full_page_recovery_distinguishes_stalled_loading_from_user_input() {
+ let stalled = serde_json::json!({ "reason": reason::SCREENSHOT_LOADING_STALLED });
+ let info = info_for_error(ErrorCode::Timeout, Some(&stalled));
+ assert!(info.hint.unwrap().contains("--scope current"));
+ assert!(!info.hint.unwrap().contains("increase"));
+ assert_eq!(info.exit_code, 4);
+
+ let cancelled = serde_json::json!({ "reason": reason::SCREENSHOT_USER_CANCELLED });
+ let info = info_for_error(ErrorCode::Cancelled, Some(&cancelled));
+ assert!(info.hint.unwrap().contains("do not automatically retry"));
+ assert_eq!(info.exit_code, 2);
+
+ let hidden = serde_json::json!({ "reason": reason::SCREENSHOT_PAGE_HIDDEN });
+ let info = info_for_error(ErrorCode::CdpFailed, Some(&hidden));
+ assert!(info.hint.unwrap().contains("visible"));
+ assert_eq!(info.exit_code, 3);
+ }
+
#[test]
fn file_transfer_reasons_render_actionable_fallbacks() {
let unsupported = serde_json::json!({ "reason": reason::FILE_INPUT_NOT_ACTIVATED });
diff --git a/crates/bsk-cli/src/cli/screenshot.rs b/crates/bsk-cli/src/cli/screenshot.rs
index 87d6fb58..a1d95913 100644
--- a/crates/bsk-cli/src/cli/screenshot.rs
+++ b/crates/bsk-cli/src/cli/screenshot.rs
@@ -13,6 +13,7 @@ use bsk_protocol::Method;
use bsk_protocol::tools::{
ScreenshotFullPageParams, ScreenshotFullPageResult, ScreenshotParams, ScreenshotReadParams,
ScreenshotReadResult, ScreenshotReleaseParams, ScreenshotReleaseResult, ScreenshotResult,
+ ScreenshotScope,
};
use clap::Args;
@@ -40,6 +41,10 @@ pub struct ScreenshotArgs {
#[arg(long, conflicts_with = "ref_")]
pub full_page: bool,
+ /// Full-page range: follow appended content, or capture the initial document height.
+ #[arg(long, requires = "full_page", value_parser = ["follow", "current"])]
+ pub scope: Option,
+
/// Full-page capture/encoding timeout (e.g. 30s, 5m). Defaults to 2m.
#[arg(long, requires = "full_page", value_parser = crate::cli::navigate::parse_timeout_ms)]
pub timeout: Option,
@@ -119,12 +124,27 @@ fn run_full_page(sock: PathBuf, args: ScreenshotArgs, format: Format) -> Result<
"screenshot-full-page",
Method::ToolScreenshotFullPage,
Some(ScreenshotFullPageParams {
+ scope: args.scope.as_deref().map(|value| {
+ if value == "current" {
+ ScreenshotScope::Current
+ } else {
+ ScreenshotScope::Follow
+ }
+ }),
session_id: args.session.clone(),
tab_id: args.tab_id,
timeout_ms: Some(timeout),
}),
Duration::from_millis(u64::from(timeout) + 5_000),
)?;
+ // An older extension may ignore new optional params. Never save a capture
+ // that did not explicitly acknowledge the requested range.
+ if args.scope.as_deref() == Some("current") && reply.scope != Some(ScreenshotScope::Current) {
+ let _ = release_full_page(&sock, &args.session, &reply.capture_id);
+ return Err(CliError::Local(anyhow!(
+ "extension did not acknowledge --scope current; update the extension"
+ )));
+ }
let out = args.out.unwrap_or_else(default_out_path);
let result = write_full_page(&sock, &args.session, &reply, &out);
// Cleanup must run even after Ctrl-C or a local write failure. Unlike the
@@ -140,6 +160,7 @@ fn run_full_page(sock: PathBuf, args: ScreenshotArgs, format: Format) -> Result<
"format": reply.format,
"path": out.to_string_lossy(),
"byte_size": reply.byte_size,
+ "scope": reply.scope,
});
println!(
"{}",
@@ -326,6 +347,9 @@ mod tests {
assert!(parse(&["--full-page", "--timeout", "5m"]).is_ok());
assert!(parse(&["--full-page", "--ref", "@e1"]).is_err());
assert!(parse(&["--timeout", "5m"]).is_err());
+ assert!(parse(&["--scope", "current"]).is_err());
+ assert!(parse(&["--full-page", "--scope", "current"]).is_ok());
+ assert!(parse(&["--full-page", "--scope", "invalid"]).is_err());
assert!(parse(&["--full-page", "--timeout", "0ms"]).is_err());
}
diff --git a/crates/bsk-protocol/schema/tool_screenshot_full_page_params.json b/crates/bsk-protocol/schema/tool_screenshot_full_page_params.json
index 35e55a4a..8cf657a3 100644
--- a/crates/bsk-protocol/schema/tool_screenshot_full_page_params.json
+++ b/crates/bsk-protocol/schema/tool_screenshot_full_page_params.json
@@ -7,6 +7,17 @@
"session_id"
],
"properties": {
+ "scope": {
+ "description": "Follow appended content (default), or capture the initial document height.",
+ "anyOf": [
+ {
+ "$ref": "#/definitions/ScreenshotScope"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"session_id": {
"type": "string"
},
@@ -26,5 +37,14 @@
"format": "uint32",
"minimum": 0.0
}
+ },
+ "definitions": {
+ "ScreenshotScope": {
+ "type": "string",
+ "enum": [
+ "follow",
+ "current"
+ ]
+ }
}
}
diff --git a/crates/bsk-protocol/schema/tool_screenshot_full_page_result.json b/crates/bsk-protocol/schema/tool_screenshot_full_page_result.json
index ff43cd0a..bf15abda 100644
--- a/crates/bsk-protocol/schema/tool_screenshot_full_page_result.json
+++ b/crates/bsk-protocol/schema/tool_screenshot_full_page_result.json
@@ -34,6 +34,16 @@
"format": "uint32",
"minimum": 0.0
},
+ "scope": {
+ "anyOf": [
+ {
+ "$ref": "#/definitions/ScreenshotScope"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"tab_id": {
"type": "integer",
"format": "int64"
@@ -112,6 +122,13 @@
"prompt",
"beforeunload"
]
+ },
+ "ScreenshotScope": {
+ "type": "string",
+ "enum": [
+ "follow",
+ "current"
+ ]
}
}
}
diff --git a/crates/bsk-protocol/src/tools/observation.rs b/crates/bsk-protocol/src/tools/observation.rs
index 13a51bbd..f88f33b7 100644
--- a/crates/bsk-protocol/src/tools/observation.rs
+++ b/crates/bsk-protocol/src/tools/observation.rs
@@ -249,10 +249,20 @@ pub struct ScreenshotResult {
pub dialogs: Vec,
}
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum ScreenshotScope {
+ Follow,
+ Current,
+}
+
/// Scroll the session's active web page from top to bottom. PNG bytes are
/// exported in bounded chunks through `tool.screenshot_read`, then released.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ScreenshotFullPageParams {
+ /// Follow appended content (default), or capture the initial document height.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub scope: Option,
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option,
@@ -263,6 +273,8 @@ pub struct ScreenshotFullPageParams {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ScreenshotFullPageResult {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub scope: Option,
/// Opaque, session-scoped export capability. Never an agent filesystem path.
pub capture_id: String,
pub width: u32,
diff --git a/docs/long-screenshot.md b/docs/long-screenshot.md
index 221915a4..e99ef557 100644
--- a/docs/long-screenshot.md
+++ b/docs/long-screenshot.md
@@ -34,8 +34,38 @@ whole-page rescan is needed for that repair.
```sh
bsk screenshot --session --full-page --out page.png
bsk screenshot --session --full-page --timeout 5m --out page.png --json
+bsk screenshot --session --full-page --scope current --out loaded.png --json
```
+`--scope follow` (the default) follows appended content. `--scope current` captures
+only the document region measured at capture start, in CSS pixels. It still scrolls
+through that region to expose lazy images, but later height growth does not extend
+the range. Layout shifts can move content past that boundary; this mode is a bounded
+visual capture, not a promise to include every initially present article. The loading
+row remains visible. Success means the selected range was captured, not that the
+website finished loading. JSON results include the acknowledged `scope`; the CLI
+refuses to save a `current` request if an older extension does not acknowledge it.
+
+On Windows, Agent captures prefer their session's renderer screenshot source to
+avoid stale window-surface pixels seen after script-driven scrolling on some builds.
+Those captures also check compact frame signatures against the measured displacement
+and retry a clearly stale frame twice before failing with `stale_frame`. Blank or
+ambiguous content alone does not trigger this error. Layout and stale-frame retries
+have separate consecutive-failure counters.
+
+On other platforms, Agent captures retain a working window-surface source and fall
+back to their session renderer only if the initial probe fails. Popup captures keep
+their existing backend selection and do not enable the Agent freshness check.
+Screenshot backends never switch midway through an image.
+
+In Agent `follow` mode, 30 seconds at an unchanged bottom with a rendered loading
+indicator produces `loading_stalled`, rather than waiting until the total deadline.
+This saves no partial image. Errors carry phase, frame count, progress and captured
+height when available. `user_cancelled`, `page_hidden`, `navigation` and
+`watchdog_timeout` distinguish user input, visibility changes, navigation and lost
+page contact. Keeping the tab selected is necessary; hiding it can stop capture.
+Individual browser operations retain their own shorter deadlines.
+
The default viewport screenshot and `--ref` crop are unchanged. `--full-page` is exclusive
with `--ref`. An optional `--tab-id` must identify the selected tab in the session's Agent
Window; the tab must have been created or borrowed by that session. Automatic document
@@ -53,7 +83,8 @@ deadline. The popup and any existing user previews are independent of Agent capt
`tool.screenshot_read` and `tool.screenshot_release` RPCs. This distinct capture method lets
the daemon gate scrolling and wait for cancellation cleanup without changing the existing
passive `tool.screenshot` route. Full-page requests use `session_id`, optional `tab_id` and
-optional `timeout_ms`. Results contain dimensions, `format: "png"`, `tab_id`, `byte_size`,
+optional `timeout_ms` and `scope` (`follow` or `current`). Results include the acknowledged
+`scope` and dimensions, `format: "png"`, `tab_id`, `byte_size`,
optional dialogs and an opaque session-scoped `capture_id`; they contain no whole-image
base64 or agent filesystem path. Read requests use `session_id`, `capture_id` and byte
`offset`, returning at most 256 KiB encoded as `data_base64`, `next_offset` and `eof`.
diff --git a/skill/SKILL.md b/skill/SKILL.md
index f5fc6bff..9a08643e 100644
--- a/skill/SKILL.md
+++ b/skill/SKILL.md
@@ -165,6 +165,7 @@ On an unrecoverable failure, report the blocker and stop the owned session.
bsk screenshot --session --out viewport.png
bsk screenshot --session --ref @e3 --out element.png --json
bsk screenshot --session --full-page --out page.png
+bsk screenshot --session --full-page --scope current --out loaded.png
```
Screenshots return a local PNG path; view the image to interpret it. `--out`
@@ -172,6 +173,10 @@ replaces an existing file; omitting it uses a temporary path. `--json` includes
dimensions and byte size. `--ref` and `--full-page` cannot be combined.
Full-page mode scrolls an ordinary webpage and restores its position/styles.
+The default `--scope follow` follows appended content. Use `--scope current` when
+capturing the currently loaded range is requested: it stops at the initial document
+height, even if a loading indicator remains. Later content below that boundary is
+excluded; report this range rather than claiming all feed entries were loaded.
Use a selected, session-controlled tab and stable viewport; `--tab-id` targets a
tab without selecting it. Internal browser pages, the Web Store, nested scrolling
panels and virtualized lists are unsupported. Capture/encoding defaults to 2m;
@@ -179,7 +184,12 @@ panels and virtualized lists are unsupported. Capture/encoding defaults to 2m;
capture plus transfer. Respect cancellation; do not blindly retry endless pages
or substitute a viewport image when an older extension rejects full-page capture.
Use matching CLI/extension builds. Ctrl-C cancels; failed full-page captures save
-no partial image.
+no partial image. A `loading_stalled` error means the bottom kept a loading
+indicator without height growth for 30s; do not simply increase the deadline.
+Choose `current` only when that range satisfies the request. Keep the capture tab
+visible: `page_hidden` is an environment interruption, while `user_cancelled`
+means user input stopped capture. For other failures follow the returned reason
+and hint; do not work around them by editing the page or stitching screenshots.
For `@eN canvas [visual:screenshot]`, observe returns text, not pixels. Screenshot
that ref when its contents matter; never infer Canvas controls or names from