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