From 84f7bf7d491d698177e7ca09164ee0be3c1379eb Mon Sep 17 00:00:00 2001 From: Ljy-0827 Date: Tue, 15 Sep 2026 15:33:43 +0800 Subject: [PATCH 1/4] fix: capture full pages without activating controlled tabs --- .../src/long-screenshot/page.test.ts | 25 ++ apps/extension/src/long-screenshot/page.ts | 16 +- .../background-full-page.browser.test.ts | 257 ++++++++++++++++++ .../__tests__/screenshot-full-page.test.ts | 235 ++++++++++++++-- .../src/tools/screenshot-full-page.ts | 92 +++---- crates/bsk-cli/skill/SKILL.md | 13 +- docs/long-screenshot.md | 11 +- skill/SKILL.md | 13 +- 8 files changed, 570 insertions(+), 92 deletions(-) create mode 100644 apps/extension/src/tools/__tests__/background-full-page.browser.test.ts diff --git a/apps/extension/src/long-screenshot/page.test.ts b/apps/extension/src/long-screenshot/page.test.ts index 2b3452a5..c4e8f5b2 100644 --- a/apps/extension/src/long-screenshot/page.test.ts +++ b/apps/extension/src/long-screenshot/page.test.ts @@ -66,6 +66,31 @@ describe("page capture cleanup", () => { expect(cancel).not.toHaveBeenCalled(); }); + it("measures fractional CSS viewport dimensions while retaining scrollbar gutters", async () => { + vi.stubGlobal("innerWidth", 815); + vi.stubGlobal("innerHeight", 615); + vi.stubGlobal("visualViewport", { width: 800.4, height: 600.6, scale: 1 }); + expect(await send({ action: "probe" })).toMatchObject({ + viewportWidth: 800.4, + viewportHeight: 600.6, + innerWidth: 815.4, + innerHeight: 615.6, + }); + }); + + it("does not substitute a pinched visual viewport for layout geometry", async () => { + vi.stubGlobal("innerWidth", 815); + vi.stubGlobal("innerHeight", 615); + vi.stubGlobal("visualViewport", { width: 400, height: 300, scale: 2 }); + expect(await send({ action: "probe" })).toMatchObject({ + viewportWidth: 800, + viewportHeight: 600, + innerWidth: 815, + innerHeight: 615, + }); + + }); + it("restores each changed CSS property and the original two-dimensional scroll", async () => { await send({ action: "begin", label: "Capture", cancelLabel: "Cancel" }); const moving = send({ action: "move", y: 800, capture: true }); diff --git a/apps/extension/src/long-screenshot/page.ts b/apps/extension/src/long-screenshot/page.ts index af20e2d2..83769946 100644 --- a/apps/extension/src/long-screenshot/page.ts +++ b/apps/extension/src/long-screenshot/page.ts @@ -379,15 +379,21 @@ export function createPageCapture(onCancel: (id: string, reason: CaptureCancelRe function measure(): PageMetrics { const root = document.documentElement; const scrolling = document.scrollingElement ?? root; + // client/inner dimensions are integers. At fractional browser zoom that can + // lose more than one image pixel; the unpinched visual viewport retains the + // CSS precision needed to match CDP and native screenshot surfaces. + const visual = window.visualViewport; + const viewportWidth = visual?.scale === 1 ? visual.width : root.clientWidth; + const viewportHeight = visual?.scale === 1 ? visual.height : root.clientHeight; return { x: window.scrollX, y: window.scrollY, width: scrolling.scrollWidth, - height: Math.max(scrolling.scrollHeight, root.clientHeight), - viewportWidth: root.clientWidth, - viewportHeight: root.clientHeight, - innerWidth: window.innerWidth, - innerHeight: window.innerHeight, + height: Math.max(scrolling.scrollHeight, viewportHeight), + viewportWidth, + viewportHeight, + innerWidth: viewportWidth + (window.innerWidth - root.clientWidth), + innerHeight: viewportHeight + (window.innerHeight - root.clientHeight), dpr: window.devicePixelRatio, bottomOverlayHeight: task?.bottomOverlayHeight ?? 0, }; diff --git a/apps/extension/src/tools/__tests__/background-full-page.browser.test.ts b/apps/extension/src/tools/__tests__/background-full-page.browser.test.ts new file mode 100644 index 00000000..53a0353f --- /dev/null +++ b/apps/extension/src/tools/__tests__/background-full-page.browser.test.ts @@ -0,0 +1,257 @@ +// @vitest-environment node +// Opt in with BSK_BACKGROUND_CHROME after building the extension. Runs the real +// Agent handler, driver, page script, tiler and PNG exporter in an isolated +// extension; no daemon or user profile. The harness supplies only session setup. +import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { fixture } from "@/long-screenshot/test-fixture"; + +type Send = >( + method: string, + params?: object, + sessionId?: string, +) => Promise; + +// Bundle current production modules, not a second implementation of capture. +const entry = ` +import { ChromiumCdp } from '@/browser-driver/chromium-cdp'; +import { SessionManager } from '@/session-manager/manager'; +import { ScreenshotExports } from '@/long-screenshot/exports'; +import { handleFullPageScreenshot } from '@/tools/screenshot-full-page'; +globalThis.run = async (url) => { + const cdp = new ChromiumCdp(chrome.debugger); + const currentWindow = await chrome.windows.getCurrent(); + const [control] = await chrome.tabs.query({windowId:currentWindow.id,active:true}); + const manager = new SessionManager({agentWindow:{create:async()=>currentWindow.id,remove:async()=>{},ensureActiveTab:async()=>control.id}}); + const ctx = await manager.start('regression'); + const exports = new ScreenshotExports(id=>manager.has(id)); + const activated=[], focused=[], calls=[], trace=[]; + const message=chrome.tabs.sendMessage.bind(chrome.tabs); + chrome.tabs.sendMessage=async(...args)=>{const reply=await message(...args);if(args[1].type==='bsk/long-screenshot-page')trace.push({command:args[1],reply});return reply;}; + const onActive=info=>activated.push(info.tabId), onFocus=id=>focused.push(id); + chrome.tabs.onActivated.addListener(onActive); + chrome.windows.onFocusChanged.addListener(onFocus); + const send=cdp.send.bind(cdp); + cdp.send=(id,method,params)=>{calls.push(method);return send(id,method,params)}; + const created=[]; + const evaluate=async(id,expression)=>(await cdp.send(id,'Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true})).result.value; + const state=id=>evaluate(id,"({y:scrollY,sticky:document.querySelector('#sticky').getAttribute('style'),fixed:document.querySelector('#fixed').getAttribute('style')})"); + try { + const results=[]; + for (const variant of ['rows','other']) { + const tab=await chrome.tabs.create({windowId:currentWindow.id,url:'about:blank',active:false});created.push(tab.id); + ctx.agentCreatedTabs.add(tab.id); + await cdp.acquireBackgroundExecution(ctx.sessionId,tab.id); + await chrome.tabs.update(tab.id,{url:url+'/'+variant}); + for(let i=0;i<200;i++){ + if(await evaluate(tab.id,"document.readyState==='complete' && !!document.querySelector('#pattern')"))break; + await new Promise(r=>setTimeout(r,50)); + } + const original=await state(tab.id); + const dpr=await evaluate(tab.id,'devicePixelRatio'); + const result=await handleFullPageScreenshot(manager,{session_id:ctx.sessionId,tab_id:tab.id},{cdp,tabsApi:chrome.tabs,exports}); + if(result.code)throw new Error(JSON.stringify({result,variant,trace,calls})); + const parts=[];let offset=0; + for(;;){const chunk=await exports.read({session_id:ctx.sessionId,capture_id:result.capture_id,offset}); + if(chunk.code)throw new Error(JSON.stringify(chunk)); + parts.push(Uint8Array.from(atob(chunk.data_base64),c=>c.charCodeAt(0)));offset=chunk.next_offset;if(chunk.eof)break; + } + const bitmap=await createImageBitmap(new Blob(parts,{type:'image/png'})); + const canvas=new OffscreenCanvas(bitmap.width,bitmap.height),x=canvas.getContext('2d');x.drawImage(bitmap,0,0);bitmap.close(); + const bad=[]; + // Sample each CSS row's center. Fractional-DPR antialiasing is checked with + // a 3/255 color-conversion tolerance, as in the viewport regression. + // Avoid wrap boundaries where fractional-DPR interpolation is intentional. + for(let y=2;y<2600;y++){ + if(y%256<2||y%256>253)continue; + const pixel=Array.from(x.getImageData(Math.floor(100*dpr),Math.floor((31+y+.5)*dpr),1,1).data); + const expected=variant==='other'?[20,190,60]:[y%256,Math.floor(y/256),127]; + if(expected.some((v,i)=>Math.abs(pixel[i]-v)>3)&&bad.length<10)bad.push({y,pixel,expected}); + } + const footer=Array.from(x.getImageData(Math.floor(canvas.width-40*dpr),Math.floor(canvas.height-40*dpr),1,1).data); + results.push({result,dpr,bad,footer,original,restored:await state(tab.id),active:(await chrome.tabs.get(tab.id)).active}); + await exports.release({session_id:ctx.sessionId,capture_id:result.capture_id}); + } + return {results,activated,focused,calls,control:control.id,selected:(await chrome.tabs.query({windowId:currentWindow.id,active:true}))[0].id}; + } finally { + chrome.tabs.onActivated.removeListener(onActive);chrome.windows.onFocusChanged.removeListener(onFocus); + await exports.dispose();await cdp.detachAll();cdp.dispose(); + for(const id of created)await chrome.tabs.remove(id); + } +};`; + +describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)( + "background full-page browser regression", + () => { + it.each([ + { scale: 1, zoom: 1 }, + { scale: 2, zoom: 1.25 }, + ])("stitches target pixels and restores the page at $scale/$zoom", async ({ scale, zoom }) => { + const directory = await mkdtemp(path.join(tmpdir(), "bsk-full-page-extension-")); + const server = createServer((req, res) => { + res.setHeader("Content-Type", "text/html"); + const page = fixture(); + res.end( + req.url === "/other" ? page.replace(/rgb\(\d+,\d+,127\)/g, "rgb(20,190,60)") : page, + ); + }); + try { + const require = createRequire(import.meta.url); + const { build } = await import( + require.resolve("vite", { paths: [require.resolve("vitest")] }) + ); + await writeFile(path.join(directory, "entry.js"), entry); + await build({ + configFile: false, + logLevel: "error", + resolve: { + alias: { + "@": path.resolve("src"), + "@browser-skill/i18n": path.resolve("../../packages/i18n/src/index.ts"), + }, + }, + build: { + outDir: directory, + emptyOutDir: false, + lib: { + entry: path.join(directory, "entry.js"), + formats: ["iife"], + name: "Regression", + fileName: () => "regression.js", + }, + }, + }); + await cp( + path.resolve("dist/chrome-mv3/content-scripts"), + path.join(directory, "content-scripts"), + { recursive: true }, + ); + await writeFile( + path.join(directory, "manifest.json"), + JSON.stringify({ + manifest_version: 3, + background: { service_worker: "worker.js" }, + name: "Background full-page regression", + version: "1.0", + permissions: ["debugger", "tabs", "scripting", "webNavigation", "storage"], + host_permissions: ["http://127.0.0.1/*"], + content_scripts: [ + { + matches: ["http://127.0.0.1/*"], + js: ["content-scripts/long-screenshot-page.js"], + run_at: "document_end", + }, + ], + }), + ); + await writeFile( + path.join(directory, "worker.js"), + "chrome.runtime.onInstalled.addListener(() => {});", + ); + await writeFile( + path.join(directory, "regression.html"), + '', + ); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + const { withChrome } = await import( + new URL( + "../../../../../evals/browser/cases/regression/snapshot-coordinates/chrome.mjs", + import.meta.url, + ).href + ); + await withChrome( + { + executable: process.env.BSK_BACKGROUND_CHROME, + deviceScale: scale, + zoom, + extensionPath: directory, + softwareRendering: true, + headless: true, + }, + async (send: Send) => { + let origin = ""; + await expect + .poll( + async () => { + const { targetInfos } = await send<{ + targetInfos: { type: string; url: string }[]; + }>("Target.getTargets"); + const worker = targetInfos.find( + (target) => + target.type === "service_worker" && target.url.endsWith("/worker.js"), + ); + origin = worker?.url.slice(0, -"/worker.js".length) ?? ""; + return !!origin; + }, + { timeout: 10_000 }, + ) + .toBe(true); + const { targetId } = await send<{ targetId: string }>("Target.createTarget", { + url: `${origin}/regression.html`, + background: true, + }); + const { sessionId } = await send<{ sessionId: string }>("Target.attachToTarget", { + targetId, + flatten: true, + }); + const evaluate = async (expression: string) => { + const reply = await send<{ result: { value: T }; exceptionDetails?: unknown }>( + "Runtime.evaluate", + { expression, returnByValue: true, awaitPromise: true }, + sessionId, + ); + if (reply.exceptionDetails) throw new Error(JSON.stringify(reply.exceptionDetails)); + return reply.result.value; + }; + await expect.poll(() => evaluate("typeof run==='function'")).toBe(true); + await evaluate( + `run(${JSON.stringify(url)}).then(value=>globalThis.done={value},error=>globalThis.done={error:String(error)});true`, + ); + await expect.poll(() => evaluate("!!globalThis.done"), { timeout: 100_000 }).toBe(true); + const done = await evaluate<{ + error?: string; + value: { + results: { + result: { height: number }; + dpr: number; + bad: unknown[]; + footer: number[]; + original: unknown; + restored: unknown; + active: boolean; + }[]; + activated: number[]; + focused: number[]; + calls: string[]; + control: number; + selected: number; + }; + }>("globalThis.done"); + expect(done.error).toBeUndefined(); + for (const item of done.value.results) { + expect(item.bad).toEqual([]); + [255, 136, 0, 255].forEach((value, index) => + expect(Math.abs(item.footer[index] - value)).toBeLessThanOrEqual(3), + ); + expect(Math.abs(item.result.height - 2634 * item.dpr)).toBeLessThanOrEqual(1); + expect(item.restored).toEqual(item.original); + expect(item.active).toBe(false); + } + expect(done.value.selected).toBe(done.value.control); + expect(done.value.activated).toEqual([]); + expect(done.value.focused).toEqual([]); + expect(done.value.calls).not.toContain("Page.bringToFront"); + }, + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await rm(directory, { recursive: true, force: true }); + } + }, 120_000); + }, +); 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 e0378a1f..4b2de8dc 100644 --- a/apps/extension/src/tools/__tests__/screenshot-full-page.test.ts +++ b/apps/extension/src/tools/__tests__/screenshot-full-page.test.ts @@ -2,17 +2,14 @@ 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"; +import type { CdpRunner } from "../shared"; vi.mock("@/long-screenshot/page-client", () => ({ createPageClient: () => ({ documentId: "document-one", prepare: async () => {}, page: vi.fn() }), })); vi.mock("@/long-screenshot/capture", () => ({ capturePage: vi.fn(async () => {}) })); -vi.mock("@/long-screenshot/source", () => ({ - openScreenshotSource: vi.fn(async () => ({ capture: vi.fn(), close: async () => {} })), -})); vi.mock("@/long-screenshot/tiles", () => ({ TileWriter: class { shot = { width: 64, height: 256 }; @@ -44,12 +41,15 @@ async function setup() { url: "https://example.test/", } as chrome.tabs.Tab; const cdp = { - send: vi.fn(async () => { - throw new Error("Unexpected CDP call"); + getAttachmentId: vi.fn(() => "attachment-one" as string | undefined), + send: vi.fn(async (_id: number, method: string, _params?: object): Promise => { + if (method === "Page.getFrameTree") return {} as T; + if (method === "Page.captureScreenshot") return { data: "cG5n" } as T; + throw new Error(`Unexpected CDP call: ${method}`); }), }; const deps = { - cdp, + cdp: cdp as CdpRunner & typeof cdp, tabsApi: { get: vi.fn(async () => tab), query: vi.fn(async () => [tab]) }, exports: new ScreenshotExports((id) => manager.has(id)), }; @@ -71,7 +71,7 @@ describe("full-page screenshot target policy", () => { expect(deps.tabsApi.query).not.toHaveBeenCalled(); expect(deps.cdp.send).not.toHaveBeenCalled(); }); - it("requires a selected, explicitly controlled tab in the Agent Window", async () => { + it("requires an explicitly controlled tab in the Agent Window", async () => { const { manager, tab, deps } = await setup(); tab.windowId = 200; expect( @@ -82,12 +82,6 @@ describe("full-page screenshot target policy", () => { expect(await handleFullPageScreenshot(manager, { session_id: "one" }, deps)).toMatchObject({ code: "permission_denied", }); - manager.get("one")!.agentCreatedTabs.add(7); - tab.active = false; - expect(await handleFullPageScreenshot(manager, { session_id: "one" }, deps)).toMatchObject({ - code: "invalid_params", - data: { reason: "tab_not_active" }, - }); expect(deps.cdp.send).not.toHaveBeenCalled(); }); it("does not attempt automatic scrolling on browser-internal or non-web pages", async () => { @@ -114,9 +108,17 @@ async function setupCapture() { async (_tabId: number, _message: { phase: string }, _options: object) => ({}), ); vi.stubGlobal("chrome", { - webNavigation: { onBeforeNavigate: events[0], onCommitted: events[1] }, + webNavigation: { + onBeforeNavigate: events[0], + onCommitted: events[1], + getFrame: vi.fn(async () => ({ documentId: "document-one" })), + }, tabs: { onRemoved: events[2], onAttached: events[3], onActivated: events[4], sendMessage }, - runtime: { id: "extension", onMessage: events[5] }, + runtime: { + id: "extension", + onMessage: events[5], + getPlatformInfo: vi.fn(async () => ({ os: "mac" })), + }, }); vi.spyOn(context.deps.exports, "prepare").mockResolvedValue(); vi.spyOn(context.deps.exports, "discard").mockResolvedValue(); @@ -162,7 +164,8 @@ describe("full-page screenshot overlay cleanup", () => { if (phase === "begin") expect(capturePage).not.toHaveBeenCalled(); expect(exportPng).not.toHaveBeenCalled(); expect(deps.exports.discard).toHaveBeenCalledOnce(); - for (const event of events) + expect(events[4].addListener).not.toHaveBeenCalled(); + for (const event of events.filter((_, index) => index !== 4)) expect(event.removeListener).toHaveBeenCalledWith(event.addListener.mock.calls[0][0]); lateReply(); await vi.advanceTimersByTimeAsync(0); @@ -181,21 +184,205 @@ describe("full-page screenshot overlay cleanup", () => { }); }); -describe("full-page scope and diagnostics", () => { - it("acknowledges the selected range and forwards source-specific validation", async () => { +describe("full-page background capture", () => { + it.each([ + false, + true, + ])("uses only target CDP when active=%s and tolerates other tab selection", async (active) => { + const { manager, tab, deps, events } = await setupCapture(); + tab.active = active; + const bitmap = { close: vi.fn() }; + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => bitmap), + ); + vi.mocked(capturePage).mockImplementationOnce(async (options) => { + expect(await options.screenshot()).toBe(bitmap); + tab.active = false; + for (const [listener] of events[4].addListener.mock.calls) + listener({ tabId: 99, windowId: 100 }); + expect(await options.screenshot()).toBe(bitmap); + return { width: 64, height: 256 }; + }); + expect( + await handleFullPageScreenshot(manager, { session_id: "one", tab_id: 7 }, deps), + ).toMatchObject({ width: 64, height: 256 }); + expect( + deps.cdp.send.mock.calls.filter(([, method]) => method === "Page.captureScreenshot"), + ).toEqual( + Array.from({ length: 2 }, () => [ + 7, + "Page.captureScreenshot", + { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }, + ]), + ); + expect(events[4].addListener).not.toHaveBeenCalled(); + await deps.exports.dispose(); + }); + + it.each([ + "attachment", + "document", + "control", + "window", + "navigation", + ])("discards the job when %s changes between tiles", async (change) => { + const { manager, tab, deps, events, sendMessage } = await setupCapture(); + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => ({ close() {} })), + ); + vi.mocked(capturePage).mockImplementationOnce(async (options) => { + await options.screenshot(); + if (change === "attachment") deps.cdp.getAttachmentId.mockReturnValue("attachment-two"); + if (change === "document") + vi.mocked(chrome.webNavigation.getFrame).mockResolvedValue({ + documentId: "document-two", + } as never); + if (change === "control") manager.get("one")!.agentCreatedTabs.clear(); + if (change === "window") tab.windowId = 200; + if (change === "navigation") events[0].addListener.mock.calls[0][0]({ tabId: 7, frameId: 0 }); + await options.screenshot(); + return { width: 64, height: 256 }; + }); + expect(await handleFullPageScreenshot(manager, { session_id: "one" }, deps)).toMatchObject({ + code: "cdp_failed", + }); + expect( + deps.cdp.send.mock.calls.filter(([, method]) => method === "Page.captureScreenshot"), + ).toHaveLength(1); + expect(exportPng).not.toHaveBeenCalled(); + expect(deps.exports.discard).toHaveBeenCalledOnce(); + expect(sendMessage).toHaveBeenLastCalledWith(7, expect.objectContaining({ phase: "end" }), { + documentId: "document-one", + }); + }); + + it.each([ + "attachment", + "document", + "control", + ])("does not publish after %s changes while encoding", async (change) => { + const { manager, deps } = await setupCapture(); + const put = vi.spyOn(deps.exports, "put"); + vi.mocked(exportPng).mockImplementationOnce(async () => { + if (change === "attachment") deps.cdp.getAttachmentId.mockReturnValue(undefined); + if (change === "document") + vi.mocked(chrome.webNavigation.getFrame).mockResolvedValue({ + documentId: "document-two", + } as never); + if (change === "control") manager.get("one")!.agentCreatedTabs.clear(); + return new File(["png"], "page.png"); + }); + expect(await handleFullPageScreenshot(manager, { session_id: "one" }, deps)).toMatchObject({ + code: "cdp_failed", + }); + expect(put).not.toHaveBeenCalled(); + expect(deps.exports.discard).toHaveBeenCalledOnce(); + }); + + it("rejects a screenshot whose attachment changes during the CDP reply", async () => { const { manager, deps } = await setupCapture(); - vi.mocked(openScreenshotSource).mockResolvedValueOnce({ - capture: vi.fn(), - close: async () => {}, - checkFreshness: true, + deps.cdp.send.mockImplementation(async (_id: number, method: string): Promise => { + if (method === "Page.captureScreenshot") { + deps.cdp.getAttachmentId.mockReturnValue("replacement"); + return { data: "cG5n" } as T; + } + return {} as T; + }); + const decode = vi.fn(); + vi.stubGlobal("createImageBitmap", decode); + vi.mocked(capturePage).mockImplementationOnce(async (options) => { + await options.screenshot(); + return { width: 64, height: 256 }; + }); + expect(await handleFullPageScreenshot(manager, { session_id: "one" }, deps)).toMatchObject({ + code: "cdp_failed", + }); + expect(decode).not.toHaveBeenCalled(); + expect(deps.exports.discard).toHaveBeenCalledOnce(); + }); +}); + +describe("full-page capture interruption", () => { + it("honors cancellation from the original document only", async () => { + const { manager, deps, events } = await setupCapture(); + vi.mocked(capturePage).mockImplementationOnce(async (options) => { + const listener = events[5].addListener.mock.calls[0][0]; + const jobId = "agent-test"; + listener( + { type: "bsk/long-screenshot", action: "cancel", id: jobId }, + { + id: "extension", + tab: { id: 7 }, + frameId: 0, + documentId: "other-document", + }, + ); + expect(options.signal.aborted).toBe(false); + listener( + { type: "bsk/long-screenshot", action: "cancel", id: jobId }, + { + id: "extension", + tab: { id: 7 }, + frameId: 0, + documentId: "document-one", + }, + ); + options.signal.throwIfAborted(); + return { width: 64, height: 256 }; }); + vi.spyOn(crypto, "randomUUID").mockReturnValue("test" as ReturnType); + expect(await handleFullPageScreenshot(manager, { session_id: "one" }, deps)).toMatchObject({ + code: "cancelled", + }); + expect(exportPng).not.toHaveBeenCalled(); + expect(deps.exports.discard).toHaveBeenCalledOnce(); + }); + + it("bounds a stalled CDP capture and restores the overlay", async () => { + vi.useFakeTimers(); + const { manager, deps, sendMessage } = await setupCapture(); + deps.cdp.send.mockImplementation(async (_id: number, method: string): Promise => { + if (method === "Page.captureScreenshot") return new Promise(() => {}); + return {} as T; + }); + vi.mocked(capturePage).mockImplementationOnce(async (options) => { + await options.screenshot(); + return { width: 64, height: 256 }; + }); + const pending = handleFullPageScreenshot(manager, { session_id: "one", timeout_ms: 50 }, deps); + await vi.advanceTimersByTimeAsync(60); + expect(await pending).toMatchObject({ code: "timeout" }); + expect(sendMessage).toHaveBeenLastCalledWith(7, expect.objectContaining({ phase: "end" }), { + documentId: "document-one", + }); + expect(deps.exports.discard).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("full-page scope and diagnostics", () => { + it.each([ + "win", + "mac", + "linux", + ])("acknowledges the selected range and preserves freshness policy on %s", async (os) => { + const { manager, deps } = await setupCapture(); + vi.mocked(chrome.runtime.getPlatformInfo).mockResolvedValue({ + os, + } as chrome.runtime.PlatformInfo); 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, + checkFreshness: os === "win", }); await deps.exports.dispose(); }); diff --git a/apps/extension/src/tools/screenshot-full-page.ts b/apps/extension/src/tools/screenshot-full-page.ts index 097abedb..d707b93d 100644 --- a/apps/extension/src/tools/screenshot-full-page.ts +++ b/apps/extension/src/tools/screenshot-full-page.ts @@ -4,7 +4,6 @@ import { capturePage } from "@/long-screenshot/capture"; import type { ScreenshotExports } from "@/long-screenshot/exports"; 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 { type CaptureCancelReason, @@ -64,12 +63,6 @@ export async function handleFullPageScreenshot( "agent_window_scope", "Full-page screenshot scrolls the page; borrow this tab into the session first", ); - if (!target.active) - return rpcError( - "invalid_params", - "tab_not_active", - "Select the target tab before capturing a full-page screenshot", - ); if (!target.url || !/^https?:\/\//i.test(target.url)) return { code: "unsupported", @@ -89,18 +82,30 @@ export async function handleFullPageScreenshot( else deadline = setTimeout(armDeadline, Math.min(remaining, 0x7fffffff)); }; armDeadline(); - const checkTab = async () => { + // Never resume a partially stitched job on a replacement debugger connection. + let attachmentId: string | undefined; + const checkOwnership = () => { controller.signal.throwIfAborted(); - const current = await deps.tabsApi.get(target.tabId); if ( manager.get(ctx.sessionId) !== ctx || !isAgentControlledTab(ctx, target.tabId) || - !current.active || - current.windowId !== target.windowId || - current.url !== target.url + (attachmentId !== undefined && deps.cdp.getAttachmentId?.(target.tabId) !== attachmentId) ) throw new ScreenshotError("changed"); - controller.signal.throwIfAborted(); + }; + const checkTab = async () => { + checkOwnership(); + const current = await waitForReply(deps.tabsApi.get(target.tabId), controller.signal); + if (current.windowId !== target.windowId || current.url !== target.url) + throw new ScreenshotError("changed"); + if (client.documentId) { + const frame = await waitForReply( + chrome.webNavigation.getFrame({ tabId: target.tabId, frameId: 0 }), + controller.signal, + ); + if (frame?.documentId !== client.documentId) throw new ScreenshotError("changed"); + } + checkOwnership(); }; const client = createPageClient(target.tabId, id, controller.signal, checkTab); const changed = () => controller.abort(new ScreenshotError("changed")); @@ -111,9 +116,6 @@ export async function handleFullPageScreenshot( const removed = (tabId: number) => { if (tabId === target.tabId) changed(); }; - const activated = (info: chrome.tabs.TabActiveInfo) => { - if (info.windowId === target.windowId && info.tabId !== target.tabId) changed(); - }; const cancelled = (message: unknown, sender: chrome.runtime.MessageSender) => { if ( sender.id !== chrome.runtime.id || @@ -147,50 +149,29 @@ export async function handleFullPageScreenshot( chrome.webNavigation.onCommitted.addListener(navigated); chrome.tabs.onRemoved.addListener(removed); chrome.tabs.onAttached.addListener(removed); - chrome.tabs.onActivated.addListener(activated); chrome.runtime.onMessage.addListener(cancelled); 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 { await deps.exports.prepare(); + // Attach before measuring: the debugger infobar can change viewport geometry. + // The dispatcher owns background execution; this job never toggles it. + await checkTab(); + deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); + await waitForReply(deps.cdp.send(target.tabId, "Page.getFrameTree"), controller.signal); + attachmentId = deps.cdp.getAttachmentId?.(target.tabId); + if (!attachmentId) throw new ScreenshotError("changed"); + const platform = await waitForReply(chrome.runtime.getPlatformInfo(), controller.signal); await client.prepare(); await withScreenshotOverlayHidden( target.tabId, client.documentId!, controller.signal, async () => { - phase = "opening capture source"; - source = await openScreenshotSource( - target.tabId, - target.windowId, - controller.signal, - checkTab, - true, - async () => { - deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); - await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url); - return { - async capture() { - const shot = await waitForReply( - deps.cdp.send<{ data: string }>(target.tabId, "Page.captureScreenshot", { - format: "png", - fromSurface: true, - captureBeyondViewport: false, - }), - controller.signal, - ); - if (!shot.data) throw new ScreenshotError("captureFailed"); - return `data:image/png;base64,${shot.data}`; - }, - async close() {}, // The session retains ownership of its debugger. - }; - }, - ); phase = "capturing"; await capturePage({ page: (command) => { @@ -200,7 +181,18 @@ export async function handleFullPageScreenshot( signal: controller.signal, screenshot: async () => { phase = "reading viewport pixels"; - const data = await source!.capture(); + await checkTab(); + checkOwnership(); + const shot = await waitForReply( + deps.cdp.send<{ data?: string }>(target.tabId, "Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }), + controller.signal, + ); + if (!shot.data) throw new ScreenshotError("captureFailed"); + const data = `data:image/png;base64,${shot.data}`; await checkTab(); phase = "decoding viewport pixels"; return createImageBitmap(await (await fetch(data)).blob()); @@ -208,7 +200,8 @@ export async function handleFullPageScreenshot( write: (...args) => writer.write(...args, controller.signal), scope: params.scope, loadingTimeoutMs: 30_000, - checkFreshness: source.checkFreshness, + // Preserve the Windows stale-frame guard with the target-only CDP source. + checkFreshness: platform.os === "win", progress: (_phase, value, count) => { progress = value; frames = count; @@ -223,6 +216,9 @@ export async function handleFullPageScreenshot( phase = "encoding"; const file = await exportPng(writer.shot, controller.signal); controller.signal.throwIfAborted(); + await checkTab(); + // No await between the final ownership check and publishing the export. + checkOwnership(); deps.exports.put(ctx.sessionId, id, file); retained = true; return attachDialogs(deps.cdp, target.tabId, cursor, { @@ -295,9 +291,7 @@ export async function handleFullPageScreenshot( chrome.webNavigation.onCommitted.removeListener(navigated); chrome.tabs.onRemoved.removeListener(removed); chrome.tabs.onAttached.removeListener(removed); - chrome.tabs.onActivated.removeListener(activated); chrome.runtime.onMessage.removeListener(cancelled); - await source?.close(); if (!retained) await deps.exports.discard(id); } } diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md index 7b40c46c..d72d83ed 100644 --- a/crates/bsk-cli/skill/SKILL.md +++ b/crates/bsk-cli/skill/SKILL.md @@ -141,9 +141,10 @@ window. For a background-created tab (`tab create --no-active`), retain the retu `tab_id` and pass `--tab-id ` to observation, navigation and input commands. Created and borrowed web pages continue running while controlled even after they move into the background. A default created tab starts at `about:blank`. -Ordinary viewport and full-page screenshots still require an active tab; do not -activate a background task just to work around that limitation. Prefer semantic -observation, and report the limitation when an image is required. +Full-page screenshots support background controlled tabs; pass `--tab-id` without +selecting the target. Ordinary viewport screenshots still require an active tab; +do not activate a background task just to work around that limitation. Prefer +semantic observation first and take a screenshot when the task needs image content. Never invent tab IDs or keep a user tab across unrelated work. Do not repeat pending, denied or timed-out borrows. For `borrow_outcome_unknown`, inspect tab/ @@ -219,8 +220,10 @@ The default `--scope follow` follows appended content. Use `--scope current` whe 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 +Use a session-controlled tab and stable viewport; `--tab-id` targets a tab without +selecting it or focusing the window. Switching to another tab does not cancel +capture; navigation, loss of control or a debugger reconnection does. +Internal browser pages, the Web Store, nested scrolling panels and virtualized lists are unsupported. Capture/encoding defaults to 2m; `--timeout 5m` extends it only in full-page mode. Allow the shell enough time for capture plus transfer. Respect cancellation; do not blindly retry endless pages diff --git a/docs/long-screenshot.md b/docs/long-screenshot.md index e99ef557..4c562946 100644 --- a/docs/long-screenshot.md +++ b/docs/long-screenshot.md @@ -67,14 +67,17 @@ 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 +with `--ref`. An optional `--tab-id` identifies a tab in the session's Agent Window; +the tab must have been created or borrowed by that session. Background targets are supported +without selecting the tab or focusing the window. Agent capture uses target-scoped CDP +for every viewport, with no fallback to the window's selected tab. Automatic document scrolling is transient page input and obeys the existing user-interrupt gate. It supports scriptable HTTP(S) pages, not restricted browser pages or nested/virtualized scrollers. Agent capture restores scroll and temporary styles on completion, failure, timeout or -cancellation. Ctrl-C cancels capture or transfer; a page navigation or tab switch stops the -capture. Unlike interactive Quick Actions, an Agent failure never returns partial success. +cancellation while the original document remains responsive. Ctrl-C cancels capture or transfer. +Navigation, loss of control or a debugger connection change stops capture; selecting another +tab does not. All tiles must belong to the same document and debugger connection. Unlike interactive Quick Actions, an Agent failure never returns partial success. The capture/encoding deadline is two minutes by default and can be changed with `--timeout`. No fixed page-height or full-image canvas limit is added. Infinite scrolling may reach the deadline. The popup and any existing user previews are independent of Agent captures. diff --git a/skill/SKILL.md b/skill/SKILL.md index 7b40c46c..d72d83ed 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -141,9 +141,10 @@ window. For a background-created tab (`tab create --no-active`), retain the retu `tab_id` and pass `--tab-id ` to observation, navigation and input commands. Created and borrowed web pages continue running while controlled even after they move into the background. A default created tab starts at `about:blank`. -Ordinary viewport and full-page screenshots still require an active tab; do not -activate a background task just to work around that limitation. Prefer semantic -observation, and report the limitation when an image is required. +Full-page screenshots support background controlled tabs; pass `--tab-id` without +selecting the target. Ordinary viewport screenshots still require an active tab; +do not activate a background task just to work around that limitation. Prefer +semantic observation first and take a screenshot when the task needs image content. Never invent tab IDs or keep a user tab across unrelated work. Do not repeat pending, denied or timed-out borrows. For `borrow_outcome_unknown`, inspect tab/ @@ -219,8 +220,10 @@ The default `--scope follow` follows appended content. Use `--scope current` whe 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 +Use a session-controlled tab and stable viewport; `--tab-id` targets a tab without +selecting it or focusing the window. Switching to another tab does not cancel +capture; navigation, loss of control or a debugger reconnection does. +Internal browser pages, the Web Store, nested scrolling panels and virtualized lists are unsupported. Capture/encoding defaults to 2m; `--timeout 5m` extends it only in full-page mode. Allow the shell enough time for capture plus transfer. Respect cancellation; do not blindly retry endless pages From 72d79a615c4e0aee816f175164103387154c5fb4 Mon Sep 17 00:00:00 2001 From: Ljy-0827 Date: Tue, 15 Sep 2026 18:41:53 +0800 Subject: [PATCH 2/4] test: stabilize daemon discovery metadata replacement regression --- crates/bsk-cli/tests/daemon_discovery.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/bsk-cli/tests/daemon_discovery.rs b/crates/bsk-cli/tests/daemon_discovery.rs index d8b6d7a7..9315e424 100644 --- a/crates/bsk-cli/tests/daemon_discovery.rs +++ b/crates/bsk-cli/tests/daemon_discovery.rs @@ -420,20 +420,28 @@ fn invalid_and_mismatched_responses_do_not_trigger_startup() { #[test] fn discovery_rereads_metadata_when_an_instance_changes_during_probe() { - let daemon = MockDaemon::new(FOREIGN_PID, |_, info| { + let daemon = MockDaemon::new(FOREIGN_PID, |n, info| { let mut replacement = info.clone(); replacement.pid -= 1; - write_to_path( - &replacement, - &info.sock_path.parent().unwrap().join("daemon.json"), - ) - .unwrap(); + if n == 0 { + // Publish only after discovery has read the original metadata. + let home = info.sock_path.parent().unwrap(); + std::fs::rename(home.join("replacement.json"), home.join("daemon.json")).unwrap(); + } Some(status(&replacement)) }); + // Prepare and sync outside the CLI's probe deadline: this test exercises + // atomic metadata replacement, not filesystem durability latency. + let mut replacement: DaemonInfo = serde_json::from_slice(&daemon.metadata()).unwrap(); + replacement.pid -= 1; + write_to_path(&replacement, &daemon.home().join("replacement.json")).unwrap(); let out = run(daemon.home(), &["--json", "status"]); success(&out); let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); assert_eq!(value["pid"], FOREIGN_PID - 1); + let published: DaemonInfo = serde_json::from_slice(&daemon.metadata()).unwrap(); + assert_eq!(published, replacement); + assert!(!daemon.home().join("replacement.json").exists()); assert!(!daemon.home().join("daemon.lock").exists()); } From c772fa0e8f7a54a3c5835b9812b80ac8eb27c8fa Mon Sep 17 00:00:00 2001 From: Ljy-0827 Date: Tue, 15 Sep 2026 19:06:05 +0800 Subject: [PATCH 3/4] fix: hide root overlay scrollbars during full-page capture --- .../src/long-screenshot/page.test.ts | 45 +++++++++++++++++++ apps/extension/src/long-screenshot/page.ts | 6 +++ .../background-full-page.browser.test.ts | 44 +++++++++++++++--- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/apps/extension/src/long-screenshot/page.test.ts b/apps/extension/src/long-screenshot/page.test.ts index c4e8f5b2..e49251ae 100644 --- a/apps/extension/src/long-screenshot/page.test.ts +++ b/apps/extension/src/long-screenshot/page.test.ts @@ -91,6 +91,51 @@ describe("page capture cleanup", () => { }); + it.each([ + "finish", + "cancel", + "watchdog", + "dispose", + ])("restores root scrollbar styles after %s without changing nested scrollbars", async (end) => { + vi.stubGlobal("innerWidth", 800); + vi.stubGlobal("innerHeight", 600); + const root = document.documentElement; + root.style.setProperty("scrollbar-width", "thin", "important"); + const nested = document.createElement("div"); + nested.style.cssText = "overflow:auto;scrollbar-width:thin"; + document.body.append(nested); + const nestedStyle = nested.getAttribute("style"); + try { + const before = await send({ action: "probe" }); + expect(await send({ action: "begin", label: "Capture", cancelLabel: "Cancel" })).toEqual( + before, + ); + expect(root.style.getPropertyValue("scrollbar-width")).toBe("none"); + if (end === "finish") await send({ action: "finish" }); + else if (end === "cancel") + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + else if (end === "watchdog") await vi.advanceTimersByTimeAsync(15_001); + else capture.dispose(); + expect(root.style.getPropertyValue("scrollbar-width")).toBe("thin"); + expect(root.style.getPropertyPriority("scrollbar-width")).toBe("important"); + expect(nested.getAttribute("style")).toBe(nestedStyle); + expect(window.scrollY).toBe(350); + } finally { + capture.dispose(); + root.removeAttribute("style"); + } + }); + + it.each([ + [815, 600], + [800, 615], + ])("preserves reserved scrollbar space at %s/%s", async (width, height) => { + vi.stubGlobal("innerWidth", width); + vi.stubGlobal("innerHeight", height); + await send({ action: "begin", label: "Capture", cancelLabel: "Cancel" }); + expect(document.documentElement.style.getPropertyValue("scrollbar-width")).toBe(""); + }); + it("restores each changed CSS property and the original two-dimensional scroll", async () => { await send({ action: "begin", label: "Capture", cancelLabel: "Cancel" }); const moving = send({ action: "move", y: 800, capture: true }); diff --git a/apps/extension/src/long-screenshot/page.ts b/apps/extension/src/long-screenshot/page.ts index 83769946..481502c2 100644 --- a/apps/extension/src/long-screenshot/page.ts +++ b/apps/extension/src/long-screenshot/page.ts @@ -25,6 +25,12 @@ export function createPageCapture(onCancel: (id: string, reason: CaptureCancelRe visibility: string; priority: string; }[] = []; + const root = document.documentElement; + // Overlay scrollbars paint over content and survive the tiler's gutter crop. + // Only suppress them when neither axis reserves space: removing a classic + // scrollbar would change wrapping and responsive layout during capture. + if (root.clientWidth === window.innerWidth && root.clientHeight === window.innerHeight) + setStyle(root, "scrollbar-width", "none"); const style = document.createElement("style"); style.textContent = ` html, body, * { scroll-behavior: auto !important; scroll-snap-type: none !important; diff --git a/apps/extension/src/tools/__tests__/background-full-page.browser.test.ts b/apps/extension/src/tools/__tests__/background-full-page.browser.test.ts index 53a0353f..a3df0e5e 100644 --- a/apps/extension/src/tools/__tests__/background-full-page.browser.test.ts +++ b/apps/extension/src/tools/__tests__/background-full-page.browser.test.ts @@ -2,6 +2,8 @@ // Opt in with BSK_BACKGROUND_CHROME after building the extension. Runs the real // Agent handler, driver, page script, tiler and PNG exporter in an isolated // extension; no daemon or user profile. The harness supplies only session setup. +// BSK_OVERLAY_SCROLLBAR=1 runs headed and requires visible native overlay pixels +// in the uncaptured page as a positive control (use macOS overlay scrollbars). import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { createRequire } from "node:module"; @@ -22,7 +24,7 @@ import { ChromiumCdp } from '@/browser-driver/chromium-cdp'; import { SessionManager } from '@/session-manager/manager'; import { ScreenshotExports } from '@/long-screenshot/exports'; import { handleFullPageScreenshot } from '@/tools/screenshot-full-page'; -globalThis.run = async (url) => { +globalThis.run = async (url, overlay) => { const cdp = new ChromiumCdp(chrome.debugger); const currentWindow = await chrome.windows.getCurrent(); const [control] = await chrome.tabs.query({windowId:currentWindow.id,active:true}); @@ -39,7 +41,7 @@ globalThis.run = async (url) => { cdp.send=(id,method,params)=>{calls.push(method);return send(id,method,params)}; const created=[]; const evaluate=async(id,expression)=>(await cdp.send(id,'Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true})).result.value; - const state=id=>evaluate(id,"({y:scrollY,sticky:document.querySelector('#sticky').getAttribute('style'),fixed:document.querySelector('#fixed').getAttribute('style')})"); + const state=id=>evaluate(id,"({y:scrollY,root:document.documentElement.getAttribute('style'),width:document.documentElement.clientWidth,height:document.documentElement.clientHeight,sticky:document.querySelector('#sticky').getAttribute('style'),fixed:document.querySelector('#fixed').getAttribute('style')})"); try { const results=[]; for (const variant of ['rows','other']) { @@ -51,7 +53,22 @@ globalThis.run = async (url) => { if(await evaluate(tab.id,"document.readyState==='complete' && !!document.querySelector('#pattern')"))break; await new Promise(r=>setTimeout(r,50)); } + await evaluate(tab.id, "scrollTo({top:"+(variant==='rows'?333:900)+",behavior:'instant'})"); const original=await state(tab.id); + let overlayPixels=0; + if(overlay && variant==='other') { + const geometry=await evaluate(tab.id,'({width:innerWidth,height:innerHeight})'); + if(geometry.width!==original.width||geometry.height!==original.height)throw new Error('Overlay regression requires zero scrollbar gutter'); + const raw=await cdp.send(tab.id,'Page.captureScreenshot',{format:'png',fromSurface:true,captureBeyondViewport:false}); + const image=await createImageBitmap(await (await fetch('data:image/png;base64,'+raw.data)).blob()); + const surface=new OffscreenCanvas(image.width,image.height),ctx=surface.getContext('2d');ctx.drawImage(image,0,0);image.close(); + const scale=surface.width/geometry.width; + for(let y=50;yMath.abs(pixel[i]-v)>3))overlayPixels++; + } + if(!overlayPixels)throw new Error('Overlay regression requires visible scrollbar pixels before capture'); + } const dpr=await evaluate(tab.id,'devicePixelRatio'); const result=await handleFullPageScreenshot(manager,{session_id:ctx.sessionId,tab_id:tab.id},{cdp,tabsApi:chrome.tabs,exports}); if(result.code)throw new Error(JSON.stringify({result,variant,trace,calls})); @@ -72,8 +89,15 @@ globalThis.run = async (url) => { const expected=variant==='other'?[20,190,60]:[y%256,Math.floor(y/256),127]; if(expected.some((v,i)=>Math.abs(pixel[i]-v)>3)&&bad.length<10)bad.push({y,pixel,expected}); } + const edgeBad=[]; + for(let y=50;y<2500;y++) { + if(y%256<2||y%256>253)continue; + const pixel=Array.from(x.getImageData(canvas.width-Math.ceil(3*dpr),Math.floor((31+y+.5)*dpr),1,1).data); + const expected=variant==='other'?[20,190,60]:[y%256,Math.floor(y/256),127]; + if(expected.some((v,i)=>Math.abs(pixel[i]-v)>3)&&edgeBad.length<10)edgeBad.push({y,pixel,expected}); + } const footer=Array.from(x.getImageData(Math.floor(canvas.width-40*dpr),Math.floor(canvas.height-40*dpr),1,1).data); - results.push({result,dpr,bad,footer,original,restored:await state(tab.id),active:(await chrome.tabs.get(tab.id)).active}); + results.push({result,dpr,bad,edgeBad,overlayPixels,footer,original,restored:await state(tab.id),active:(await chrome.tabs.get(tab.id)).active}); await exports.release({session_id:ctx.sessionId,capture_id:result.capture_id}); } return {results,activated,focused,calls,control:control.id,selected:(await chrome.tabs.query({windowId:currentWindow.id,active:true}))[0].id}; @@ -171,7 +195,7 @@ describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)( zoom, extensionPath: directory, softwareRendering: true, - headless: true, + headless: !process.env.BSK_OVERLAY_SCROLLBAR, }, async (send: Send) => { let origin = ""; @@ -210,7 +234,7 @@ describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)( }; await expect.poll(() => evaluate("typeof run==='function'")).toBe(true); await evaluate( - `run(${JSON.stringify(url)}).then(value=>globalThis.done={value},error=>globalThis.done={error:String(error)});true`, + `run(${JSON.stringify(url)},${!!process.env.BSK_OVERLAY_SCROLLBAR}).then(value=>globalThis.done={value},error=>globalThis.done={error:String(error)});true`, ); await expect.poll(() => evaluate("!!globalThis.done"), { timeout: 100_000 }).toBe(true); const done = await evaluate<{ @@ -220,6 +244,8 @@ describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)( result: { height: number }; dpr: number; bad: unknown[]; + edgeBad: unknown[]; + overlayPixels: number; footer: number[]; original: unknown; restored: unknown; @@ -235,13 +261,19 @@ describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)( expect(done.error).toBeUndefined(); for (const item of done.value.results) { expect(item.bad).toEqual([]); + expect(item.edgeBad).toEqual([]); [255, 136, 0, 255].forEach((value, index) => - expect(Math.abs(item.footer[index] - value)).toBeLessThanOrEqual(3), + expect( + Math.abs(item.footer[index] - value), + JSON.stringify(item), + ).toBeLessThanOrEqual(3), ); expect(Math.abs(item.result.height - 2634 * item.dpr)).toBeLessThanOrEqual(1); expect(item.restored).toEqual(item.original); expect(item.active).toBe(false); } + if (process.env.BSK_OVERLAY_SCROLLBAR) + expect(done.value.results[1].overlayPixels).toBeGreaterThan(0); expect(done.value.selected).toBe(done.value.control); expect(done.value.activated).toEqual([]); expect(done.value.focused).toEqual([]); From a7540c364d86279f1c2d5711d8bbb2329ccf60b4 Mon Sep 17 00:00:00 2001 From: Ljy-0827 Date: Thu, 17 Sep 2026 16:21:38 +0800 Subject: [PATCH 4/4] fix: keep full-page screenshot changes focused --- .../src/long-screenshot/page.test.ts | 42 ++++++++++++++++++- crates/bsk-cli/tests/daemon_discovery.rs | 20 +++------ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/apps/extension/src/long-screenshot/page.test.ts b/apps/extension/src/long-screenshot/page.test.ts index e49251ae..75fb33dc 100644 --- a/apps/extension/src/long-screenshot/page.test.ts +++ b/apps/extension/src/long-screenshot/page.test.ts @@ -88,7 +88,6 @@ describe("page capture cleanup", () => { innerWidth: 815, innerHeight: 615, }); - }); it.each([ @@ -96,6 +95,8 @@ describe("page capture cleanup", () => { "cancel", "watchdog", "dispose", + "hidden", + "navigation", ])("restores root scrollbar styles after %s without changing nested scrollbars", async (end) => { vi.stubGlobal("innerWidth", 800); vi.stubGlobal("innerHeight", 600); @@ -115,6 +116,10 @@ describe("page capture cleanup", () => { else if (end === "cancel") window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); else if (end === "watchdog") await vi.advanceTimersByTimeAsync(15_001); + else if (end === "hidden") { + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + document.dispatchEvent(new Event("visibilitychange")); + } else if (end === "navigation") window.dispatchEvent(new Event("pagehide")); else capture.dispose(); expect(root.style.getPropertyValue("scrollbar-width")).toBe("thin"); expect(root.style.getPropertyPriority("scrollbar-width")).toBe("important"); @@ -136,6 +141,41 @@ describe("page capture cleanup", () => { expect(document.documentElement.style.getPropertyValue("scrollbar-width")).toBe(""); }); + it.each([ + undefined, + "color: red;", + ])("restores the root style attribute across repeated captures (%s)", async (original) => { + vi.stubGlobal("innerWidth", 800); + vi.stubGlobal("innerHeight", 600); + vi.stubGlobal("visualViewport", { width: 800.4, height: 600.6, scale: 1 }); + const root = document.documentElement; + if (original === undefined) root.removeAttribute("style"); + else root.setAttribute("style", original); + const beforeStyle = root.getAttribute("style"); + const beforeMetrics = await send({ action: "probe" }); + try { + for (const initialY of [100, 350]) { + window.scrollTo({ top: initialY, left: 12 }); + await send({ action: "begin", label: "Capture", cancelLabel: "Cancel" }); + expect(root.style.getPropertyValue("scrollbar-width")).toBe("none"); + expect(await send({ action: "probe" })).toMatchObject({ + viewportWidth: 800.4, + viewportHeight: 600.6, + innerWidth: 800.4, + innerHeight: 600.6, + }); + await send({ action: "finish" }); + expect(root.getAttribute("style")).toBe(beforeStyle); + expect(window.scrollX).toBe(12); + expect(window.scrollY).toBe(initialY); + expect(await send({ action: "probe" })).toEqual({ ...beforeMetrics, y: initialY }); + } + } finally { + capture.dispose(); + root.removeAttribute("style"); + } + }); + it("restores each changed CSS property and the original two-dimensional scroll", async () => { await send({ action: "begin", label: "Capture", cancelLabel: "Cancel" }); const moving = send({ action: "move", y: 800, capture: true }); diff --git a/crates/bsk-cli/tests/daemon_discovery.rs b/crates/bsk-cli/tests/daemon_discovery.rs index 9315e424..d8b6d7a7 100644 --- a/crates/bsk-cli/tests/daemon_discovery.rs +++ b/crates/bsk-cli/tests/daemon_discovery.rs @@ -420,28 +420,20 @@ fn invalid_and_mismatched_responses_do_not_trigger_startup() { #[test] fn discovery_rereads_metadata_when_an_instance_changes_during_probe() { - let daemon = MockDaemon::new(FOREIGN_PID, |n, info| { + let daemon = MockDaemon::new(FOREIGN_PID, |_, info| { let mut replacement = info.clone(); replacement.pid -= 1; - if n == 0 { - // Publish only after discovery has read the original metadata. - let home = info.sock_path.parent().unwrap(); - std::fs::rename(home.join("replacement.json"), home.join("daemon.json")).unwrap(); - } + write_to_path( + &replacement, + &info.sock_path.parent().unwrap().join("daemon.json"), + ) + .unwrap(); Some(status(&replacement)) }); - // Prepare and sync outside the CLI's probe deadline: this test exercises - // atomic metadata replacement, not filesystem durability latency. - let mut replacement: DaemonInfo = serde_json::from_slice(&daemon.metadata()).unwrap(); - replacement.pid -= 1; - write_to_path(&replacement, &daemon.home().join("replacement.json")).unwrap(); let out = run(daemon.home(), &["--json", "status"]); success(&out); let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); assert_eq!(value["pid"], FOREIGN_PID - 1); - let published: DaemonInfo = serde_json::from_slice(&daemon.metadata()).unwrap(); - assert_eq!(published, replacement); - assert!(!daemon.home().join("replacement.json").exists()); assert!(!daemon.home().join("daemon.lock").exists()); }