mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(screenshot): hide overlays during capture so shots only contain page content (#64)
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAPTURE_SUPPRESS, type CaptureSuppressAck } from "@/lib/capture-suppress-bridge";
|
||||
import { CAPTURE_HIDDEN_ATTR, createCaptureSuppressController } from "../capture-suppress";
|
||||
|
||||
/**
|
||||
* rAF is stubbed with a manual queue so tests can drive the compositor
|
||||
* frame-by-frame and assert the ack only lands after the second frame.
|
||||
*/
|
||||
describe("capture-suppress controller", () => {
|
||||
let rafQueue: FrameRequestCallback[];
|
||||
let host: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
rafQueue = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
rafQueue.push(cb);
|
||||
return rafQueue.length;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
document.body.innerHTML = "";
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
});
|
||||
|
||||
/** Run every queued rAF callback, draining microtasks between rounds. */
|
||||
async function flushFrames(rounds = 4): Promise<void> {
|
||||
for (let round = 0; round < rounds && rafQueue.length > 0; round += 1) {
|
||||
const callbacks = rafQueue.splice(0);
|
||||
for (const cb of callbacks) cb(0);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
it("begin hides the host immediately but only acks after two frames", async () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
const needsAsync = controller.handleMessage(
|
||||
{ type: CAPTURE_SUPPRESS, phase: "begin" },
|
||||
sendResponse,
|
||||
);
|
||||
|
||||
expect(needsAsync).toBe(true);
|
||||
expect(controller.pendingCount).toBe(1);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
|
||||
expect(sendResponse).not.toHaveBeenCalled();
|
||||
|
||||
// First frame commits the style change; the ack must still be pending.
|
||||
await flushFrames(1);
|
||||
expect(sendResponse).not.toHaveBeenCalled();
|
||||
|
||||
// Second frame: the compositor has produced an overlay-free frame.
|
||||
await flushFrames();
|
||||
const expected: CaptureSuppressAck = { type: CAPTURE_SUPPRESS, ok: true };
|
||||
expect(sendResponse).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it("end removes the attribute once the count returns to zero", async () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
|
||||
await flushFrames();
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
|
||||
|
||||
const sendResponse = vi.fn();
|
||||
const needsAsync = controller.handleMessage(
|
||||
{ type: CAPTURE_SUPPRESS, phase: "end" },
|
||||
sendResponse,
|
||||
);
|
||||
|
||||
expect(needsAsync).toBe(false);
|
||||
expect(controller.pendingCount).toBe(0);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
|
||||
expect(sendResponse).toHaveBeenCalledWith({ type: CAPTURE_SUPPRESS, ok: true });
|
||||
});
|
||||
|
||||
it("reference-counts concurrent begins so one end does not unhide early", async () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
|
||||
await flushFrames();
|
||||
expect(controller.pendingCount).toBe(2);
|
||||
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "end" }, vi.fn());
|
||||
expect(controller.pendingCount).toBe(1);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
|
||||
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "end" }, vi.fn());
|
||||
expect(controller.pendingCount).toBe(0);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a stray end as a no-op", () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "end" }, sendResponse);
|
||||
|
||||
expect(controller.pendingCount).toBe(0);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
|
||||
expect(sendResponse).toHaveBeenCalledWith({ type: CAPTURE_SUPPRESS, ok: true });
|
||||
});
|
||||
|
||||
it("re-applies the attribute when the host is remounted mid-capture", () => {
|
||||
let currentHost: HTMLElement | null = host;
|
||||
const controller = createCaptureSuppressController(() => currentHost);
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
|
||||
|
||||
// Host lost and rebuilt before `end` arrives.
|
||||
currentHost = null;
|
||||
const rebuilt = document.createElement("div");
|
||||
controller.onHostMounted(rebuilt);
|
||||
|
||||
expect(rebuilt.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a remounted host visible once suppression has ended", () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
const rebuilt = document.createElement("div");
|
||||
|
||||
controller.onHostMounted(rebuilt);
|
||||
|
||||
expect(rebuilt.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Content-script side of the capture-suppress bridge: hides the overlay
|
||||
* shadow host while the background captures a screenshot.
|
||||
*
|
||||
* `begin` sets `data-bsk-capture-hidden` on the host (overlay.css maps it
|
||||
* to `display: none !important`, which removes the whole shadow subtree
|
||||
* from compositing) and only acks after two animation frames — the first
|
||||
* frame commits the style change, the second guarantees the compositor
|
||||
* has produced a frame without the overlay, so the capture API cannot
|
||||
* pick up a stale frame that still contains it. `end` is
|
||||
* reference-counted so overlapping captures do not unhide the host early.
|
||||
*/
|
||||
|
||||
import {
|
||||
CAPTURE_SUPPRESS,
|
||||
type CaptureSuppressAck,
|
||||
type CaptureSuppressMessage,
|
||||
} from "@/lib/capture-suppress-bridge";
|
||||
|
||||
export const CAPTURE_HIDDEN_ATTR = "data-bsk-capture-hidden";
|
||||
|
||||
/**
|
||||
* Safety net for hidden/background tabs where rAF callbacks never fire:
|
||||
* the attribute is already set synchronously, so any frame the compositor
|
||||
* produces from now on excludes the overlay. Waiting longer would just
|
||||
* deadlock the capture.
|
||||
*/
|
||||
const FRAME_FALLBACK_MS = 500;
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const raf = requestAnimationFrame(() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
cancelAnimationFrame(raf);
|
||||
resolve();
|
||||
}, FRAME_FALLBACK_MS);
|
||||
});
|
||||
}
|
||||
|
||||
export interface CaptureSuppressController {
|
||||
/**
|
||||
* Handle one bridge message. Returns true when the ack is sent
|
||||
* asynchronously and the caller must keep the message channel open.
|
||||
*/
|
||||
handleMessage(
|
||||
message: CaptureSuppressMessage,
|
||||
sendResponse: (ack: CaptureSuppressAck) => void,
|
||||
): boolean;
|
||||
/** Re-apply the hidden attribute when the overlay host is (re)mounted. */
|
||||
onHostMounted(host: HTMLElement): void;
|
||||
/** Number of in-flight `begin` phases (exposed for tests). */
|
||||
readonly pendingCount: number;
|
||||
}
|
||||
|
||||
export function createCaptureSuppressController(
|
||||
getHost: () => HTMLElement | null,
|
||||
): CaptureSuppressController {
|
||||
let pending = 0;
|
||||
|
||||
const ack: CaptureSuppressAck = { type: CAPTURE_SUPPRESS, ok: true };
|
||||
|
||||
function setHidden(host: HTMLElement | null, hidden: boolean): void {
|
||||
if (!host) return;
|
||||
if (hidden) host.setAttribute(CAPTURE_HIDDEN_ATTR, "");
|
||||
else host.removeAttribute(CAPTURE_HIDDEN_ATTR);
|
||||
}
|
||||
|
||||
return {
|
||||
get pendingCount() {
|
||||
return pending;
|
||||
},
|
||||
handleMessage(message, sendResponse) {
|
||||
if (message.phase === "begin") {
|
||||
pending += 1;
|
||||
setHidden(getHost(), true);
|
||||
void (async () => {
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
sendResponse(ack);
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
// `end` without a matching `begin` (e.g. a retried message) must not
|
||||
// drive the counter negative or clear a concurrent capture's flag.
|
||||
pending = Math.max(0, pending - 1);
|
||||
if (pending === 0) setHidden(getHost(), false);
|
||||
sendResponse(ack);
|
||||
return false;
|
||||
},
|
||||
onHostMounted(host) {
|
||||
// Host lost + rebuilt mid-capture: the fresh host must stay hidden
|
||||
// until the last `end` arrives.
|
||||
setHidden(host, pending > 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,3 +14,10 @@
|
||||
:host([data-bsk-overlay-surface][data-bsk-overlay-blocking]) {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Screenshot suppression: the host lives in the main DOM, so hiding it
|
||||
removes the whole shadow subtree from compositing and captured frames
|
||||
only contain page content. */
|
||||
:host([data-bsk-capture-hidden]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user