Compare commits

..
Author SHA1 Message Date
Ljy-0827 f6d129b12e perf(geometry): batch iframe owner size measurements 2026-09-09 04:09:15 +08:00
Ljy-0827 fc39bc4f8f fix(vom): surface incomplete iframe geometry in observations 2026-09-09 04:09:15 +08:00
Ljy-0827 05bee25b2f perf(observation): measure sibling iframe geometry with bounded concurrency 2026-09-09 04:09:15 +08:00
Ljy-0827 2778ab1eee fix(geometry): unify frame projection and screenshot coordinates 2026-09-09 04:09:15 +08:00
Zhang GH 020fb3212e Merge pull request #201 from Tencent/fix/windows-install-and-cancellation
fix(windows): harden installation and parent cancellation
2026-09-08 20:15:12 +08:00
Zhang GH 0822c53c65 fix(windows): preserve transfer cleanup after cancellation 2026-09-08 20:04:24 +08:00
Zhang GH 4450fbe28c Merge pull request #196 from Tencent/codex/dsh-plugin-docs
docs(dsh-plugin): clarify installation, configuration, and releases
2026-09-08 19:43:24 +08:00
drakezhang a3bb81bfab docs(dsh-plugin): clarify patch editing and reload behavior 2026-09-08 19:34:25 +08:00
Zhang GH caea98cd52 fix(windows): harden installation and parent cancellation 2026-09-08 19:21:54 +08:00
drakezhang 187b088013 docs(dsh-plugin): restore configuration guidance and simplify releases 2026-09-08 19:03:40 +08:00
drakezhang 5046b5ab44 docs(dsh-plugin): clarify setup and verify npm publishing 2026-09-08 18:44:20 +08:00
Zhang GH e8137b42cc Merge pull request #194 from Tencent/fix/dsh-runtime-peer-dependency
fix(dsh-plugin): remove obsolete client runtime host dependency
2026-09-08 17:34:33 +08:00
Zhang GH cb5dc148d5 fix(dsh-plugin): remove obsolete client runtime host dependency
Keep dsh-client-runtime as a development-only type dependency. Remove its peer metadata, client injection entry, and runtime external allowance so newer hosts do not preflight a package they no longer ship.

Fixes #193
2026-09-08 16:58:20 +08:00
62 changed files with 2632 additions and 3757 deletions
+11 -3
View File
@@ -38,7 +38,7 @@ jobs:
run: cargo test --workspace --locked
windows-daemon:
name: Windows daemon and update regression tests
name: Windows daemon, installer and cancellation regression tests
runs-on: windows-latest
steps:
@@ -55,8 +55,16 @@ jobs:
- name: Run Windows update tests
run: cargo test -p bsk --lib --locked cli::update::tests
- name: Run Windows update process tests
run: cargo test -p bsk --test windows_update --locked
- name: Run Windows update and cancellation process tests
run: cargo test -p bsk --test windows_update --test windows_parent_cancel --locked
- name: Run installer tests (Windows PowerShell 5.1)
shell: powershell
run: ./scripts/install-windows.test.ps1
- name: Run installer tests (PowerShell 7)
shell: pwsh
run: ./scripts/install-windows.test.ps1
frontend:
name: Frontend lint, typecheck, tests, build
+13 -7
View File
@@ -147,20 +147,26 @@ Start a new Agent session and write a prompt that needs the browser, for example
Using [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`)?
BrowserSkill ships a first-class dsh plugin on npm as
[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin).
It injects native `browser_*` tools (no shelling out to `bsk`) and a live Web UI
overlay of each Agent Window.
It gives the agent native `browser_*` tools and a live view of its browser sessions
in the Web UI. The plugin runs `bsk` on the agent's behalf.
Add it to a dsh profile, then start that profile:
Install the `bsk` CLI and connect the browser extension first. Then add the plugin
to a dsh profile and start it (replace `web` with your profile name):
```sh
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
dsh --profile web
```
The plugin carries its own copy of the skill, so `bsk install-skill` is not needed
for dsh — but the `bsk` CLI and the browser extension are still prerequisites. See
the [plugin README](packages/dsh-plugin-browserskill/README.md) for the tool list,
configuration, and the observation overlay.
The plugin includes the `browser-skill` skill, so `bsk install-skill` is not needed
for dsh. Installed plugins do not update automatically. To upgrade this plugin:
```sh
dsh plugin --profile web update @wxg-prc-cpg/browser-skill-dsh-plugin --latest
```
Restart the profile after upgrading. See the
[plugin README](packages/dsh-plugin-browserskill/README.md) for usage and configuration.
## How It Works
+9 -3
View File
@@ -125,16 +125,22 @@ bsk install-skill
## DeepSeek Harness 插件
在用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)?BrowserSkill 提供了官方 dsh 插件,已发布到 npm:[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)。它会注入原生 `browser_*` 工具(无需再通过 Shell 调用 `bsk`),并在 Web UI 中实时观察每个 Agent Window。
在用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)?BrowserSkill 提供了官方 dsh 插件,已发布到 npm:[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)。它为 Agent 提供原生 `browser_*` 工具,由插件代为调用 `bsk`,并在 Web UI 中实时展示浏览器会话。
把它装进某个 dsh profile,然后启动该 profile:
先安装 `bsk` CLI 并连接浏览器扩展,再将插件装进 dsh profile 并启动(将 `web` 替换为你的 profile 名称):
```sh
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
dsh --profile web
```
插件自带 skill,所以在 dsh 下无需执行 `bsk install-skill`;但 `bsk` CLI 和浏览器扩展仍是前置条件。工具清单、配置项与观察浮层见[插件 README](packages/dsh-plugin-browserskill/README.md)。
插件自带 `browser-skill` skill,所以在 dsh 下无需执行 `bsk install-skill`。已安装的插件不会自动更新;升级此插件请运行:
```sh
dsh plugin --profile web update @wxg-prc-cpg/browser-skill-dsh-plugin --latest
```
升级后重启该 profile。用法与配置见[插件 README](packages/dsh-plugin-browserskill/README.md)。
## 工作原理
@@ -13,8 +13,6 @@ describe("RefStore", () => {
backendNodeId: 42,
tabId: 7,
generation: 0,
kind: "dom",
capabilities: ["interact", "screenshot"],
});
expect(s.size()).toBe(1);
expect(s.isEmpty()).toBe(false);
@@ -50,41 +48,4 @@ describe("RefStore", () => {
cdpSessionId: "child-session",
});
});
it("preserves explicit capabilities for visual surface refs", () => {
const s = new RefStore();
s.set("e1", 42, {
tabId: 7,
kind: "surface",
capabilities: ["screenshot"],
});
expect(s.resolveEntry("e1")).toMatchObject({
kind: "surface",
capabilities: ["screenshot"],
});
});
it("never grants ordinary interaction to a surface through defaults or caller input", () => {
const s = new RefStore();
s.set("e1", 42, { tabId: 7, kind: "surface" });
s.set("e2", 43, {
tabId: 7,
kind: "surface",
capabilities: ["interact", "screenshot"],
});
expect(s.resolveEntry("e1")?.capabilities).toEqual(["screenshot"]);
expect(s.resolveEntry("e2")?.capabilities).toEqual(["screenshot"]);
});
it("copies caller-owned capability arrays", () => {
const s = new RefStore();
const capabilities: Array<"interact" | "screenshot"> = ["screenshot"];
s.set("e1", 42, { tabId: 7, capabilities });
capabilities.push("interact");
expect(s.resolveEntry("e1")?.capabilities).toEqual(["screenshot"]);
});
});
@@ -1,5 +1,3 @@
import type { Rect } from "@browser-skill/vom";
/**
* Per-session map from `@e<N>` snapshot refs to a CDP node address.
*
@@ -14,17 +12,12 @@ import type { Rect } from "@browser-skill/vom";
* inside the `SessionContext`.
*/
export type BackendNodeId = number;
export type RefCapability = "interact" | "screenshot";
export type RefTargetKind = "dom" | "surface";
export interface RefEntry {
backendNodeId: BackendNodeId;
tabId: number | null;
frameId?: string;
cdpSessionId?: string;
visibleRect?: Rect;
kind: RefTargetKind;
capabilities: RefCapability[];
generation: number;
}
@@ -35,19 +28,8 @@ export type RefInput =
tabId: number;
frameId?: string;
cdpSessionId?: string;
visibleRect?: Rect;
kind?: RefTargetKind;
capabilities?: RefCapability[];
};
function capabilitiesFor(
kind: RefTargetKind,
capabilities: RefCapability[] | undefined,
): RefCapability[] {
if (kind === "surface") return ["screenshot"];
return capabilities ? [...capabilities] : ["interact", "screenshot"];
}
export class RefStore {
private readonly map = new Map<string, RefEntry>();
private generation = 0;
@@ -88,20 +70,13 @@ export class RefStore {
tabId?: number;
frameId?: string;
cdpSessionId?: string;
visibleRect?: Rect;
kind?: RefTargetKind;
capabilities?: RefCapability[];
} = {},
): void {
const kind = opts.kind ?? "dom";
this.map.set(normaliseRef(ref), {
backendNodeId: id,
tabId: opts.tabId ?? null,
...(opts.frameId ? { frameId: opts.frameId } : {}),
...(opts.cdpSessionId ? { cdpSessionId: opts.cdpSessionId } : {}),
...(opts.visibleRect ? { visibleRect: opts.visibleRect } : {}),
kind,
capabilities: capabilitiesFor(kind, opts.capabilities),
generation: this.generation,
});
}
@@ -119,20 +94,14 @@ export class RefStore {
return {
backendNodeId: input,
tabId: null,
kind: "dom",
capabilities: ["interact", "screenshot"],
generation: this.generation,
};
}
const kind = input.kind ?? "dom";
return {
backendNodeId: input.backendNodeId,
tabId: input.tabId,
...(input.frameId ? { frameId: input.frameId } : {}),
...(input.cdpSessionId ? { cdpSessionId: input.cdpSessionId } : {}),
...(input.visibleRect ? { visibleRect: input.visibleRect } : {}),
kind,
capabilities: capabilitiesFor(kind, input.capabilities),
generation: this.generation,
};
}
@@ -1,9 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
createFrameGeometryContext,
resolveFrameProjection,
resolveNodeGeometry,
} from "../frame-geometry";
import { resolveNodeGeometry } from "../frame-geometry";
import {
clipPolygon,
polygonArea,
@@ -14,6 +10,7 @@ import {
rectPolygon,
regionBounds,
} from "../geometry";
import { GeometryContext } from "../geometry/frame-context";
import type { CdpRunner } from "../shared";
describe("frame geometry projection", () => {
@@ -41,53 +38,6 @@ describe("frame geometry projection", () => {
expect(polygonBounds(projected.flat())).toEqual({ x: 224, y: 346, width: 200, height: 80 });
});
it("projects nested same-target frame-local bounds through authoritative content quads", async () => {
const boxCalls: number[] = [];
const cdp: CdpRunner = {
send: vi.fn(async (_tabId, method, params) => {
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1_000, clientHeight: 800 } };
}
if (method === "Page.createIsolatedWorld") return { executionContextId: 91 };
if (method === "Runtime.evaluate") {
return { result: { value: { width: 100, height: 80 } } };
}
if (method === "DOM.getBoxModel") {
const backendNodeId = (params as { backendNodeId?: number }).backendNodeId ?? 0;
boxCalls.push(backendNodeId);
return backendNodeId === 10
? { model: { content: [30, 100.5, 330, 100.5, 330, 300.5, 30, 300.5] } }
: { model: { content: [45, 120.5, 145, 120.5, 145, 200.5, 45, 200.5] } };
}
throw new Error(`unexpected ${method}`);
}) as CdpRunner["send"],
};
const geometry = createFrameGeometryContext(cdp, {
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "child",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 4 },
},
{
frameId: "nested",
parentFrameId: "child",
ownerBackendNodeId: 20,
target: { tabId: 4 },
},
],
});
await expect(
geometry.projectFrameLocalRect("nested", { x: 12, y: 12, w: 40, h: 20 }),
).resolves.toEqual({ x: 57, y: 132.5, width: 40, height: 20 });
await geometry.projectFrameLocalRect("nested", { x: 1, y: 1, w: 2, h: 2 });
expect(boxCalls.sort()).toEqual([10, 20]);
});
it("uses a projective mapping for perspective-transformed iframe quads", () => {
const quad: Quad = [
{ x: 0, y: 0 },
@@ -133,28 +83,24 @@ describe("frame geometry projection", () => {
throw new Error(`unexpected child command ${method}`);
}) as CdpRunner["sendToTarget"],
};
const projection = await resolveFrameProjection(
cdp,
{
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "same-process-parent",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 4 },
},
{
frameId: "oopif",
parentFrameId: "same-process-parent",
ownerBackendNodeId: 20,
target: { tabId: 4, sessionId: "oopif-session" },
},
],
},
"oopif",
);
const projection = await new GeometryContext(cdp, 4, {
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "same-process-parent",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 4 },
},
{
frameId: "oopif",
parentFrameId: "same-process-parent",
ownerBackendNodeId: 20,
target: { tabId: 4, sessionId: "oopif-session" },
},
],
}).targetProjection("oopif");
expect(projection?.edges).toEqual([
{
@@ -0,0 +1,380 @@
import { describe, expect, it, vi } from "vitest";
import type { CdpFrameGraph } from "@/browser-driver/frame-graph";
import { resolveNodeGeometry } from "../frame-geometry";
import { rectPolygon } from "../geometry";
import {
projectSnapshotRect,
screenshotPageRect,
snapshotViewportRect,
} from "../geometry/coordinate-types";
import { GeometryContext } from "../geometry/frame-context";
import type { CdpRunner } from "../shared";
const target = { tabId: 4 };
const graph: CdpFrameGraph = {
rootFrameId: "main",
frames: [
{ frameId: "main", target },
{ frameId: "child", parentFrameId: "main", ownerBackendNodeId: 10, target },
],
};
function driver() {
const send = vi.fn(async (_tab: number, method: string): Promise<object> => {
if (method === "Page.getLayoutMetrics")
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 1000 } };
if (method === "DOM.getBoxModel")
return { model: { content: [52.5, 612.5, 427.5, 612.5, 427.5, 862.5, 52.5, 862.5] } };
if (method === "DOM.resolveNode") return { object: { objectId: "owner" } };
if (method === "Runtime.callFunctionOn")
return { result: { value: { width: 300, height: 200 } } };
if (method === "Runtime.releaseObject") return {};
throw new Error(method);
});
return { send: send as CdpRunner["send"], getFrameGraph: vi.fn(async () => graph), calls: send };
}
describe("measurement geometry context", () => {
it("shares in-flight frame measurements, but never shares them with the next context", async () => {
const cdp = driver();
const context = new GeometryContext(cdp, 4);
const results = await Promise.all(
Array.from({ length: 20 }, () => context.targetProjection("child")),
);
expect(results.every((value) => value === results[0])).toBe(true);
expect(cdp.getFrameGraph).toHaveBeenCalledTimes(1);
expect(cdp.calls.mock.calls.map(([, method]) => method)).toEqual([
"Page.getLayoutMetrics",
"DOM.getBoxModel",
]);
await new GeometryContext(cdp, 4).targetProjection("child");
expect(cdp.getFrameGraph).toHaveBeenCalledTimes(2);
expect(cdp.calls.mock.calls).toHaveLength(4);
});
it("projects frame-local snapshot CSS bounds through the scaled owner content quad", async () => {
const cdp = driver();
const context = new GeometryContext(cdp, 4);
const source = { target, frameId: "child" };
const projection = await context.snapshotProjection(source, 10, [], {
width: 1000,
height: 1000,
});
if (projection.status !== "available") throw new Error("expected available projection");
const input = snapshotViewportRect([17, 23, 120, 40], source, { x: 0, y: 0 });
expect(projectSnapshotRect(input!, projection.projection)).toEqual({
x: 73.75,
y: 641.25,
width: 150,
height: 50,
});
const scrolled = snapshotViewportRect([17, 23, 120, 40], source, { x: 0, y: 10 });
expect(projectSnapshotRect(scrolled!, projection.projection)?.y).toBe(628.75);
await context.snapshotProjection(source, 10, [], { width: 1000, height: 1000 });
expect(cdp.calls.mock.calls.filter(([, method]) => method === "DOM.resolveNode")).toHaveLength(
1,
);
expect(
cdp.calls.mock.calls.filter(([, method]) => method === "Runtime.releaseObject"),
).toHaveLength(1);
});
it("falls back only for missing or invalid batch entries and does not reuse sizes across contexts", async () => {
const cdp = driver();
const original = cdp.send;
const calls: string[] = [];
cdp.send = async (tabId, method, params) => {
calls.push(method);
if (method === "Runtime.evaluate")
return {
result: {
deepSerializedValue: {
type: "array",
value: [
{
type: "array",
value: [
{ type: "node", value: { backendNodeId: 10 } },
{ type: "string", value: '{"width":300,"height":200}' },
],
},
{
type: "array",
value: [
{ type: "node", value: { backendNodeId: 11 } },
{ type: "string", value: '{"width":0,"height":200}' },
],
},
],
},
},
} as never;
if (method === "Runtime.releaseObjectGroup") return {} as never;
return original(tabId, method, params);
};
const context = new GeometryContext(cdp, 4);
context.registerSnapshotOwners(target, new Set([10, 11]));
const values = await Promise.all(
[10, 11].map((id) =>
context.snapshotProjection({ target, frameId: String(id) }, id, [], {
width: 1000,
height: 1000,
}),
),
);
expect(values.every((value) => value.status === "available")).toBe(true);
expect(calls.filter((method) => method === "DOM.resolveNode")).toHaveLength(1);
expect(calls.filter((method) => method === "Runtime.evaluate")).toHaveLength(1);
await new GeometryContext(cdp, 4).snapshotProjection({ target, frameId: "child" }, 10, [], {
width: 1000,
height: 1000,
});
expect(calls.filter((method) => method === "DOM.resolveNode")).toHaveLength(2);
});
it("waits for batch object cleanup and propagates cancellation", async () => {
const cdp = driver();
const original = cdp.send;
const controller = new AbortController();
let release!: () => void;
let cleanup!: () => void;
const started = new Promise<void>((resolve) => {
cleanup = resolve;
});
cdp.send = async (tabId, method, params) => {
if (method === "Runtime.evaluate") return {} as never;
if (method === "Runtime.releaseObjectGroup") {
cleanup();
await new Promise<void>((resolve) => {
release = resolve;
});
return {} as never;
}
return original(tabId, method, params);
};
const context = new GeometryContext(cdp, 4, undefined, controller.signal);
context.registerSnapshotOwners(target, new Set([10, 11]));
let settled = false;
const operation = context
.snapshotProjection({ target, frameId: "child" }, 10, [], { width: 1000, height: 1000 })
.finally(() => {
settled = true;
});
const rejected = expect(operation).rejects.toMatchObject({ name: "AbortError" });
await started;
controller.abort();
expect(settled).toBe(false);
release();
await rejected;
expect(cdp.calls.mock.calls.some(([, method]) => method === "DOM.resolveNode")).toBe(false);
});
it("rejects owner mismatches and invalid numeric bounds", () => {
const input = snapshotViewportRect(
[30, 50, 120, 40],
{ target, frameId: "main" },
{ x: 10, y: 20 },
)!;
const projection = {
source: { target, frameId: "main" },
geometry: { sourceClips: [], edges: [], topViewport: { width: 800, height: 600 } },
};
expect(projectSnapshotRect(input, projection)).toEqual({
x: 20,
y: 30,
width: 120,
height: 40,
});
expect(
projectSnapshotRect(input, { ...projection, source: { target, frameId: "other" } }),
).toBeNull();
expect(
projectSnapshotRect(input, {
...projection,
source: { target: { tabId: 5 }, frameId: "main" },
}),
).toBeNull();
expect(snapshotViewportRect([0, 0, NaN, 40], { target }, { x: 0, y: 0 })).toBeNull();
expect(snapshotViewportRect([0, 0, 10, 40], { target }, { x: Infinity, y: 0 })).toBeNull();
});
it("does not turn a border quad or a failed owner read into a content projection", async () => {
const cdp: CdpRunner = {
send: vi.fn(async () => ({
model: { border: [0, 0, 100, 0, 100, 100, 0, 100] },
})) as CdpRunner["send"],
};
expect(
await new GeometryContext(cdp, 4).snapshotProjection({ target, frameId: "child" }, 10, [], {
width: 100,
height: 100,
}),
).toEqual({
status: "unavailable",
source: { target, frameId: "child" },
ownerBackendNodeId: 10,
});
});
it("keeps ancestor clips in target coordinates instead of applying nested offsets twice", async () => {
const cdp = driver();
const projection = await new GeometryContext(cdp, 4).snapshotProjection(
{ target, frameId: "nested" },
10,
[rectPolygon({ x: 60, y: 620, w: 100, h: 100 })],
{ width: 1000, height: 1000 },
);
if (projection.status !== "available") throw new Error("expected available projection");
const input = snapshotViewportRect(
[0, 0, 300, 200],
{ target, frameId: "nested" },
{ x: 0, y: 0 },
);
expect(projectSnapshotRect(input!, projection.projection)).toEqual({
x: 60,
y: 620,
width: 100,
height: 100,
});
});
it("rejects malformed frame ancestry without looping", async () => {
const cdp = driver();
const cycle: CdpFrameGraph = {
rootFrameId: "a",
frames: [
{ frameId: "a", parentFrameId: "b", target },
{ frameId: "b", parentFrameId: "a", target },
],
};
expect(await new GeometryContext(cdp, 4, cycle).targetProjection("a")).toBeNull();
expect(cdp.calls).not.toHaveBeenCalled();
const orphan: CdpFrameGraph = {
rootFrameId: "a",
frames: [{ frameId: "a", parentFrameId: "missing", target }],
};
expect(await new GeometryContext(cdp, 4, orphan).targetProjection("a")).toBeNull();
});
it("rejects a live node whose frame belongs to another target before any DOM input", async () => {
const cdp = driver();
expect(
await resolveNodeGeometry(
cdp,
4,
{ target: { tabId: 4, sessionId: "wrong" }, frameId: "child", backendNodeId: 20 },
{ scrollIntoView: true },
),
).toMatchObject({ code: "cdp_failed" });
expect(cdp.calls).not.toHaveBeenCalled();
});
it("bounds concurrent measurements and does not dispatch queued reads after cancellation", async () => {
let active = 0;
let peak = 0;
const release: Array<() => void> = [];
const send = vi.fn(async () => {
active++;
peak = Math.max(peak, active);
await new Promise<void>((resolve) => release.push(resolve));
active--;
return { cssLayoutViewport: { clientWidth: 100, clientHeight: 100 } };
});
const controller = new AbortController();
const context = new GeometryContext(
{ send: send as CdpRunner["send"] },
4,
undefined,
controller.signal,
);
const tasks = Array.from({ length: 8 }, (_, index) =>
context.viewport({ tabId: 4, sessionId: String(index) }),
);
const settled = Promise.allSettled(tasks);
expect(send).toHaveBeenCalledTimes(4);
controller.abort();
for (const done of release) done();
const results = await settled;
expect(peak).toBe(4);
expect(send).toHaveBeenCalledTimes(4);
expect(results.filter((item) => item.status === "rejected")).toHaveLength(4);
});
it("releases an owner object even when cancellation arrives before its viewport read", async () => {
const controller = new AbortController();
const cdp = driver();
const original = cdp.calls.getMockImplementation()!;
cdp.calls.mockImplementation(async (tab, method) => {
const result = await original(tab, method);
if (method === "DOM.resolveNode") controller.abort();
return result;
});
const context = new GeometryContext(cdp, 4, undefined, controller.signal);
await expect(
context.snapshotProjection({ target, frameId: "child" }, 10, [], {
width: 1000,
height: 1000,
}),
).rejects.toMatchObject({ name: "AbortError" });
expect(cdp.calls.mock.calls.map(([, method]) => method)).toEqual([
"DOM.getBoxModel",
"DOM.resolveNode",
"Runtime.releaseObject",
]);
});
it("reads live owner geometry only after scrolling", async () => {
const cdp = driver();
const original = cdp.calls.getMockImplementation()!;
let scrolled = false;
cdp.calls.mockImplementation(async (tab, method) => {
if (method === "DOM.scrollIntoViewIfNeeded") {
scrolled = true;
return {};
}
if (method === "DOM.getContentQuads") {
expect(scrolled).toBe(true);
return { quads: [[60, 620, 100, 620, 100, 640, 60, 640]] };
}
if (method === "DOM.getBoxModel") expect(scrolled).toBe(true);
return original(tab, method);
});
expect(
await resolveNodeGeometry(
cdp,
4,
{ target, frameId: "child", backendNodeId: 20 },
{ scrollIntoView: true },
),
).toMatchObject({ topBounds: { x: 60, y: 620, width: 40, height: 20 } });
expect(cdp.getFrameGraph).toHaveBeenCalledTimes(1);
expect(cdp.calls.mock.calls.filter(([, method]) => method === "DOM.getBoxModel")).toHaveLength(
1,
);
});
it("adapts viewport CSS bounds to page DIP with scroll and browser zoom, never raster DPR", () => {
const viewport = { width: 1600, height: 1000, scrollX: 10, scrollY: 100, cssToDip: 0.9 };
const clip = screenshotPageRect({ x: 20, y: 30, width: 120, height: 40 }, viewport);
expect(clip).toEqual({ space: "page-dip", rect: { x: 27, y: 117, width: 108, height: 36 } });
expect(
screenshotPageRect({ x: 20, y: 30, width: 120, height: 40 }, { ...viewport, cssToDip: NaN }),
).toBeNull();
expect(
screenshotPageRect({ x: 20, y: 30, width: 120, height: 40 }, { ...viewport, cssToDip: 0 }),
).toBeNull();
});
it("retries a rejected measurement only in a new operation", async () => {
const cdp = driver();
cdp.calls.mockRejectedValueOnce(new Error("unavailable"));
const context = new GeometryContext(cdp, 4);
await expect(context.viewport(target)).rejects.toThrow("unavailable");
await expect(context.viewport(target)).rejects.toThrow("unavailable");
expect(cdp.calls).toHaveBeenCalledTimes(1);
expect(await new GeometryContext(cdp, 4).viewport(target)).toEqual({
width: 1000,
height: 1000,
});
});
});
@@ -123,29 +123,6 @@ describe("handleClick", () => {
expect(fake.cdp.send).not.toHaveBeenCalled();
});
it("rejects screenshot-only visual surface refs before issuing CDP calls", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, {
tabId: 4,
kind: "surface",
capabilities: ["screenshot"],
});
const fake = makeFakeCdp({});
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "@e3" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({
code: "permission_denied",
data: { reason: "ref_capability_denied", required_capability: "interact" },
});
expect(fake.cdp.send).not.toHaveBeenCalled();
});
it("clicks by ref, computes the quad centre, dispatches three mouse events", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
@@ -36,14 +36,6 @@ function fakeAgentWindow(ids: number[]) {
const TINY_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==";
function pngWithIhdrDimensions(width: number, height: number): string {
const bytes = Uint8Array.from(atob(TINY_PNG), (character) => character.charCodeAt(0));
const view = new DataView(bytes.buffer);
view.setUint32(16, width);
view.setUint32(20, height);
return btoa(String.fromCharCode(...bytes));
}
function makeScreenshotDeps(
opts: {
cdp?: CdpRunner;
@@ -94,7 +86,6 @@ describe("stripDataUrlPrefix", () => {
describe("parsePngDimensions", () => {
it("parses width/height from the IHDR chunk", () => {
expect(parsePngDimensions(TINY_PNG)).toEqual({ width: 1, height: 1 });
expect(parsePngDimensions(pngWithIhdrDimensions(100, 80))).toEqual({ width: 100, height: 80 });
});
it("returns null on non-PNG input", () => {
expect(parsePngDimensions("not-a-png-payload-just-random-base64-text-zzzzzzzzz")).toBeNull();
@@ -129,20 +120,6 @@ describe("handleScreenshot", () => {
expect(get).not.toHaveBeenCalled();
});
it("fails closed when a full-tab capture is not a valid PNG", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const res = await handleScreenshot(
sm,
{ session_id: "aa11" },
makeScreenshotDeps({
captureVisibleTab: vi.fn(async () => "data:image/png;base64,bm90LWEtcG5n"),
}),
);
expect(res).toMatchObject({ code: "cdp_failed", message: /invalid PNG data/ });
});
it("returns not_found when Agent Window has no active tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
@@ -289,7 +266,15 @@ describe("handleScreenshot", () => {
expect(sent.find((c) => c.method === "Page.captureScreenshot")).toBeUndefined();
});
it("captures a clipped PNG when ref is given", async () => {
it.each([
{ zoom: 1, scroll: 0, expected: { x: 10, y: 20, width: 100, height: 40 } },
{ zoom: 0.9, scroll: 100, expected: { x: 9, y: 108, width: 90, height: 36 } },
{ zoom: 1.25, scroll: 100, expected: { x: 12.5, y: 150, width: 125, height: 50 } },
])("captures a ref in page DIP at zoom $zoom and scroll $scroll", async ({
zoom,
scroll,
expected,
}) => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e5", 999, { tabId: 7 });
@@ -297,6 +282,10 @@ describe("handleScreenshot", () => {
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
"Page.captureScreenshot": () => ({ data: TINY_PNG }),
"Page.getLayoutMetrics": () => ({
cssLayoutViewport: { clientWidth: 1280, clientHeight: 720, pageX: 0, pageY: scroll },
cssVisualViewport: { zoom },
}),
});
const captureVisibleTab = vi.fn();
const res = await handleScreenshot(
@@ -320,251 +309,8 @@ describe("handleScreenshot", () => {
clip?: { x: number; y: number; width: number; height: number };
}
)?.clip;
expect(clip).toMatchObject({ x: 10, y: 20, width: 100, height: 40 });
});
it("captures a bounded visible crop for a visual surface without scrolling it", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e5", 999, {
tabId: 7,
kind: "surface",
capabilities: ["screenshot"],
visibleRect: { x: 0, y: 0, w: 4096, h: 4096 },
});
const { cdp, sent } = makeFakeCdp({
"Page.getLayoutMetrics": () => ({
cssLayoutViewport: { clientWidth: 4096, clientHeight: 4096 },
}),
"DOM.getContentQuads": () => ({ quads: [[0, 0, 4096, 0, 4096, 4096, 0, 4096]] }),
"Page.captureScreenshot": () => ({ data: TINY_PNG }),
});
const res = await handleScreenshot(
sm,
{ session_id: "aa11", ref: "@e5", tab_id: 7 },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: false }) as chrome.tabs.Tab),
query: vi.fn(),
captureVisibleTab: vi.fn(),
}),
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(sent.some((call) => call.method === "DOM.scrollIntoViewIfNeeded")).toBe(false);
const clip = sent.find((call) => call.method === "Page.captureScreenshot")?.params as {
clip?: { width: number; height: number; scale: number };
};
expect(clip.clip).toMatchObject({ width: 4096, height: 4096 });
expect(clip.clip?.scale).toBeCloseTo(Math.sqrt(4_000_000 / (4096 * 4096)));
});
it("retries once when actual surface PNG dimensions exceed the pixel budget", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e5", 999, {
tabId: 7,
kind: "surface",
visibleRect: { x: 0, y: 0, w: 1_000, h: 1_000 },
});
let captures = 0;
const { cdp, sent } = makeFakeCdp({
"Runtime.evaluate": () => ({ result: { value: 1 } }),
"Page.getLayoutMetrics": () => ({
cssLayoutViewport: { clientWidth: 1_000, clientHeight: 1_000 },
}),
"DOM.getContentQuads": () => ({
quads: [[0, 0, 1_000, 0, 1_000, 1_000, 0, 1_000]],
}),
"Page.captureScreenshot": () => ({
data:
++captures === 1
? pngWithIhdrDimensions(4_000, 4_000)
: pngWithIhdrDimensions(1_998, 1_998),
}),
});
const res = await handleScreenshot(
sm,
{ session_id: "aa11", ref: "@e5", tab_id: 7 },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: false }) as chrome.tabs.Tab),
query: vi.fn(),
captureVisibleTab: vi.fn(),
}),
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res).toMatchObject({ width: 1_998, height: 1_998 });
const calls = sent.filter((call) => call.method === "Page.captureScreenshot");
expect(calls).toHaveLength(2);
expect((calls[1]?.params as { clip?: { scale?: number } }).clip?.scale).toBeLessThan(0.5);
});
it("fails closed when the retried surface PNG still exceeds the pixel budget", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e5", 999, {
tabId: 7,
kind: "surface",
visibleRect: { x: 0, y: 0, w: 1_000, h: 1_000 },
});
const { cdp, sent } = makeFakeCdp({
"Runtime.evaluate": () => ({ result: { value: 1 } }),
"Page.getLayoutMetrics": () => ({
cssLayoutViewport: { clientWidth: 1_000, clientHeight: 1_000 },
}),
"DOM.getContentQuads": () => ({
quads: [[0, 0, 1_000, 0, 1_000, 1_000, 0, 1_000]],
}),
"Page.captureScreenshot": () => ({ data: pngWithIhdrDimensions(4_000, 4_000) }),
});
const res = await handleScreenshot(
sm,
{ session_id: "aa11", ref: "@e5", tab_id: 7 },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: false }) as chrome.tabs.Tab),
query: vi.fn(),
captureVisibleTab: vi.fn(),
}),
);
expect(res).toEqual({
code: "cdp_failed",
message: "Page.captureScreenshot exceeded the visual surface pixel budget",
});
expect(sent.filter((call) => call.method === "Page.captureScreenshot")).toHaveLength(2);
});
it("crops a visual surface screenshot to its observation-time visible region", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e5", 999, {
tabId: 7,
kind: "surface",
visibleRect: { x: 25, y: 30, w: 50, h: 40 },
});
const { cdp, sent } = makeFakeCdp({
"Page.getLayoutMetrics": () => ({
cssLayoutViewport: { clientWidth: 800, clientHeight: 600 },
}),
"DOM.getContentQuads": () => ({ quads: [[0, 0, 200, 0, 200, 100, 0, 100]] }),
"Page.captureScreenshot": () => ({ data: pngWithIhdrDimensions(100, 80) }),
});
const res = await handleScreenshot(
sm,
{ session_id: "aa11", ref: "@e5", tab_id: 7 },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: false }) as chrome.tabs.Tab),
query: vi.fn(),
captureVisibleTab: vi.fn(),
}),
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
const clip = sent.find((call) => call.method === "Page.captureScreenshot")?.params as {
clip?: { x: number; y: number; width: number; height: number };
};
expect(clip.clip).toMatchObject({ x: 25, y: 30, width: 50, height: 40 });
expect(res).toMatchObject({ width: 100, height: 80 });
});
it("routes OOPIF surface geometry through its child session and captures top coordinates", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e5", 999, {
tabId: 7,
frameId: "child-frame",
cdpSessionId: "child-session",
kind: "surface",
visibleRect: { x: 224, y: 346, w: 200, h: 80 },
});
const { cdp, sent } = makeFakeCdp({
"DOM.getBoxModel": () => ({
model: { content: [204, 306, 604, 306, 604, 506, 204, 506] },
}),
"Page.getLayoutMetrics": () => ({
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
}),
"Page.captureScreenshot": () => ({ data: TINY_PNG }),
});
cdp.getFrameGraph = vi.fn(async () => ({
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 7 } },
{
frameId: "child-frame",
parentFrameId: "main",
ownerBackendNodeId: 99,
target: { tabId: 7, sessionId: "child-session" },
},
],
}));
const targetCalls: Array<{ sessionId?: string; method: string }> = [];
cdp.sendToTarget = vi.fn(async (target, method) => {
targetCalls.push({ sessionId: target.sessionId, method });
if (method === "DOM.getContentQuads") {
return { quads: [[10, 20, 110, 20, 110, 60, 10, 60]] };
}
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
}
throw new Error(`unexpected child CDP call ${method}`);
}) as CdpRunner["sendToTarget"];
const res = await handleScreenshot(
sm,
{ session_id: "aa11", ref: "@e5", tab_id: 7 },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: false }) as chrome.tabs.Tab),
query: vi.fn(),
captureVisibleTab: vi.fn(),
}),
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(targetCalls).toEqual([
{ sessionId: "child-session", method: "DOM.getContentQuads" },
{ sessionId: "child-session", method: "Page.getLayoutMetrics" },
]);
const clip = sent.find((call) => call.method === "Page.captureScreenshot")?.params as {
clip?: { x: number; y: number; width: number; height: number };
};
expect(clip.clip).toMatchObject({ x: 224, y: 346, width: 200, height: 80 });
});
it("fails closed when a ref capture is not a valid PNG", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e5", 999, {
tabId: 7,
kind: "surface",
visibleRect: { x: 0, y: 0, w: 50, h: 50 },
});
const { cdp } = makeFakeCdp({
"DOM.getContentQuads": () => ({ quads: [[0, 0, 50, 0, 50, 50, 0, 50]] }),
"Page.captureScreenshot": () => ({ data: "bm90LWEtcG5n" }),
});
const res = await handleScreenshot(
sm,
{ session_id: "aa11", ref: "@e5", tab_id: 7 },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: false }) as chrome.tabs.Tab),
query: vi.fn(),
captureVisibleTab: vi.fn(),
}),
);
expect(res).toMatchObject({ code: "cdp_failed", message: /invalid PNG data/ });
expect(clip).toMatchObject(expected);
expect(sent.filter((c) => c.method === "Page.getLayoutMetrics")).toHaveLength(1);
});
it("returns not_found for unknown ref", async () => {
@@ -3100,229 +2846,6 @@ describe("handleSnapshot", () => {
);
});
it("discovers rendered canvases only in observe and stores a screenshot-only ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
const root: CdpAxNode = {
nodeId: "1",
role: { type: "role", value: "RootWebArea" },
name: { type: "computedString", value: "Canvas app" },
backendDOMNodeId: 100,
};
const strings = ["body", "canvas", "static", "auto"];
const i = (value: string) => strings.indexOf(value);
const send = vi.fn(async (_tabId: number, method: string) => {
if (method === "Accessibility.enable" || method === "DOMSnapshot.enable") return {};
if (method === "Accessibility.getFullAXTree") return { nodes: [root] };
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 } };
}
if (method === "DOMSnapshot.captureSnapshot") {
return {
strings,
documents: [
{
nodes: {
parentIndex: [-1, 0],
nodeName: [i("body"), i("canvas")],
backendNodeId: [100, 200],
attributes: [[], []],
},
layout: {
nodeIndex: [0, 1],
styles: [
[i("static"), i("auto"), i("auto")],
[i("static"), i("auto"), i("auto")],
],
bounds: [
[0, 0, 1000, 800],
[20, 30, 800, 500],
],
paintOrders: [0, 1],
},
},
],
};
}
throw new Error(`unexpected CDP method ${method}`);
});
const deps = {
cdp: {
send: send as unknown as <T = unknown>(
tabId: number,
method: string,
params?: object,
) => Promise<T>,
trackSessionTab: vi.fn(),
},
tabsApi: {
get: vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
},
conditionalSurfaceProbe: false,
};
const observed = await handleObserve(sm, { session_id: "aa11" }, deps);
if ("code" in observed) throw new Error(`unexpected error: ${JSON.stringify(observed)}`);
expect(observed.text).toContain('@e1 surface "canvas visual surface"');
expect(ctx.refStore.resolveEntry("e1")).toMatchObject({
backendNodeId: 200,
kind: "surface",
capabilities: ["screenshot"],
});
const snapshot = await handleSnapshot(sm, { session_id: "aa11" }, deps);
if ("code" in snapshot) throw new Error(`unexpected error: ${JSON.stringify(snapshot)}`);
expect(snapshot.text).not.toContain("visual surface");
expect(snapshot.ref_count).toBe(0);
});
it("preserves an overflow-clipped surface region through observation and screenshot", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
const root: CdpAxNode = {
nodeId: "1",
role: { type: "role", value: "RootWebArea" },
name: { type: "computedString", value: "Clipped canvas" },
backendDOMNodeId: 100,
};
const strings = [
"body",
"div",
"canvas",
"aria-label",
"clipped canvas",
"static",
"auto",
"visible",
"hidden",
"1",
];
const i = (value: string) => strings.indexOf(value);
const send = vi.fn(async (_tabId: number, method: string, _params?: object) => {
if (method === "Accessibility.enable" || method === "DOMSnapshot.enable") return {};
if (method === "Accessibility.getFullAXTree") return { nodes: [root] };
if (method === "Page.getLayoutMetrics") {
return {
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
layoutViewport: { clientWidth: 1000, clientHeight: 800 },
};
}
if (method === "DOMSnapshot.captureSnapshot") {
return {
strings,
documents: [
{
nodes: {
parentIndex: [-1, 0, 1],
nodeName: [i("body"), i("div"), i("canvas")],
backendNodeId: [100, 150, 200],
attributes: [[], [], [i("aria-label"), i("clipped canvas")]],
},
layout: {
nodeIndex: [0, 1, 2],
styles: [
[
i("static"),
i("auto"),
i("auto"),
i("visible"),
i("1"),
i("visible"),
i("visible"),
],
[
i("static"),
i("auto"),
i("auto"),
i("visible"),
i("1"),
i("hidden"),
i("hidden"),
],
[
i("static"),
i("auto"),
i("auto"),
i("visible"),
i("1"),
i("visible"),
i("visible"),
],
],
bounds: [
[0, 0, 1000, 800],
[10, 20, 100, 70],
[55, 30, 140, 60],
],
paintOrders: [0, 1, 2],
},
},
],
};
}
if (method === "DOM.getContentQuads") {
return { quads: [[55, 30, 195, 30, 195, 90, 55, 90]] };
}
if (method === "Runtime.evaluate") return { result: { value: 1 } };
if (method === "Page.captureScreenshot") {
return { data: pngWithIhdrDimensions(55, 60) };
}
throw new Error(`unexpected CDP method ${method}`);
});
const cdp = {
send: send as unknown as CdpRunner["send"],
trackSessionTab: vi.fn(),
getFrameGraph: vi.fn(async () => ({
rootFrameId: "root",
frames: [{ frameId: "root", target: { tabId: 4 } }],
})),
} as CdpRunner;
const deps = {
cdp,
tabsApi: {
get: vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
},
conditionalSurfaceProbe: false,
};
const observed = await handleObserve(sm, { session_id: "aa11" }, deps);
if ("code" in observed) throw new Error(`unexpected error: ${JSON.stringify(observed)}`);
expect(observed.text).toContain(
'@e1 surface "clipped canvas" [bounds=55,30,55,60; rendering=canvas;',
);
expect(ctx.refStore.resolveEntry("e1")).toMatchObject({
backendNodeId: 200,
kind: "surface",
visibleRect: { x: 55, y: 30, w: 55, h: 60 },
});
const screenshot = await handleScreenshot(
sm,
{ session_id: "aa11", ref: "@e1", tab_id: 4 },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 4, windowId: 100, active: true }) as chrome.tabs.Tab),
}),
);
if ("code" in screenshot) {
throw new Error(`unexpected error: ${JSON.stringify(screenshot)}`);
}
const captureCall = send.mock.calls.find(([, method]) => method === "Page.captureScreenshot");
expect((captureCall?.[2] as { clip?: object }).clip).toMatchObject({
x: 55,
y: 30,
width: 55,
height: 60,
});
expect(screenshot).toMatchObject({ width: 55, height: 60 });
});
it("allows passive snapshots of explicit user-window tabs", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
@@ -3624,7 +3147,9 @@ describe("handleSnapshot", () => {
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
};
function makeFrameAwareDeps() {
function makeFrameAwareDeps(
ownerGeometry: "unavailable" | "available" | "clipped" = "unavailable",
) {
const strings = ["html", "body", "iframe", "button", "title", "Remote", "static", "auto"];
const i = (value: string) => strings.indexOf(value);
const styles = [i("static"), i("auto"), i("auto")];
@@ -3704,6 +3229,16 @@ describe("handleSnapshot", () => {
];
const send = vi.fn(async (_tabId: number, method: string) => {
if (method === "Page.getLayoutMetrics") return VP_METRICS;
if (ownerGeometry !== "unavailable") {
if (method === "DOM.getBoxModel") {
const x = ownerGeometry === "clipped" ? 10000 : 100;
return { model: { content: [x, 100, x + 400, 100, x + 400, 400, x, 400] } };
}
if (method === "DOM.resolveNode") return { object: { objectId: "owner" } };
if (method === "Runtime.callFunctionOn")
return { result: { value: { width: 400, height: 300 } } };
if (method === "Runtime.releaseObject") return {};
}
if (method === "DOMSnapshot.enable" || method === "Accessibility.enable") return {};
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
if (method === "Accessibility.getFullAXTree") return { nodes: mainAx };
@@ -3944,6 +3479,43 @@ describe("handleSnapshot", () => {
expect(result.text).toContain("•••");
});
it.each([
undefined,
100,
0,
])("reports unavailable iframe geometry within the observation budget %s", async (maxTokens) => {
const result = await captureVomObservation(makeFrameAwareDeps().cdp, 4, "https://example.com", {
maxTokens,
});
expect(result.text.match(/@warning/g)).toHaveLength(1);
expect(result.text).toContain("iframe geometry incomplete");
expect(result.text.startsWith("@vom 1\n")).toBe(true);
if (maxTokens === undefined) {
expect(result.truncated).toBe(false);
expect(result.text).toContain("Frame action");
expect(result.matchNodes.find((node) => node.backendNodeId === 22)).toMatchObject({
rect: null,
localRect: { x: 20, y: 30, w: 120, h: 40 },
});
}
if (maxTokens === 100) expect(Math.ceil(result.text.length / 4)).toBeLessThanOrEqual(maxTokens);
});
it.each([
"available",
"clipped",
] as const)("does not report successful %s geometry as an observation failure", async (ownerGeometry) => {
const result = await captureVomObservation(
makeFrameAwareDeps(ownerGeometry).cdp,
4,
"https://example.com",
);
expect(result.text).not.toContain("@warning");
const node = result.matchNodes.find((item) => item.backendNodeId === 22);
if (ownerGeometry === "clipped") expect(node?.rect).toBeNull();
else expect(node?.rect).toEqual({ x: 120, y: 130, w: 120, h: 40 });
});
it("keeps frames and rendered refs on the same frame identity", async () => {
const deps = makeFrameAwareDeps();
@@ -24,14 +24,10 @@ describe("lookupSnapshotRef", () => {
expect(lookupSnapshotRef(ctx, "@e3", 4)).toEqual({
backendNodeId: 1234,
refKey: "e3",
kind: "dom",
capabilities: ["interact", "screenshot"],
});
expect(lookupSnapshotRef(ctx, "e3", 4)).toEqual({
backendNodeId: 1234,
refKey: "e3",
kind: "dom",
capabilities: ["interact", "screenshot"],
});
});
@@ -57,8 +53,6 @@ describe("resolveSnapshotRef", () => {
tabId: 4,
frameId: "child-frame",
cdpSessionId: "child-session",
kind: "dom",
capabilities: ["interact", "screenshot"],
});
const expected = {
@@ -66,8 +60,6 @@ describe("resolveSnapshotRef", () => {
refKey: "e3",
frameId: "child-frame",
cdpSessionId: "child-session",
kind: "dom" as const,
capabilities: ["interact", "screenshot"] as const,
};
expect(lookupSnapshotRef(ctx, "@e3", 4)).toEqual(expected);
expect(resolveSnapshotRef(ctx, "@e3", 4)).toEqual(expected);
@@ -113,28 +105,6 @@ describe("resolveSnapshotRef", () => {
expect(resolveSnapshotRef(ctx, "@e3", 4)).toEqual({
backendNodeId: 1234,
refKey: "e3",
kind: "dom",
capabilities: ["interact", "screenshot"],
});
});
it("rejects an operation outside the ref's declared capabilities", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, {
tabId: 4,
kind: "surface",
capabilities: ["screenshot"],
});
expect(resolveSnapshotRef(ctx, "@e3", 4, "interact")).toMatchObject({
code: "permission_denied",
message: "ref @e3 does not support interact",
data: {
reason: "ref_capability_denied",
required_capability: "interact",
kind: "surface",
},
});
});
});
@@ -1,37 +0,0 @@
import { describe, expect, it } from "vitest";
import {
correctedVisualScreenshotScale,
planVisualScreenshot,
screenshotFitsPixelBudget,
} from "../visual-screenshot-plan";
const budget = { maxEdge: 2_048, maxPixels: 4_000_000 };
describe("visual screenshot pixel planning", () => {
it.each([1, 1.25, 1.5, 2, 3])("accounts for raster scale %s", (rasterScaleEstimate) => {
const plan = planVisualScreenshot({
cssRect: { width: 2_000, height: 1_000 },
rasterScaleEstimate,
budget,
});
const width = 2_000 * rasterScaleEstimate * plan.clipScale;
const height = 1_000 * rasterScaleEstimate * plan.clipScale;
expect(Math.max(width, height)).toBeLessThanOrEqual(budget.maxEdge);
expect(width * height).toBeLessThanOrEqual(budget.maxPixels);
});
it("corrects a scale from actual PNG dimensions", () => {
const corrected = correctedVisualScreenshotScale({
previousClipScale: 1,
actualWidth: 4_000,
actualHeight: 2_000,
budget,
});
expect(corrected).not.toBeNull();
expect(corrected as number).toBeLessThan(2_048 / 4_000);
expect(screenshotFitsPixelBudget({ width: 2_000, height: 1_000 }, budget)).toBe(true);
expect(screenshotFitsPixelBudget({ width: 4_000, height: 2_000 }, budget)).toBe(false);
});
});
+46 -287
View File
@@ -1,30 +1,21 @@
import {
type CdpFrame,
type CdpFrameGraph,
type CdpTarget,
cdpTargetKey,
} from "@/browser-driver/frame-graph";
import { type CdpTarget, cdpTargetKey } from "@/browser-driver/frame-graph";
import type { RpcError } from "@/transport/types";
import { nodeContentRegion, scrollNodeIntoView } from "./element-geometry";
import {
clipPolygon,
type GeometryProjection,
type Point,
type Polygon,
type ProjectiveEdge,
parseCdpQuad,
polygonArea,
polygonCentroid,
projectRectToViewport,
projectRegionToViewport,
type Quad,
type Region,
rectPolygon,
regionBounds,
type Size,
type ViewportRect,
} from "./geometry";
import { type CdpRunner, cdpRunnerForTarget, isRpcError, sendToCdpTarget } from "./shared";
import type { CssViewport } from "./geometry/coordinate-types";
import { cssViewport, GeometryContext } from "./geometry/frame-context";
import { type CdpRunner, cdpRunnerForTarget, isRpcError } from "./shared";
export interface NodeAddress {
target: CdpTarget;
@@ -33,6 +24,8 @@ export interface NodeAddress {
}
export interface ResolvedNodeGeometry {
/** The same top-level measurement used by projection, for screenshot coordinate adaptation. */
topViewport: CssViewport;
topVisibleRegions: Region;
topBounds: ViewportRect;
/** Point in the top-level tab viewport, used by root-target input events. */
@@ -45,275 +38,16 @@ function geometryError(message: string): RpcError {
return { code: "cdp_failed", message };
}
function frameMap(graph: CdpFrameGraph): Map<string, CdpFrame> {
return new Map(graph.frames.map((frame) => [frame.frameId, frame]));
}
function targetRootFrame(byId: Map<string, CdpFrame>, frame: CdpFrame): CdpFrame | null {
const seen = new Set<string>();
let current = frame;
while (current.parentFrameId) {
if (seen.has(current.frameId)) return null;
seen.add(current.frameId);
const parent = byId.get(current.parentFrameId);
if (!parent || parent.target.sessionId !== current.target.sessionId) break;
current = parent;
}
return current;
}
function frameAncestry(graph: CdpFrameGraph, frameId: string): CdpFrame[] | null {
const byId = frameMap(graph);
const path: CdpFrame[] = [];
const seen = new Set<string>();
let current = byId.get(frameId);
if (!current) return null;
while (current.parentFrameId) {
if (seen.has(current.frameId)) return null;
seen.add(current.frameId);
path.push(current);
const parent = byId.get(current.parentFrameId);
if (!parent) return null;
current = parent;
}
return path;
}
async function targetViewport(cdp: CdpRunner, target: CdpTarget): Promise<Size | null> {
const metrics = await sendToCdpTarget<{
cssLayoutViewport?: { clientWidth?: number; clientHeight?: number };
layoutViewport?: { clientWidth?: number; clientHeight?: number };
}>(cdp, target, "Page.getLayoutMetrics", {});
const viewport = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
const width = viewport.clientWidth ?? 0;
const height = viewport.clientHeight ?? 0;
return width > 0 && height > 0 ? { width, height } : null;
}
async function sameTargetFrameViewport(cdp: CdpRunner, frame: CdpFrame): Promise<Size | null> {
const world = await sendToCdpTarget<{ executionContextId?: number }>(
cdp,
frame.target,
"Page.createIsolatedWorld",
{
frameId: frame.frameId,
worldName: "bsk-frame-geometry",
grantUniveralAccess: false,
},
);
if (world.executionContextId === undefined) return null;
const result = await sendToCdpTarget<{ result?: { value?: unknown } }>(
cdp,
frame.target,
"Runtime.evaluate",
{
expression: "({width:window.innerWidth,height:window.innerHeight})",
contextId: world.executionContextId,
returnByValue: true,
},
);
const value = result.result?.value as { width?: unknown; height?: unknown } | undefined;
const width = value?.width;
const height = value?.height;
return typeof width === "number" &&
Number.isFinite(width) &&
width > 0 &&
typeof height === "number" &&
Number.isFinite(height) &&
height > 0
? { width, height }
: null;
}
async function ownerContentQuad(
cdp: CdpRunner,
parent: CdpFrame,
ownerBackendNodeId: number,
): Promise<Quad | null> {
const result = await sendToCdpTarget<{ model?: { content?: number[] } }>(
cdp,
parent.target,
"DOM.getBoxModel",
{ backendNodeId: ownerBackendNodeId },
);
return parseCdpQuad(result.model?.content);
}
export interface FrameGeometryContext {
resolveFrameProjection(frameId: string): Promise<GeometryProjection | null>;
projectFrameLocalRect(
frameId: string,
rect: { x: number; y: number; w: number; h: number },
): Promise<ViewportRect | null>;
}
export function createFrameGeometryContext(
cdp: CdpRunner,
graph: CdpFrameGraph,
): FrameGeometryContext {
const byId = frameMap(graph);
const targetViewports = new Map<string, Promise<Size | null>>();
const frameViewports = new Map<string, Promise<Size | null>>();
const ownerQuads = new Map<string, Promise<Quad | null>>();
const projections = new Map<string, Promise<GeometryProjection | null>>();
const localProjections = new Map<string, Promise<GeometryProjection | null>>();
const cachedTargetViewport = (target: CdpTarget): Promise<Size | null> => {
const key = cdpTargetKey(target);
let value = targetViewports.get(key);
if (!value) {
value = targetViewport(cdp, target);
targetViewports.set(key, value);
}
return value;
};
const cachedFrameViewport = (frame: CdpFrame): Promise<Size | null> => {
let value = frameViewports.get(frame.frameId);
if (!value) {
const root = targetRootFrame(byId, frame);
value =
root?.frameId === frame.frameId
? cachedTargetViewport(frame.target)
: sameTargetFrameViewport(cdp, frame);
frameViewports.set(frame.frameId, value);
}
return value;
};
const cachedOwnerQuad = (parent: CdpFrame, backendNodeId: number): Promise<Quad | null> => {
const key = `${cdpTargetKey(parent.target)}:${backendNodeId}`;
let value = ownerQuads.get(key);
if (!value) {
value = ownerContentQuad(cdp, parent, backendNodeId);
ownerQuads.set(key, value);
}
return value;
};
const cachedSameTargetClips = async (
frame: CdpFrame,
root: CdpFrame,
): Promise<Polygon[] | null> => {
const clips: Polygon[] = [];
let current = frame;
const seen = new Set<string>();
while (current.frameId !== root.frameId) {
if (seen.has(current.frameId) || !current.parentFrameId) return null;
seen.add(current.frameId);
const parent = byId.get(current.parentFrameId);
if (!parent || current.ownerBackendNodeId === undefined) return null;
const clip = await cachedOwnerQuad(parent, current.ownerBackendNodeId);
if (!clip) return null;
clips.push(clip);
current = parent;
}
return clips;
};
const buildTargetProjection = async (frameId: string): Promise<GeometryProjection | null> => {
const frame = byId.get(frameId);
if (!frame) return null;
let root = targetRootFrame(byId, frame);
if (!root) return null;
const sourceViewport = await cachedTargetViewport(root.target);
if (!sourceViewport) return null;
const sourceClips = await cachedSameTargetClips(frame, root);
if (!sourceClips) return null;
const edges: ProjectiveEdge[] = [];
while (root.parentFrameId) {
const parent = byId.get(root.parentFrameId);
if (!parent || root.ownerBackendNodeId === undefined) return null;
const destinationQuad = await cachedOwnerQuad(parent, root.ownerBackendNodeId);
if (!destinationQuad) return null;
const source = await cachedTargetViewport(root.target);
if (!source) return null;
const parentRoot = targetRootFrame(byId, parent);
if (!parentRoot) return null;
const destinationClips = await cachedSameTargetClips(parent, parentRoot);
if (!destinationClips) return null;
edges.push({ sourceViewport: source, destinationQuad, destinationClips });
root = parentRoot;
}
const topViewport = await cachedTargetViewport(root.target);
return topViewport ? { sourceClips, edges, topViewport } : null;
};
const resolveFrameProjectionCached = (frameId: string): Promise<GeometryProjection | null> => {
let value = projections.get(frameId);
if (!value) {
value = buildTargetProjection(frameId);
projections.set(frameId, value);
}
return value;
};
const buildLocalProjection = async (frameId: string): Promise<GeometryProjection | null> => {
const frame = byId.get(frameId);
if (!frame) return null;
const root = targetRootFrame(byId, frame);
if (!root) return null;
const targetProjection = await resolveFrameProjectionCached(root.frameId);
if (!targetProjection) return null;
if (root.frameId === frame.frameId) return targetProjection;
if (!frame.parentFrameId || frame.ownerBackendNodeId === undefined) return null;
const parent = byId.get(frame.parentFrameId);
if (!parent) return null;
const sourceViewport = await cachedFrameViewport(frame);
const destinationQuad = await cachedOwnerQuad(parent, frame.ownerBackendNodeId);
const destinationClips = await cachedSameTargetClips(frame, root);
if (!sourceViewport || !destinationQuad || !destinationClips) return null;
return {
sourceClips: [],
edges: [{ sourceViewport, destinationQuad, destinationClips }, ...targetProjection.edges],
topViewport: targetProjection.topViewport,
};
};
const localProjection = (frameId: string): Promise<GeometryProjection | null> => {
let value = localProjections.get(frameId);
if (!value) {
value = buildLocalProjection(frameId);
localProjections.set(frameId, value);
}
return value;
};
return {
resolveFrameProjection: resolveFrameProjectionCached,
async projectFrameLocalRect(frameId, rect) {
const projection = await localProjection(frameId);
return projection ? projectRectToViewport(rect, projection) : null;
},
};
}
export async function resolveFrameProjection(
cdp: CdpRunner,
graph: CdpFrameGraph,
frameId: string,
): Promise<GeometryProjection | null> {
return createFrameGeometryContext(cdp, graph).resolveFrameProjection(frameId);
}
async function loadFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFrameGraph | null> {
if (!cdp.getFrameGraph) return null;
try {
return await cdp.getFrameGraph(tabId);
} catch (error) {
console.debug("[bsk frame-geometry] frame graph resolution failed", error);
return null;
}
}
async function scrollFrameOwners(
cdp: CdpRunner,
tabId: number,
graph: CdpFrameGraph,
context: GeometryContext,
frameId: string,
): Promise<RpcError | null> {
const byId = frameMap(graph);
const ancestry = frameAncestry(graph, frameId);
const ancestry = await context.ancestry(frameId);
if (!ancestry) return geometryError(`could not resolve frame ancestry for ${frameId}`);
for (const child of [...ancestry].reverse()) {
const parent = child.parentFrameId ? byId.get(child.parentFrameId) : undefined;
const parent = child.parentFrameId ? await context.frame(child.parentFrameId) : undefined;
if (!parent || child.ownerBackendNodeId === undefined) {
return geometryError(`could not resolve frame owner for ${child.frameId}`);
}
@@ -327,17 +61,20 @@ async function scrollFrameOwners(
return null;
}
async function scrollElementWithFrameGraph(
async function scrollElementWithContext(
cdp: CdpRunner,
tabId: number,
target: CdpTarget,
backendNodeId: number,
frameId: string | undefined,
graph: CdpFrameGraph | null,
context: GeometryContext,
): Promise<RpcError | null> {
if (frameId) {
if (!graph) return geometryError(`could not resolve frame graph for ${frameId}`);
const error = await scrollFrameOwners(cdp, tabId, graph, frameId);
const frame = await context.frame(frameId);
if (!frame || cdpTargetKey(frame.target) !== cdpTargetKey(target)) {
return geometryError("node frame does not belong to its target");
}
const error = await scrollFrameOwners(cdp, tabId, context, frameId);
if (error) return error;
} else if (target.sessionId) {
return geometryError("an OOPIF node address requires frameId");
@@ -352,8 +89,15 @@ export async function scrollElementAndFramesIntoView(
backendNodeId: number,
frameId?: string,
): Promise<RpcError | null> {
const graph = frameId ? await loadFrameGraph(cdp, tabId) : null;
return scrollElementWithFrameGraph(cdp, tabId, target, backendNodeId, frameId, graph);
if (target.tabId !== tabId) return geometryError("node target belongs to another tab");
return scrollElementWithContext(
cdp,
tabId,
target,
backendNodeId,
frameId,
new GeometryContext(cdp, tabId),
);
}
function largestRegion(regions: Region): Polygon | null {
@@ -376,23 +120,32 @@ export async function resolveNodeGeometry(
if (address.target.sessionId && !address.frameId) {
return geometryError("an OOPIF node address requires frameId");
}
const graph = address.frameId ? await loadFrameGraph(cdp, tabId) : null;
if (address.target.tabId !== tabId) return geometryError("node target belongs to another tab");
const context = new GeometryContext(cdp, tabId);
const graph = address.frameId ? await context.graph() : null;
if (address.frameId && graph) {
const frame = await context.frame(address.frameId);
if (!frame || cdpTargetKey(frame.target) !== cdpTargetKey(address.target)) {
return geometryError("node frame does not belong to its target");
}
}
if (address.frameId && !graph) {
return geometryError(`could not resolve frame graph for ${address.frameId}`);
}
if (options.scrollIntoView) {
const scrollError = await scrollElementWithFrameGraph(
const scrollError = await scrollElementWithContext(
cdp,
tabId,
address.target,
address.backendNodeId,
address.frameId,
graph,
context,
);
if (scrollError) return scrollError;
}
// Only topology was read above. Begin live measurements after scrolling.
const localRegion = await nodeContentRegion(
cdpRunnerForTarget(cdp, address.target),
tabId,
@@ -403,7 +156,7 @@ export async function resolveNodeGeometry(
let topVisibleRegions: Region;
let targetActionPoint: Point | null = null;
if (address.frameId && graph) {
const projection = await resolveFrameProjection(cdp, graph, address.frameId);
const projection = await context.targetProjection(address.frameId);
if (!projection)
return geometryError(`could not resolve frame geometry for ${address.frameId}`);
topVisibleRegions = projectRegionToViewport(localRegion, projection);
@@ -426,7 +179,7 @@ export async function resolveNodeGeometry(
targetActionPoint = localActionRegion ? polygonCentroid(localActionRegion) : null;
}
} else {
const viewport = await targetViewport(cdp, address.target);
const viewport = await context.viewport(address.target);
if (!viewport) return geometryError("could not resolve top viewport geometry");
topVisibleRegions = localRegion
.map((polygon) =>
@@ -445,7 +198,13 @@ export async function resolveNodeGeometry(
return { code: "permission_denied", message: "element not visible in its target" };
}
targetActionPoint ??= actionPoint;
return { topVisibleRegions, topBounds, actionPoint, targetActionPoint };
return {
topVisibleRegions,
topBounds,
actionPoint,
targetActionPoint,
topViewport: cssViewport(await context.layoutMetrics({ tabId })),
};
} catch (error) {
return geometryError(error instanceof Error ? error.message : String(error));
}
@@ -0,0 +1,102 @@
import { type CdpTarget, cdpTargetKey } from "@/browser-driver/frame-graph";
import type { GeometryProjection, ViewportRect } from "../geometry";
import { projectRectToViewport } from "../geometry";
export interface CoordinateOwner {
target: CdpTarget;
frameId?: string;
}
export interface FrameViewportRect {
space: "frame-viewport-css";
owner: CoordinateOwner;
rect: ViewportRect;
}
export interface SnapshotProjection {
source: CoordinateOwner;
geometry: GeometryProjection;
}
/** A failed owner read is different from a successfully projected, clipped-out rect. */
export interface UnavailableFrameProjection {
status: "unavailable";
source: CoordinateOwner;
ownerBackendNodeId: number;
}
export type SnapshotProjectionResult =
| { status: "available"; projection: SnapshotProjection }
| UnavailableFrameProjection;
/** Descendants inherit the failed boundary without attempting another owner read. */
export type FrameProjectionIssue =
| UnavailableFrameProjection
| { status: "blocked"; source: CoordinateOwner; cause: UnavailableFrameProjection };
export type FrameProjectionState = SnapshotProjectionResult | FrameProjectionIssue;
/** DOMSnapshot bounds are document-relative CSS pixels, independent of raster DPR. */
export function snapshotViewportRect(
bounds: number[],
owner: CoordinateOwner,
scroll: { x: number; y: number },
): FrameViewportRect | null {
if (bounds.length < 4 || !bounds.slice(0, 4).every(Number.isFinite)) return null;
const [x, y, width, height] = bounds;
if (width <= 0 || height <= 0 || !Number.isFinite(scroll.x) || !Number.isFinite(scroll.y)) {
return null;
}
return {
space: "frame-viewport-css",
owner,
rect: { x: x - scroll.x, y: y - scroll.y, width, height },
};
}
export function projectSnapshotRect(
input: FrameViewportRect,
projection: SnapshotProjection,
): ViewportRect | null {
if (
input.owner.frameId !== projection.source.frameId ||
cdpTargetKey(input.owner.target) !== cdpTargetKey(projection.source.target)
) {
return null;
}
const { x, y, width: w, height: h } = input.rect;
return projectRectToViewport({ x, y, w, h }, projection.geometry);
}
/** CSS viewport metadata; browser zoom is distinct from raster devicePixelRatio. */
export interface CssViewport {
width: number;
height: number;
scrollX: number;
scrollY: number;
cssToDip: number;
}
/** Page.captureScreenshot takes page-relative DIP, not viewport-relative CSS pixels. */
export function screenshotPageRect(
rect: ViewportRect,
viewport: CssViewport,
): { space: "page-dip"; rect: ViewportRect } | null {
const { cssToDip, scrollX, scrollY } = viewport;
if (
![rect.x, rect.y, rect.width, rect.height, cssToDip, scrollX, scrollY].every(Number.isFinite) ||
cssToDip <= 0 ||
rect.width <= 0 ||
rect.height <= 0
)
return null;
return {
space: "page-dip",
rect: {
x: (rect.x + scrollX) * cssToDip,
y: (rect.y + scrollY) * cssToDip,
width: rect.width * cssToDip,
height: rect.height * cssToDip,
},
};
}
@@ -0,0 +1,333 @@
import {
type CdpFrame,
type CdpFrameGraph,
type CdpTarget,
cdpTargetKey,
} from "@/browser-driver/frame-graph";
import {
type GeometryProjection,
type Polygon,
type ProjectiveEdge,
parseCdpQuad,
type Quad,
type Size,
} from "../geometry";
import { type CdpRunner, sendToCdpTarget } from "../shared";
import type { CoordinateOwner, CssViewport, SnapshotProjectionResult } from "./coordinate-types";
import { readSnapshotOwnerSizes } from "./snapshot-owner-sizes";
export interface LayoutMetrics {
cssVisualViewport?: { zoom?: number };
visualViewport?: { zoom?: number };
cssLayoutViewport?: {
clientWidth?: number;
clientHeight?: number;
pageX?: number;
pageY?: number;
};
layoutViewport?: { clientWidth?: number; clientHeight?: number; pageX?: number; pageY?: number };
}
/** The legacy field is a compatibility fallback, never a source of raster DPR. */
export function cssViewport(metrics: LayoutMetrics): CssViewport {
const value = metrics.cssLayoutViewport ?? metrics.layoutViewport;
return {
width: value?.clientWidth ?? 0,
height: value?.clientHeight ?? 0,
scrollX: value?.pageX ?? 0,
scrollY: value?.pageY ?? 0,
cssToDip: metrics.cssVisualViewport?.zoom ?? metrics.visualViewport?.zoom ?? 1,
};
}
/** One read-only measurement phase. Discard after scrolling or any later operation. */
export class GeometryContext {
private readonly metrics = new Map<string, Promise<LayoutMetrics>>();
private readonly owners = new Map<string, Promise<Quad | null>>();
private readonly projections = new Map<string, Promise<GeometryProjection | null>>();
private readonly snapshotOwners = new Map<string, Promise<{ quad: Quad; size: Size } | null>>();
private readonly sizeBatches = new Map<string, () => Promise<Map<number, Size>>>();
private active = 0;
private readonly waiting: Array<() => void> = [];
private graphPromise?: Promise<CdpFrameGraph | null>;
private framesPromise?: Promise<Map<string, CdpFrame>>;
constructor(
private readonly cdp: CdpRunner,
private readonly tabId: number,
graph?: CdpFrameGraph,
private readonly signal?: AbortSignal,
) {
if (graph) this.graphPromise = Promise.resolve(graph);
}
private async request<T>(
target: CdpTarget,
method: string,
params: object,
cleanup = false,
): Promise<T> {
if (!cleanup && this.signal?.aborted) throw new DOMException("geometry aborted", "AbortError");
if (this.active >= 4) await new Promise<void>((resolve) => this.waiting.push(resolve));
else this.active++;
try {
if (!cleanup && this.signal?.aborted)
throw new DOMException("geometry aborted", "AbortError");
return await sendToCdpTarget<T>(this.cdp, target, method, params);
} finally {
const next = this.waiting.shift();
if (next) next();
else this.active--;
}
}
graph(): Promise<CdpFrameGraph | null> {
this.graphPromise ??=
this.cdp.getFrameGraph?.(this.tabId).catch(() => null) ?? Promise.resolve(null);
return this.graphPromise;
}
private frames(): Promise<Map<string, CdpFrame>> {
this.framesPromise ??= this.graph().then(
(graph) => new Map(graph?.frames.map((frame) => [frame.frameId, frame]) ?? []),
);
return this.framesPromise;
}
async frame(frameId: string): Promise<CdpFrame | undefined> {
return (await this.frames()).get(frameId);
}
/** Topology only: safe to resolve before the caller scrolls frame owners. */
async ancestry(frameId: string): Promise<CdpFrame[] | null> {
const frames = await this.frames();
const path: CdpFrame[] = [];
const seen = new Set<string>();
let current = frames.get(frameId);
if (!current) return null;
while (current.parentFrameId) {
if (seen.has(current.frameId)) return null;
seen.add(current.frameId);
path.push(current);
const parent = frames.get(current.parentFrameId);
if (!parent) return null;
current = parent;
}
return path;
}
layoutMetrics(target: CdpTarget): Promise<LayoutMetrics> {
const key = cdpTargetKey(target);
let promise = this.metrics.get(key);
if (!promise) {
promise = this.request<LayoutMetrics>(target, "Page.getLayoutMetrics", {});
this.metrics.set(key, promise);
}
return promise;
}
async viewport(target: CdpTarget): Promise<Size | null> {
const { width, height } = cssViewport(await this.layoutMetrics(target));
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
? { width, height }
: null;
}
ownerContent(target: CdpTarget, backendNodeId: number): Promise<Quad | null> {
const key = `${cdpTargetKey(target)}:${backendNodeId}`;
let promise = this.owners.get(key);
if (!promise) {
promise = this.request<{ model?: { content?: number[] } }>(target, "DOM.getBoxModel", {
backendNodeId,
}).then((reply) => parseCdpQuad(reply.model?.content));
this.owners.set(key, promise);
}
return promise;
}
/** Same-target owner quads are already target-relative, including nested owners. */
async snapshotProjection(
source: CoordinateOwner,
ownerBackendNodeId: number,
ancestorClips: Polygon[],
viewport: Size,
): Promise<SnapshotProjectionResult> {
const key = `${cdpTargetKey(source.target)}:${ownerBackendNodeId}`;
let promise = this.snapshotOwners.get(key);
if (!promise) {
promise = this.snapshotOwner(source.target, ownerBackendNodeId);
this.snapshotOwners.set(key, promise);
}
let owner: { quad: Quad; size: Size } | null;
try {
owner = await promise;
} catch (error) {
if (error && typeof error === "object" && "name" in error && error.name === "AbortError")
throw error;
console.debug("[bsk geometry] frame owner unavailable", error);
owner = null;
}
return owner
? {
status: "available",
projection: {
source,
geometry: {
sourceClips: [],
edges: [
{
sourceViewport: owner.size,
destinationQuad: owner.quad,
destinationClips: ancestorClips,
},
],
topViewport: viewport,
},
},
}
: { status: "unavailable", source, ownerBackendNodeId };
}
/** Register one target's snapshot owners; the batch starts only when a
* projection actually needs a size. Single owners keep the cheaper old path. */
registerSnapshotOwners(target: CdpTarget, owners: ReadonlySet<number>): void {
const key = cdpTargetKey(target);
if (owners.size < 2 || this.sizeBatches.has(key)) return;
let pending: Promise<Map<number, Size>> | undefined;
this.sizeBatches.set(key, () => {
pending ??= readSnapshotOwnerSizes(
(method, params, cleanup) => this.request(target, method, params, cleanup),
owners,
).catch((error) => {
if (error && typeof error === "object" && "name" in error && error.name === "AbortError")
throw error;
return new Map<number, Size>();
});
return pending;
});
}
private async snapshotOwner(
target: CdpTarget,
backendNodeId: number,
): Promise<{ quad: Quad; size: Size } | null> {
const quad = await this.ownerContent(target, backendNodeId);
if (!quad) return null;
const batchSize = (await this.sizeBatches.get(cdpTargetKey(target))?.())?.get(backendNodeId);
if (this.signal?.aborted) throw new DOMException("geometry aborted", "AbortError");
if (batchSize) return { quad, size: batchSize };
const resolved = await this.request<{ object?: { objectId?: string } }>(
target,
"DOM.resolveNode",
{ backendNodeId },
);
const objectId = resolved.object?.objectId;
if (!objectId) return null;
try {
const reply = await this.request<{ result?: { value?: Size }; exceptionDetails?: unknown }>(
target,
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: `function() {
if (!this.isConnected) return null;
const style = getComputedStyle(this);
return {
width: this.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
height: this.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
};
}`,
returnByValue: true,
},
);
const size = reply.result?.value;
return !reply.exceptionDetails &&
size &&
Number.isFinite(size.width) &&
Number.isFinite(size.height) &&
size.width > 0 &&
size.height > 0
? { quad, size }
: null;
} finally {
// Cleanup must still run if cancellation arrived after resolving the object.
await this.request(target, "Runtime.releaseObject", { objectId }, true).catch(() => {});
}
}
private targetRoot(frames: Map<string, CdpFrame>, frame: CdpFrame): CdpFrame | null {
const seen = new Set<string>();
let current = frame;
while (current.parentFrameId) {
if (seen.has(current.frameId)) return null;
seen.add(current.frameId);
const parent = frames.get(current.parentFrameId);
if (!parent) return null;
if (cdpTargetKey(parent.target) !== cdpTargetKey(current.target)) break;
current = parent;
}
return current;
}
private async clips(
frames: Map<string, CdpFrame>,
frame: CdpFrame,
root: CdpFrame,
): Promise<Polygon[] | null> {
const clips: Polygon[] = [];
let current = frame;
const seen = new Set<string>();
while (current.frameId !== root.frameId) {
if (seen.has(current.frameId) || !current.parentFrameId) return null;
seen.add(current.frameId);
const parent = frames.get(current.parentFrameId);
if (!parent || current.ownerBackendNodeId === undefined) return null;
const quad = await this.ownerContent(parent.target, current.ownerBackendNodeId);
if (!quad) return null;
clips.push(quad);
current = parent;
}
return clips;
}
/** Input is already in the frame's CDP target viewport, not its local viewport. */
targetProjection(frameId: string): Promise<GeometryProjection | null> {
let promise = this.projections.get(frameId);
if (!promise) {
promise = this.buildTargetProjection(frameId);
this.projections.set(frameId, promise);
}
return promise;
}
private async buildTargetProjection(frameId: string): Promise<GeometryProjection | null> {
const frames = await this.frames();
const frame = frames.get(frameId);
if (!frame) return null;
let root = this.targetRoot(frames, frame);
if (!root) return null;
const sourceViewport = await this.viewport(root.target);
if (!sourceViewport) return null;
const sourceClips = await this.clips(frames, frame, root);
if (!sourceClips) return null;
const edges: ProjectiveEdge[] = [];
const seen = new Set<string>();
while (root.parentFrameId) {
if (seen.has(root.frameId)) return null;
seen.add(root.frameId);
const parent = frames.get(root.parentFrameId);
if (!parent || root.ownerBackendNodeId === undefined) return null;
const destinationQuad = await this.ownerContent(parent.target, root.ownerBackendNodeId);
const source = await this.viewport(root.target);
const parentRoot = this.targetRoot(frames, parent);
if (!destinationQuad || !source || !parentRoot) return null;
const destinationClips = await this.clips(frames, parent, parentRoot);
if (!destinationClips) return null;
edges.push({ sourceViewport: source, destinationQuad, destinationClips });
root = parentRoot;
}
const topViewport = await this.viewport(root.target);
return topViewport ? { sourceClips, edges, topViewport } : null;
}
}
@@ -0,0 +1,74 @@
import type { Size } from "../geometry";
interface SerializedValue {
type: string;
value?: unknown;
}
type Send = <T>(method: string, params: object, cleanup?: boolean) => Promise<T>;
/** Batch only accessible frame owners. Missing/shadow/cross-origin owners keep
* the existing node-scoped read; no page-wide style columns are requested. */
export async function readSnapshotOwnerSizes(
send: Send,
owners: ReadonlySet<number>,
): Promise<Map<number, Size>> {
const sizes = new Map<number, Size>();
const objectGroup = `bsk-frame-sizes-${crypto.randomUUID()}`;
try {
const reply = await send<{ result?: { deepSerializedValue?: SerializedValue } }>(
"Runtime.evaluate",
{
expression: `(() => {
const result = [];
const documents = [document];
for (let i = 0; i < documents.length && result.length < ${owners.size}; i++) {
for (const owner of documents[i].querySelectorAll('iframe,frame')) {
if (result.length >= ${owners.size}) break;
if (!owner.isConnected) continue;
const style = getComputedStyle(owner);
result.push([owner, JSON.stringify({
width: owner.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
height: owner.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
})]);
try { if (owner.contentDocument) documents.push(owner.contentDocument); } catch {}
}
}
return result;
})()`,
objectGroup,
serializationOptions: {
serialization: "deep",
maxDepth: 3,
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
},
},
);
const result = reply.result?.deepSerializedValue;
if (result?.type !== "array" || !Array.isArray(result.value)) return sizes;
for (const entry of result.value as SerializedValue[]) {
if (entry.type !== "array" || !Array.isArray(entry.value)) continue;
const [node, data] = entry.value as SerializedValue[];
if (node?.type !== "node" || data?.type !== "string" || typeof data.value !== "string")
continue;
const id = (node.value as { backendNodeId?: number } | undefined)?.backendNodeId;
if (id === undefined || !owners.has(id)) continue;
try {
const size = JSON.parse(data.value) as Size;
if (
size &&
Number.isFinite(size.width) &&
Number.isFinite(size.height) &&
size.width > 0 &&
size.height > 0
)
sizes.set(id, size);
} catch {
/* An invalid entry uses the existing individual read. */
}
}
} finally {
await send("Runtime.releaseObjectGroup", { objectGroup }, true).catch(() => {});
}
return sizes;
}
+1 -1
View File
@@ -169,7 +169,7 @@ export async function resolveBackendNode(
};
}
if (hasRef) {
const resolved = resolveSnapshotRef(ctx, params.ref as string, target.tabId, "interact");
const resolved = resolveSnapshotRef(ctx, params.ref as string, target.tabId);
if (isRpcError(resolved)) return resolved;
return {
backendNodeId: resolved.backendNodeId,
+55 -136
View File
@@ -8,7 +8,6 @@ import {
applyVomInteractionRecovery,
type CondSurface,
isVomReferenceNode,
type Rect,
renderVom,
type VomNode,
type VomOptions,
@@ -35,6 +34,8 @@ import type {
import { attachDialogs, markDialogCursor } from "./dialogs";
import { rpcError } from "./errors";
import { resolveNodeGeometry } from "./frame-geometry";
import { screenshotPageRect } from "./geometry/coordinate-types";
import { cssViewport, GeometryContext } from "./geometry/frame-context";
import {
type ChromeTabsApi,
enforceToolTargetScope,
@@ -48,13 +49,6 @@ import {
type ToolEffect,
} from "./shared";
import { resolveSnapshotRef } from "./snapshot-ref";
import { topLevelScreenshotRasterScale } from "./viewport-metrics";
import {
correctedVisualScreenshotScale,
planVisualScreenshot,
type ScreenshotPixelBudget,
screenshotFitsPixelBudget,
} from "./visual-screenshot-plan";
import {
type CapturedNode,
type CapturedSurfaceProbe,
@@ -70,11 +64,6 @@ import {
type CaptureVomObservationResult,
projectRecordSafeObservation,
} from "./vom/record-safe-observation";
import {
clusterRenderedSurfaces,
discoverRenderedSurfaces,
projectRenderedSurfaces,
} from "./vom/rendered-surfaces";
import {
buildSemanticGraph,
buildSemanticVomScene,
@@ -106,13 +95,6 @@ export const normaliseRef = sharedNormaliseRef;
// screenshot — `tool.screenshot`
// ---------------------------------------------------------------------------
const VISUAL_SURFACE_SCREENSHOT_MAX_EDGE = 2_048;
const VISUAL_SURFACE_SCREENSHOT_MAX_PIXELS = 4_000_000;
const VISUAL_SURFACE_SCREENSHOT_BUDGET: ScreenshotPixelBudget = {
maxEdge: VISUAL_SURFACE_SCREENSHOT_MAX_EDGE,
maxPixels: VISUAL_SURFACE_SCREENSHOT_MAX_PIXELS,
};
/**
* Strip the `data:image/...;base64,` prefix from a Chrome
* `captureVisibleTab` dataURL and return the raw base64 payload.
@@ -127,8 +109,8 @@ export function stripDataUrlPrefix(dataUrl: string): string {
/**
* Parse a PNG's IHDR chunk and return `(width, height)`. Returns
* `null` on malformed input so callers can reject dimensions that are
* not authoritative instead of guessing from a CSS-space rectangle.
* `null` on any malformed input so callers fall back to `0/0` instead
* of throwing.
*
* PNG layout: 8-byte signature, then a 4-byte length, 4-byte type
* ("IHDR"), then the chunk data — width is bytes 16-19 BE, height is
@@ -140,9 +122,9 @@ export function parsePngDimensions(base64: string): { width: number; height: num
const head = base64.length > 64 ? base64.slice(0, 64) : base64;
const bin = atob(head);
if (bin.length < 24) return null;
const expectedSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (expectedSignature.some((byte, index) => bin.charCodeAt(index) !== byte)) return null;
if (bin.slice(12, 16) !== "IHDR") return null;
if (bin.charCodeAt(0) !== 0x89 || bin.charCodeAt(1) !== 0x50 || bin.charCodeAt(2) !== 0x4e) {
return null;
}
const u32 = (off: number) =>
(bin.charCodeAt(off) << 24) |
(bin.charCodeAt(off + 1) << 16) |
@@ -205,8 +187,6 @@ async function captureElementScreenshot(
target: CdpTarget,
backendNodeId: number,
frameId?: string,
visualSurface = false,
observedVisibleRect?: Rect,
signal?: AbortSignal,
): Promise<{ image_base64: string; width: number; height: number } | RpcError> {
if (signal?.aborted) return cancelled("screenshot");
@@ -214,74 +194,32 @@ async function captureElementScreenshot(
cdp,
tabId,
{ target, backendNodeId, ...(frameId ? { frameId } : {}) },
{ scrollIntoView: !visualSurface },
{ scrollIntoView: true },
);
if (isRpcError(geometry)) return geometry;
if (signal?.aborted) return cancelled("screenshot");
const liveRect = geometry.topBounds;
const rect =
visualSurface && observedVisibleRect
? (() => {
const x = Math.max(liveRect.x, observedVisibleRect.x);
const y = Math.max(liveRect.y, observedVisibleRect.y);
const right = Math.min(
liveRect.x + liveRect.width,
observedVisibleRect.x + observedVisibleRect.w,
);
const bottom = Math.min(
liveRect.y + liveRect.height,
observedVisibleRect.y + observedVisibleRect.h,
);
return right > x && bottom > y ? { x, y, width: right - x, height: bottom - y } : null;
})()
: liveRect;
if (!rect) return { code: "permission_denied", message: "surface is no longer visible" };
const rasterScaleEstimate = visualSurface ? await topLevelScreenshotRasterScale(cdp, tabId) : 1;
let scale = visualSurface
? planVisualScreenshot({
cssRect: rect,
rasterScaleEstimate,
budget: VISUAL_SURFACE_SCREENSHOT_BUDGET,
}).clipScale
: 1;
const rect = geometry.topBounds;
const clip = screenshotPageRect(rect, geometry.topViewport);
if (!clip) return { code: "cdp_failed", message: "invalid screenshot coordinate space" };
try {
for (let attempt = 0; attempt < (visualSurface ? 2 : 1); attempt += 1) {
const shot = await cdp.send<{ data?: string }>(tabId, "Page.captureScreenshot", {
format: "png",
clip: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
scale,
},
});
if (signal?.aborted) return cancelled("screenshot");
const image_base64 = shot.data ?? "";
if (!image_base64) {
return { code: "cdp_failed", message: "Page.captureScreenshot returned no data" };
}
const dims = parsePngDimensions(image_base64);
if (!dims) {
return { code: "cdp_failed", message: "Page.captureScreenshot returned invalid PNG data" };
}
if (!visualSurface || screenshotFitsPixelBudget(dims, VISUAL_SURFACE_SCREENSHOT_BUDGET)) {
return { image_base64, width: dims.width, height: dims.height };
}
const corrected = correctedVisualScreenshotScale({
previousClipScale: scale,
actualWidth: dims.width,
actualHeight: dims.height,
budget: VISUAL_SURFACE_SCREENSHOT_BUDGET,
});
if (attempt === 1 || corrected === null) break;
scale = corrected;
const shot = await cdp.send<{ data?: string }>(tabId, "Page.captureScreenshot", {
format: "png",
clip: {
...clip.rect,
scale: 1,
},
});
if (signal?.aborted) return cancelled("screenshot");
const image_base64 = shot.data ?? "";
if (!image_base64) {
return { code: "cdp_failed", message: "Page.captureScreenshot returned no data" };
}
return {
code: "cdp_failed",
message: "Page.captureScreenshot exceeded the visual surface pixel budget",
const dims = parsePngDimensions(image_base64) ?? {
width: Math.round(rect.width),
height: Math.round(rect.height),
};
return { image_base64, width: dims.width, height: dims.height };
} catch (err) {
return {
code: "cdp_failed",
@@ -376,14 +314,8 @@ export async function handleScreenshot(
if (!deps.cdp) {
return { code: "cdp_failed", message: "screenshot ref capture requires CDP" };
}
const node = resolveSnapshotRef(ctx, ref, target.tabId, "screenshot");
const node = resolveSnapshotRef(ctx, ref, target.tabId);
if (isRpcError(node)) return node;
if (node.kind === "surface" && !node.visibleRect) {
return {
code: "permission_denied",
message: `surface ref ${ref} has no observation-time visible bounds`,
};
}
if (signal?.aborted) return cancelled("screenshot");
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
@@ -402,8 +334,6 @@ export async function handleScreenshot(
nodeTarget,
node.backendNodeId,
node.frameId,
node.kind === "surface",
node.visibleRect,
signal,
),
deps.sendToTab,
@@ -435,10 +365,7 @@ export async function handleScreenshot(
if (isRpcError(captured)) return captured;
if (signal?.aborted) return cancelled("screenshot");
const image_base64 = captured;
const dims = parsePngDimensions(image_base64);
if (!dims) {
return { code: "cdp_failed", message: "screenshot capture returned invalid PNG data" };
}
const dims = parsePngDimensions(image_base64) ?? { width: 0, height: 0 };
return withShotDialogs({
image_base64,
width: dims.width,
@@ -1058,26 +985,19 @@ function emptyCapturedViewModel(viewport = { width: 0, height: 0 }): CapturedVie
return { viewport, nodes: [], iframeNodes: new Map(), excludedBackendNodeIds: new Set() };
}
interface LayoutMetricsViewportReply {
cssLayoutViewport?: { clientWidth?: number; clientHeight?: number };
layoutViewport?: { clientWidth?: number; clientHeight?: number };
}
async function fallbackCapturedViewModel(
cdp: CdpRunner,
tabId: number,
geometry: GeometryContext,
signal?: AbortSignal,
): Promise<CapturedViewModel> {
throwIfAborted(signal, "observation");
let viewport = { width: 0, height: 0 };
try {
const metrics = await cdp.send<LayoutMetricsViewportReply>(tabId, "Page.getLayoutMetrics", {});
const metrics = await geometry.layoutMetrics({ tabId });
throwIfAborted(signal, "observation");
const source = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
viewport = {
width: source.clientWidth ?? 0,
height: source.clientHeight ?? 0,
};
const source = cssViewport(metrics);
viewport = { width: source.width, height: source.height };
} catch (error) {
if (isAbortError(error)) throw error;
}
@@ -1090,12 +1010,13 @@ async function captureForVom(
cdp: CdpRunner,
tabId: number,
options: CaptureVomObservationOptions,
geometry: GeometryContext,
): Promise<CapturedViewModel> {
try {
return await captureViewModel(cdp, tabId, { signal: options.signal });
return await captureViewModel(cdp, tabId, { signal: options.signal, geometry });
} catch (error) {
if (isAbortError(error)) throw error;
return fallbackCapturedViewModel(cdp, tabId, options.signal);
return fallbackCapturedViewModel(cdp, tabId, geometry, options.signal);
}
}
@@ -1157,7 +1078,6 @@ async function runHoverProbes(
export interface CaptureVomObservationOptions extends VomOptions {
conditionalSurfaceProbe?: boolean;
renderedSurfaceDiscovery?: boolean;
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
signal?: AbortSignal;
}
@@ -1171,9 +1091,16 @@ export async function captureVomObservation(
throwIfAborted(options.signal, "observation");
await cdp.ensureAttachedToUrl?.(tabId, url);
throwIfAborted(options.signal, "observation");
const captured = await captureForVom(cdp, tabId, options);
const geometry = new GeometryContext(cdp, tabId, undefined, options.signal);
const captured = await captureForVom(cdp, tabId, options, geometry);
throwIfAborted(options.signal, "observation");
const documents = await captureFrameData<CdpAxNode>(cdp, tabId, captured, options.signal);
const documents = await captureFrameData<CdpAxNode>(
cdp,
tabId,
captured,
options.signal,
geometry,
);
throwIfAborted(options.signal, "observation");
const normalizedDocuments =
documents.length === 1 && captured.iframeNodes.size > 0
@@ -1206,25 +1133,21 @@ export async function captureVomObservation(
captured,
hoverProbes.surfaceProbes,
);
const observedScene = options.renderedSurfaceDiscovery
? projectRenderedSurfaces(
decoratedScene,
normalizedDocuments,
clusterRenderedSurfaces(
discoverRenderedSurfaces(
normalizedDocuments,
captured.viewport,
captured.excludedBackendNodeIds,
),
),
)
: decoratedScene;
const rendered = renderVom(observedScene, {
// Reserve space using the renderer's character-based token estimate. Like VOM
// headers, this integrity notice remains visible even under a tiny token budget.
const geometryNotice = captured.frameGeometryIssues?.length
? "@warning iframe geometry incomplete: some frame content has no top-level coordinates.\n"
: "";
const rendered = renderVom(decoratedScene, {
maxDepth: options.maxDepth,
maxTokens: options.maxTokens,
maxTokens:
!geometryNotice || options.maxTokens === undefined
? options.maxTokens
: Math.max(0, options.maxTokens - Math.ceil(geometryNotice.length / 4)),
redactValues: options.redactValues,
activeRegionPolicy: options.activeRegionPolicy,
});
if (geometryNotice) rendered.text += `\n${geometryNotice.trimEnd()}`;
throwIfAborted(options.signal, "observation");
return projectRecordSafeObservation({
rootFrameId: captured.rootFrameId ?? normalizedDocuments[0]?.frameId ?? "root",
@@ -1274,7 +1197,6 @@ async function handleVomObservation(
maxTokens: params.max_tokens,
activeRegionPolicy: true,
conditionalSurfaceProbe: effectiveConditionalSurfaceProbe,
renderedSurfaceDiscovery: toolName === "observe",
hoverProbeBypassOverlay: deps.hoverProbeBypassOverlay,
signal,
});
@@ -1292,9 +1214,6 @@ async function handleVomObservation(
tabId: target.tabId,
...(ref.frameId ? { frameId: ref.frameId } : {}),
...(refTarget?.sessionId ? { cdpSessionId: refTarget.sessionId } : {}),
...(ref.visibleRect ? { visibleRect: ref.visibleRect } : {}),
...(ref.kind ? { kind: ref.kind } : {}),
...(ref.capabilities ? { capabilities: ref.capabilities } : {}),
},
] as const;
}),
+1 -17
View File
@@ -2,9 +2,8 @@
// compound CDP node identity via the session RefStore, and build stable
// `ref_not_found` errors for hard-failure tool paths.
import type { Rect } from "@browser-skill/vom";
import type { SessionContext } from "@/session-manager/manager";
import { normaliseRef, type RefCapability, type RefTargetKind } from "@/session-manager/ref-store";
import { normaliseRef } from "@/session-manager/ref-store";
import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors";
@@ -13,9 +12,6 @@ export interface SnapshotRefLookup {
refKey: string;
frameId?: string;
cdpSessionId?: string;
visibleRect?: Rect;
kind: RefTargetKind;
capabilities: RefCapability[];
}
function refEntryForTab(ctx: SessionContext, refKey: string, tabId: number) {
@@ -41,9 +37,6 @@ export function lookupSnapshotRef(
refKey,
...(entry.frameId ? { frameId: entry.frameId } : {}),
...(entry.cdpSessionId ? { cdpSessionId: entry.cdpSessionId } : {}),
...(entry.visibleRect ? { visibleRect: entry.visibleRect } : {}),
kind: entry.kind,
capabilities: entry.capabilities,
};
}
@@ -56,7 +49,6 @@ export function resolveSnapshotRef(
ctx: SessionContext,
ref: string,
tabId: number,
requiredCapability?: RefCapability,
): SnapshotRefLookup | RpcError {
const looked = lookupSnapshotRef(ctx, ref, tabId);
if (looked === null) {
@@ -66,13 +58,5 @@ export function resolveSnapshotRef(
`ref ${ref} unknown for tab ${tabId} in session ${ctx.sessionId}`,
);
}
if (requiredCapability && !looked.capabilities.includes(requiredCapability)) {
return rpcError(
"permission_denied",
"ref_capability_denied",
`ref ${ref} does not support ${requiredCapability}`,
{ ref: looked.refKey, required_capability: requiredCapability, kind: looked.kind },
);
}
return looked;
}
@@ -1,18 +0,0 @@
import type { CdpRunner } from "./shared";
/** Estimate the raster/CSS ratio used by top-level Page.captureScreenshot. */
export async function topLevelScreenshotRasterScale(
cdp: CdpRunner,
tabId: number,
): Promise<number> {
try {
const reply = await cdp.send<{ result?: { value?: unknown } }>(tabId, "Runtime.evaluate", {
expression: "window.devicePixelRatio",
returnByValue: true,
});
const value = reply.result?.value;
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 1;
} catch {
return 1;
}
}
@@ -1,52 +0,0 @@
export interface ScreenshotPixelBudget {
maxEdge: number;
maxPixels: number;
}
export interface VisualScreenshotPlan {
clipScale: number;
}
export function planVisualScreenshot(input: {
cssRect: { width: number; height: number };
rasterScaleEstimate: number;
budget: ScreenshotPixelBudget;
}): VisualScreenshotPlan {
const rasterScale =
Number.isFinite(input.rasterScaleEstimate) && input.rasterScaleEstimate > 0
? input.rasterScaleEstimate
: 1;
const { width, height } = input.cssRect;
const scale = Math.min(
1,
input.budget.maxEdge / (width * rasterScale),
input.budget.maxEdge / (height * rasterScale),
Math.sqrt(input.budget.maxPixels / (width * height * rasterScale * rasterScale)),
);
return { clipScale: Math.max(Number.EPSILON, scale) };
}
export function correctedVisualScreenshotScale(input: {
previousClipScale: number;
actualWidth: number;
actualHeight: number;
budget: ScreenshotPixelBudget;
}): number | null {
const correction = Math.min(
input.budget.maxEdge / input.actualWidth,
input.budget.maxEdge / input.actualHeight,
Math.sqrt(input.budget.maxPixels / (input.actualWidth * input.actualHeight)),
);
if (!Number.isFinite(correction) || correction >= 1 || correction <= 0) return null;
return Math.max(Number.EPSILON, input.previousClipScale * correction * 0.999);
}
export function screenshotFitsPixelBudget(
dimensions: { width: number; height: number },
budget: ScreenshotPixelBudget,
): boolean {
return (
Math.max(dimensions.width, dimensions.height) <= budget.maxEdge &&
dimensions.width * dimensions.height <= budget.maxPixels
);
}
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { OVERLAY_HOST_MARKER_ATTR, OVERLAY_HOST_NAME } from "../../../lib/overlay-bridge";
import type { CdpRunner } from "../../shared";
import { captureViewModel, collectOverlayExcludedBackendIds, probeHoverSurfaces } from "../capture";
import { discoverRenderedSurfaces } from "../rendered-surfaces";
// Minimal but format-accurate captureSnapshot reply: a body with one
// fixed full-screen overlay div carrying a password input.
@@ -736,44 +736,6 @@ describe("captureViewModel", () => {
expect(nodes.find((node) => node.backendNodeId === 14)?.rendered).toBe(false);
});
it("propagates fully transparent ancestor suppression to painted descendants", async () => {
const S = ["html", "body", "div", "canvas", "static", "auto", "visible", "1", "0"];
const i = (value: string) => S.indexOf(value);
const snapshot = {
strings: S,
documents: [
{
nodes: {
parentIndex: [-1, 0, 1, 2],
nodeName: [i("html"), i("body"), i("div"), i("canvas")],
backendNodeId: [10, 11, 12, 13],
attributes: [[], [], [], []],
},
layout: {
nodeIndex: [1, 2, 3],
styles: [
[i("static"), i("auto"), i("auto"), i("visible"), i("1")],
[i("static"), i("auto"), i("auto"), i("visible"), i("0")],
[i("static"), i("auto"), i("auto"), i("visible"), i("1")],
],
bounds: [
[0, 0, 1000, 800],
[10, 10, 200, 100],
[10, 10, 200, 100],
],
paintOrders: [0, 1, 2],
},
},
],
};
const { nodes } = await captureViewModel(makeCdp(snapshot), 4);
const canvas = nodes.find((node) => node.backendNodeId === 13);
expect(canvas?.rendered).toBe(true);
expect(canvas?.visuallySuppressed).toBe(true);
});
it("subtracts scroll offset to make rects viewport-relative", async () => {
const cdp = {
send: vi.fn(async (_t: number, method: string) => {
@@ -793,10 +755,8 @@ describe("captureViewModel", () => {
expect(div?.rect).toMatchObject({ y: 0, h: 600 });
});
it.each([
1, 2,
])("keeps CSS-space bounds stable at devicePixelRatio=%i", async (deviceScaleFactor) => {
const S = ["html", "body", "canvas", "position", "fixed", "static", "pointer-events", "auto"];
it("keeps CSS snapshot bounds independent of legacy metrics ratio", async () => {
const S = ["html", "body", "div", "position", "fixed", "static", "pointer-events", "auto"];
const i = (s: string) => S.indexOf(s);
const snapshot = {
strings: S,
@@ -804,7 +764,7 @@ describe("captureViewModel", () => {
{
nodes: {
parentIndex: [-1, 0, 1],
nodeName: [i("html"), i("body"), i("canvas")],
nodeName: [i("html"), i("body"), i("div")],
backendNodeId: [10, 11, 12],
attributes: [[], [], []],
},
@@ -815,8 +775,8 @@ describe("captureViewModel", () => {
[i("fixed"), i("auto")],
],
bounds: [
[0, 0, 1000 * deviceScaleFactor, 1600 * deviceScaleFactor],
[0, 0, 1000 * deviceScaleFactor, 800 * deviceScaleFactor],
[0, 0, 2000, 3200],
[0, 0, 2000, 1600],
],
paintOrders: [0, 50],
},
@@ -830,24 +790,25 @@ describe("captureViewModel", () => {
if (method === "Page.getLayoutMetrics") {
return {
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
layoutViewport: {
clientWidth: 1000 * deviceScaleFactor,
clientHeight: 800 * deviceScaleFactor,
},
layoutViewport: { clientWidth: 2000, clientHeight: 1600 },
};
}
throw new Error(method);
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
};
const { nodes } = await captureViewModel(cdp, 4);
const canvas = nodes.find((node) => node.backendNodeId === 12);
expect(canvas?.tag).toBe("canvas");
expect(canvas?.rect).toEqual({
expect(nodes.find((n) => n.backendNodeId === 12)?.rect).toEqual({
x: 0,
y: 0,
w: 1000,
h: 800,
});
expect(nodes.find((n) => n.backendNodeId === 12)?.localRect).toEqual({
x: 0,
y: 0,
w: 2000,
h: 1600,
});
});
it("extracts textContent from #text child nodes via nodeValue", async () => {
@@ -996,6 +957,12 @@ describe("captureViewModel", () => {
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
};
}
if (method === "DOM.getBoxModel")
return { model: { content: [100, 300, 900, 300, 900, 700, 100, 700] } };
if (method === "DOM.resolveNode") return { object: { objectId: "owner" } };
if (method === "Runtime.callFunctionOn")
return { result: { value: { width: 800, height: 400 } } };
if (method === "Runtime.releaseObject") return {};
throw new Error(method);
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
};
@@ -1018,14 +985,14 @@ describe("captureViewModel", () => {
expect(input?.attrs.type).toBe("text");
expect(input?.ownerFrameBackendNodeId).toBe(13);
expect(input?.localRect).toEqual({ x: 0, y: 0, w: 200, h: 40 });
expect(input?.rect).toBeNull();
expect(input?.rect).toEqual({ x: 100, y: 300, w: 200, h: 40 });
expect(rootFrameId).toBe("main-frame");
expect(frameNodes?.get("child-frame")).toBe(subNodes);
expect(frameOwnerBackendNodeIds?.get("child-frame")).toBe(13);
expect(frameParentIds?.get("child-frame")).toBe("main-frame");
});
it("captures iframe documents without requiring owner-frame geometry", async () => {
it("projects iframe documents independently of owner snapshot geometry", async () => {
const S = [
"html",
"body",
@@ -1086,6 +1053,12 @@ describe("captureViewModel", () => {
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
};
}
if (method === "DOM.getBoxModel")
return { model: { content: [100, 300, 900, 300, 900, 700, 100, 700] } };
if (method === "DOM.resolveNode") return { object: { objectId: "owner" } };
if (method === "Runtime.callFunctionOn")
return { result: { value: { width: 800, height: 400 } } };
if (method === "Runtime.releaseObject") return {};
throw new Error(method);
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
};
@@ -1094,7 +1067,7 @@ describe("captureViewModel", () => {
const input = iframeNodes.get(13)?.find((n) => n.backendNodeId === 101);
expect(input?.localRect).toEqual({ x: 20, y: 20, w: 120, h: 60 });
expect(input?.rect).toBeNull();
expect(input?.rect).toEqual({ x: 120, y: 320, w: 120, h: 60 });
});
it("recursively normalizes nested iframe sub-documents", async () => {
@@ -1173,16 +1146,43 @@ describe("captureViewModel", () => {
],
};
const cdp = {
send: vi.fn(async (_t: number, method: string) => {
if (method === "DOMSnapshot.enable") return {};
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
if (method === "Page.getLayoutMetrics") {
return {
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
};
}
throw new Error(method);
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
send: vi.fn(
async (
_t: number,
method: string,
params?: { backendNodeId?: number; objectId?: string },
) => {
if (method === "DOMSnapshot.enable") return {};
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
if (method === "Page.getLayoutMetrics") {
return {
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
};
}
if (method === "DOM.getBoxModel")
return {
model: {
content:
params?.backendNodeId === 13
? [10, 20, 310, 20, 310, 220, 10, 220]
: [15, 26, 115, 26, 115, 106, 15, 106],
},
};
if (method === "DOM.resolveNode")
return { object: { objectId: String(params?.backendNodeId) } };
if (method === "Runtime.callFunctionOn")
return {
result: {
value:
params?.objectId === "13"
? { width: 300, height: 200 }
: { width: 100, height: 80 },
},
};
if (method === "Runtime.releaseObject") return {};
throw new Error(method);
},
) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
};
const { iframeNodes } = await captureViewModel(cdp, 4);
@@ -1190,98 +1190,13 @@ describe("captureViewModel", () => {
const input = iframeNodes.get(23)?.find((n) => n.backendNodeId === 31);
expect(nestedIframe?.localRect).toEqual({ x: 5, y: 6, w: 100, h: 80 });
expect(nestedIframe?.rect).toBeNull();
expect(nestedIframe?.rect).toEqual({ x: 15, y: 26, w: 100, h: 80 });
expect(input?.ownerFrameBackendNodeId).toBe(23);
expect(input?.localRect).toEqual({ x: 1, y: 2, w: 40, h: 20 });
expect(input?.rect).toBeNull();
expect(input?.rect).toEqual({ x: 16, y: 28, w: 40, h: 20 });
});
it("keeps same-origin iframe canvas frame-local until frame geometry is resolved", async () => {
const captureAtDpr = async (dpr: number) => {
const S = ["html", "body", "iframe", "canvas", "static", "auto"];
const i = (s: string) => S.indexOf(s);
const scale = (values: number[]) => values.map((value) => value * dpr);
const snapshot = {
strings: S,
documents: [
{
frameId: "main",
nodes: {
parentIndex: [-1, 0, 1],
nodeName: [i("html"), i("body"), i("iframe")],
backendNodeId: [10, 11, 13],
attributes: [[], [], []],
contentDocumentIndex: { index: [2], value: [1] },
},
layout: {
nodeIndex: [1, 2],
styles: [
[i("static"), i("auto")],
[i("static"), i("auto")],
],
bounds: [scale([0, 0, 1000, 800]), scale([100, 150, 300, 200])],
paintOrders: [0, 1],
},
},
{
frameId: "child",
nodes: {
parentIndex: [-1, 0],
nodeName: [i("body"), i("canvas")],
backendNodeId: [20, 21],
attributes: [[], []],
},
layout: {
nodeIndex: [0, 1],
styles: [
[i("static"), i("auto")],
[i("static"), i("auto")],
],
bounds: [scale([0, 0, 300, 200]), scale([20, 30, 120, 60])],
paintOrders: [0, 1],
},
},
],
};
const cdp = {
send: vi.fn(async (_tabId: number, method: string) => {
if (method === "DOMSnapshot.enable") return {};
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
if (method === "Page.getLayoutMetrics") {
return {
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
layoutViewport: { clientWidth: 1000 * dpr, clientHeight: 800 * dpr },
};
}
throw new Error(method);
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
};
const captured = await captureViewModel(cdp, 4);
const childNodes = captured.frameNodes?.get("child") ?? [];
return discoverRenderedSurfaces(
[
{
frameId: "child",
contextScopeId: "child",
parentFrameId: "main",
ownerBackendNodeId: 13,
target: { tabId: 4 },
domNodes: childNodes,
axNodes: [],
},
],
captured.viewport,
);
};
const atDpr1 = await captureAtDpr(1);
const atDpr2 = await captureAtDpr(2);
expect(atDpr1).toEqual([]);
expect(atDpr2).toEqual(atDpr1);
});
it("normalizes bounds then subtracts CSS scroll at dpr>1", async () => {
it("subtracts CSS scroll once without dividing snapshot bounds by a metrics ratio", async () => {
const S = ["html", "body", "div", "position", "fixed", "static", "pointer-events", "auto"];
const i = (s: string) => S.indexOf(s);
const snapshot = {
@@ -1323,7 +1238,7 @@ describe("captureViewModel", () => {
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
};
const { nodes } = await captureViewModel(cdp, 4);
expect(nodes.find((n) => n.backendNodeId === 12)?.rect?.y).toBe(400 / 2 - 100);
expect(nodes.find((n) => n.backendNodeId === 12)?.rect?.y).toBe(400 - 100);
});
it("collectOverlayExcludedBackendIds walks the pierced overlay host subtree", async () => {
@@ -1348,3 +1263,194 @@ describe("captureViewModel", () => {
expect(excluded).toEqual(new Set([200, 201, 202]));
});
});
// Six sibling frames and one nested frame exercise scheduling through the real capture entry.
function siblingCaptureFixture(
beforeReply: (method: string, params: Record<string, unknown>) => Promise<void> = async () => {},
) {
const document = (id: number, owners: number[], childIndexes: number[]) => ({
frameId: `frame-${id}`,
nodes: {
parentIndex: [-1, ...owners.map(() => 0)],
nodeName: [0, ...owners.map(() => 1)],
backendNodeId: [1000 + id, ...owners],
attributes: [[], ...owners.map(() => [])],
contentDocumentIndex: { index: owners.map((_, i) => i + 1), value: childIndexes },
},
layout: {
nodeIndex: [0, ...owners.map((_, i) => i + 1)],
bounds: [[0, 0, 200, 100], ...owners.map(() => [0, 0, 200, 100])],
},
});
const snapshot = {
strings: ["body", "iframe"],
documents: [
document(0, [100, 101, 102, 103, 104, 105], [1, 2, 3, 4, 5, 6]),
document(1, [200], [7]),
...Array.from({ length: 6 }, (_, i) => document(i + 2, [], [])),
],
};
let active = 0;
let peak = 0;
const send = vi.fn(async (_tabId: number, method: string, params: object = {}) => {
const args = params as Record<string, unknown>;
active++;
peak = Math.max(peak, active);
try {
await beforeReply(method, args);
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
if (method === "Page.getLayoutMetrics")
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
if (method === "DOM.getBoxModel")
return { model: { content: [0, 0, 200, 0, 200, 100, 0, 100] } };
if (method === "DOM.resolveNode") return { object: { objectId: String(args.backendNodeId) } };
if (method === "Runtime.callFunctionOn")
return { result: { value: { width: 200, height: 100 } } };
return {};
} finally {
active--;
}
});
return {
cdp: { send: send as CdpRunner["send"] },
send,
peak: () => peak,
active: () => active,
measured: () =>
send.mock.calls
.filter(([, method]) => method === "DOM.getBoxModel")
.map(([, , params]) => (params as { backendNodeId: number }).backendNodeId),
};
}
describe("sibling frame measurement scheduling", () => {
it("batches same-target owner sizes without changing projected results", async () => {
const fixture = siblingCaptureFixture();
const original = fixture.cdp.send;
const methods: string[] = [];
fixture.cdp.send = async (tabId, method, params) => {
methods.push(method);
if (method === "Runtime.evaluate")
return {
result: {
deepSerializedValue: {
type: "array",
value: [100, 101, 102, 103, 104, 105, 200].map((backendNodeId) => ({
type: "array",
value: [
{ type: "node", value: { backendNodeId } },
{ type: "string", value: JSON.stringify({ width: 200, height: 100 }) },
],
})),
},
},
} as never;
return original(tabId, method, params);
};
const captured = await captureViewModel(fixture.cdp, 4);
expect(captured.frameGeometryIssues).toEqual([]);
expect([...captured.iframeNodes.keys()]).toEqual([100, 200, 101, 102, 103, 104, 105]);
expect(captured.iframeNodes.get(200)?.[0].rect).toEqual({ x: 0, y: 0, w: 200, h: 100 });
expect(methods.filter((method) => method === "DOM.getBoxModel")).toHaveLength(7);
expect(methods.filter((method) => method === "Runtime.evaluate")).toHaveLength(1);
expect(methods.filter((method) => method === "Runtime.releaseObjectGroup")).toHaveLength(1);
expect(methods).not.toContain("DOM.resolveNode");
expect(methods).not.toContain("Runtime.callFunctionOn");
});
it("bounds concurrent reads, fills free slots and preserves depth-first output despite reordered replies", async () => {
const pending = new Map<number, () => void>();
const fixture = siblingCaptureFixture(async (method, params) => {
if (method === "DOM.getBoxModel" && Number(params.backendNodeId) < 200)
await new Promise<void>((resolve) => pending.set(Number(params.backendNodeId), resolve));
});
const capture = captureViewModel(fixture.cdp, 4);
await vi.waitFor(() => expect(pending.size).toBe(4));
expect(fixture.measured()).toEqual([100, 101, 102, 103]);
pending.get(103)!();
await vi.waitFor(() => expect(pending.has(104)).toBe(true));
pending.get(104)!();
await vi.waitFor(() => expect(pending.has(105)).toBe(true));
expect(fixture.measured()).not.toContain(200);
for (const id of [105, 102, 101, 100]) pending.get(id)!();
const captured = await capture;
expect(captured.frameGeometryIssues).toEqual([]);
expect(fixture.peak()).toBe(4);
expect(fixture.active()).toBe(0);
expect(fixture.measured()).toEqual([100, 101, 102, 103, 104, 105, 200]);
expect([...captured.iframeNodes.keys()]).toEqual([100, 200, 101, 102, 103, 104, 105]);
expect(captured.iframeNodes.get(200)?.[0].rect).toEqual({ x: 0, y: 0, w: 200, h: 100 });
for (const method of [
"DOM.getBoxModel",
"DOM.resolveNode",
"Runtime.callFunctionOn",
"Runtime.releaseObject",
])
expect(fixture.send.mock.calls.filter(([, name]) => name === method)).toHaveLength(7);
});
it("does not measure descendants of a failed owner and retains sibling geometry and local nodes", async () => {
const fixture = siblingCaptureFixture(async (method, params) => {
if (method === "DOM.resolveNode" && params.backendNodeId === 100)
throw new Error("owner replaced");
});
const captured = await captureViewModel(fixture.cdp, 4);
expect(captured.frameGeometryIssues).toEqual([
{
status: "unavailable",
source: { target: { tabId: 4 }, frameId: "frame-1" },
ownerBackendNodeId: 100,
},
{
status: "blocked",
source: { target: { tabId: 4 }, frameId: "frame-7" },
cause: captured.frameGeometryIssues?.[0],
},
]);
expect(fixture.measured()).not.toContain(200);
expect(captured.iframeNodes.get(200)?.[0]).toMatchObject({
tag: "body",
rect: null,
localRect: { x: 0, y: 0, w: 200, h: 100 },
});
expect(captured.iframeNodes.get(101)?.[0].rect).toEqual({ x: 0, y: 0, w: 200, h: 100 });
expect([...captured.iframeNodes.keys()]).toEqual([100, 200, 101, 102, 103, 104, 105]);
});
it("stops scheduling on cancellation and waits for resolved objects to be released", async () => {
const controller = new AbortController();
const resolutions = new Map<string, () => void>();
const releases = new Map<string, () => void>();
const fixture = siblingCaptureFixture(async (method, params) => {
if (method === "DOM.resolveNode")
await new Promise<void>((resolve) =>
resolutions.set(String(params.backendNodeId), resolve),
);
if (method === "Runtime.releaseObject")
await new Promise<void>((resolve) => releases.set(String(params.objectId), resolve));
});
let settled = false;
const capture = captureViewModel(fixture.cdp, 4, { signal: controller.signal });
const rejected = expect(capture).rejects.toMatchObject({ name: "AbortError" });
void capture.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await vi.waitFor(() => expect(resolutions.size).toBe(4));
controller.abort();
for (const resolve of resolutions.values()) resolve();
await vi.waitFor(() => expect(releases.size).toBe(4));
expect(settled).toBe(false);
expect(fixture.measured()).toEqual([100, 101, 102, 103]);
for (const release of releases.values()) release();
await rejected;
expect(fixture.active()).toBe(0);
expect(fixture.send.mock.calls.some(([, method]) => method === "Runtime.callFunctionOn")).toBe(
false,
);
});
});
@@ -50,80 +50,41 @@ function childSnapshot(frameId: string, backendNodeId: number) {
}
describe("captureFrameData", () => {
it("does not request frame geometry for a root-only page", async () => {
const root = { ...ownerNode(1, 0), tag: "body" };
const captured: CapturedViewModel = {
nodes: [root],
viewport: { width: 1000, height: 800 },
iframeNodes: new Map(),
frameNodes: new Map([["main", [root]]]),
rootFrameId: "main",
excludedBackendNodeIds: new Set(),
};
const send = vi.fn(async (_tabId, method) => {
if (method === "Accessibility.enable") return {};
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
throw new Error(`unexpected ${method}`);
});
const cdp: CdpRunner = {
send: send as CdpRunner["send"],
getFrameGraph: vi.fn(async () => ({
rootFrameId: "main",
frames: [{ frameId: "main", target: { tabId: 4 } }],
})),
};
await captureFrameData(cdp, 4, captured);
expect(send.mock.calls.some(([, method]) => method === "Page.createIsolatedWorld")).toBe(false);
expect(send.mock.calls.some(([, method]) => method === "DOM.getBoxModel")).toBe(false);
expect(send.mock.calls.some(([, method]) => method === "Page.getLayoutMetrics")).toBe(false);
});
it("projects same-origin frame-local rects through the iframe content box", async () => {
it("retains nested owner failures when merging a captured OOPIF", async () => {
const owner = ownerNode(10, 50);
const canvas: CapturedNode = {
backendNodeId: 101,
parentBackendNodeId: 100,
frameId: "child",
ownerFrameBackendNodeId: 10,
tag: "canvas",
attrs: {},
rect: null,
localRect: { x: 12, y: 12, w: 224, h: 94 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
};
const captured: CapturedViewModel = {
nodes: [owner],
viewport: { width: 1000, height: 800 },
iframeNodes: new Map([[10, [canvas]]]),
frameNodes: new Map([
["main", [owner]],
["child", [canvas]],
]),
frameOwnerBackendNodeIds: new Map([["child", 10]]),
frameParentIds: new Map([["child", "main"]]),
iframeNodes: new Map(),
frameNodes: new Map([["main", [owner]]]),
rootFrameId: "main",
excludedBackendNodeIds: new Set(),
};
const child = childSnapshot("child", 101);
const nested = childSnapshot("nested", 201);
const document = child.documents[0];
const snapshot = {
strings: child.strings,
documents: [
{
...document,
nodes: { ...document.nodes, contentDocumentIndex: { index: [1], value: [1] } },
},
nested.documents[0],
],
};
const reply = async (_target: unknown, method: string) => {
if (method === "Page.getLayoutMetrics")
return { cssLayoutViewport: { clientWidth: 300, clientHeight: 200 } };
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
if (method === "DOM.getBoxModel")
return { model: { content: [50, 100, 350, 100, 350, 300, 50, 300] } };
if (method === "DOM.resolveNode") throw new Error("nested owner replaced");
return {};
};
const cdp: CdpRunner = {
send: vi.fn(async (_tabId, method) => {
if (method === "Accessibility.enable") return {};
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
}
if (method === "DOM.getBoxModel") {
return { model: { content: [52, 102, 352, 102, 352, 302, 52, 302] } };
}
if (method === "Page.createIsolatedWorld") return { executionContextId: 91 };
if (method === "Runtime.evaluate") {
return { result: { value: { width: 300, height: 200 } } };
}
throw new Error(`unexpected ${method}`);
}) as CdpRunner["send"],
send: vi.fn(reply) as CdpRunner["send"],
sendToTarget: vi.fn(reply) as NonNullable<CdpRunner["sendToTarget"]>,
getFrameGraph: vi.fn(async () => ({
rootFrameId: "main",
frames: [
@@ -132,21 +93,23 @@ describe("captureFrameData", () => {
frameId: "child",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 4 },
target: { tabId: 4, sessionId: "child-session" },
},
],
})),
};
await captureFrameData(cdp, 4, captured);
expect(captured.frameNodes?.get("child")?.[0]?.rect).toEqual({
x: 64,
y: 114,
w: 224,
h: 94,
expect(captured.frameGeometryIssues).toEqual([
{
status: "unavailable",
source: { target: { tabId: 4, sessionId: "child-session" }, frameId: "nested" },
ownerBackendNodeId: 101,
},
]);
expect(captured.frameNodes?.get("nested")?.[1]).toMatchObject({
rect: null,
localRect: { x: 10, y: 20, w: 100, h: 40 },
});
expect(captured.iframeNodes.get(10)?.[0]?.rect).toEqual({ x: 64, y: 114, w: 224, h: 94 });
});
it("captures and positions multiple OOPIF documents missing from the root snapshot", async () => {
@@ -232,87 +195,6 @@ describe("captureFrameData", () => {
);
});
it("bounds concurrent same-target frame geometry resolution", async () => {
const owners = Array.from({ length: 8 }, (_, index) => ownerNode(10 + index, index * 40));
const childFrames = owners.map((owner, index) => {
const frameId = `child-${index}`;
const node: CapturedNode = {
backendNodeId: 100 + index,
parentBackendNodeId: null,
frameId,
ownerFrameBackendNodeId: owner.backendNodeId,
tag: "canvas",
attrs: {},
rect: null,
localRect: { x: 1, y: 2, w: 20, h: 10 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
};
return { frameId, owner, node };
});
const captured: CapturedViewModel = {
nodes: owners,
viewport: { width: 1000, height: 800 },
iframeNodes: new Map(childFrames.map(({ owner, node }) => [owner.backendNodeId, [node]])),
frameNodes: new Map([
["main", owners],
...childFrames.map(({ frameId, node }): [string, CapturedNode[]] => [frameId, [node]]),
]),
frameOwnerBackendNodeIds: new Map(
childFrames.map(({ frameId, owner }) => [frameId, owner.backendNodeId]),
),
rootFrameId: "main",
excludedBackendNodeIds: new Set(),
};
let activeWorlds = 0;
let maxActiveWorlds = 0;
const cdp: CdpRunner = {
send: vi.fn(async (_tabId, method, params) => {
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
}
if (method === "DOM.getBoxModel") {
const backendNodeId = (params as { backendNodeId?: number }).backendNodeId ?? 10;
const x = (backendNodeId - 10) * 40;
return { model: { content: [x, 100, x + 30, 100, x + 30, 120, x, 120] } };
}
if (method === "Page.createIsolatedWorld") {
activeWorlds += 1;
maxActiveWorlds = Math.max(maxActiveWorlds, activeWorlds);
await new Promise((resolve) => setTimeout(resolve, 5));
activeWorlds -= 1;
return { executionContextId: 90 };
}
if (method === "Runtime.evaluate") {
return { result: { value: { width: 30, height: 20 } } };
}
if (method === "Accessibility.enable") return {};
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
throw new Error(`unexpected ${method}`);
}) as CdpRunner["send"],
getFrameGraph: vi.fn(async () => ({
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
...childFrames.map(({ frameId, owner }) => ({
frameId,
parentFrameId: "main",
ownerBackendNodeId: owner.backendNodeId,
target: { tabId: 4 },
})),
],
})),
};
await captureFrameData(cdp, 4, captured);
expect(maxActiveWorlds).toBe(4);
expect(childFrames.every(({ frameId }) => captured.frameNodes?.get(frameId)?.[0]?.rect)).toBe(
true,
);
});
it("keeps OOPIF semantics when viewport projection is unavailable", async () => {
const owner = { ...ownerNode(10, 50), rect: null, localRect: null };
const captured: CapturedViewModel = {
@@ -1,386 +0,0 @@
import type { VomScene } from "@browser-skill/vom";
import { describe, expect, it } from "vitest";
import type { CapturedNode } from "../capture";
import type { CapturedFrameDocument } from "../frame-capture";
import {
clusterRenderedSurfaces,
discoverRenderedSurfaces,
projectRenderedSurfaces,
} from "../rendered-surfaces";
import type { SemanticAxNode } from "../semantic-graph";
function domNode(backendNodeId: number, overrides: Partial<CapturedNode> = {}): CapturedNode {
return {
backendNodeId,
parentBackendNodeId: null,
frameId: "main",
ownerFrameBackendNodeId: null,
tag: "canvas",
attrs: {},
rect: { x: 10, y: 20, w: 800, h: 500 },
localRect: { x: 10, y: 20, w: 800, h: 500 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
rendered: true,
...overrides,
};
}
function document(
domNodes: CapturedNode[],
axNodes: SemanticAxNode[] = [],
): CapturedFrameDocument<SemanticAxNode> {
return {
frameId: domNodes[0]?.frameId ?? "main",
contextScopeId: domNodes[0]?.frameId ?? "main",
target: { tabId: 4 },
domNodes,
axNodes,
};
}
describe("rendered surface discovery", () => {
it("finds prominent rendered canvases and excludes hidden descendants", () => {
const visible = domNode(2, { parentBackendNodeId: 1 });
const hidden = domNode(3, { parentBackendNodeId: 4 });
const surfaces = discoverRenderedSurfaces(
[
document([
domNode(1, { tag: "main", rect: null, localRect: null }),
visible,
domNode(4, { tag: "section", attrs: { "aria-hidden": "true" }, rect: null }),
hidden,
]),
],
{ width: 1280, height: 720 },
);
expect(surfaces).toHaveLength(1);
expect(surfaces[0]).toMatchObject({ backendNodeId: 2 });
});
it("keeps tiny canvases as individually addressable exact discoveries", () => {
const surfaces = discoverRenderedSurfaces(
[document([domNode(2, { rect: { x: 1, y: 1, w: 8, h: 8 } })])],
{ width: 1280, height: 720 },
);
expect(surfaces).toEqual([expect.objectContaining({ backendNodeId: 2 })]);
});
it("keeps a surface when the canvas only has partial native accessibility content", () => {
const axNodes = [
{
nodeId: "canvas",
backendDOMNodeId: 2,
role: { type: "role", value: "presentation" },
childIds: ["button"],
frameId: "main",
},
{
nodeId: "button",
backendDOMNodeId: 3,
role: { type: "role", value: "button" },
frameId: "main",
},
] as SemanticAxNode[];
expect(
discoverRenderedSurfaces([document([domNode(2)], axNodes)], { width: 1280, height: 720 }),
).toEqual([expect.objectContaining({ backendNodeId: 2 })]);
});
it("discovers unnamed canvases without guessing labels from engineering attributes", () => {
const canvas = domNode(2, {
attrs: {
id: "must-not-be-a-label",
class: "must-not-be-a-label",
"data-testid": "must-not-be-a-label",
},
});
expect(discoverRenderedSurfaces([document([canvas])], { width: 1280, height: 720 })).toEqual([
expect.not.objectContaining({
label: expect.anything(),
}),
]);
});
it("treats blank explicit Canvas names as unnamed", () => {
expect(
discoverRenderedSurfaces(
[document([domNode(2, { attrs: { "aria-label": " ", title: "\n\t" } })])],
{ width: 1280, height: 720 },
),
).toEqual([expect.not.objectContaining({ label: expect.anything() })]);
});
it("excludes canvases inside BrowserSkill-owned DOM subtrees", () => {
const owner = domNode(1, { tag: "div", rect: null, localRect: null });
const canvas = domNode(2, { parentBackendNodeId: 1 });
expect(
discoverRenderedSurfaces(
[document([owner, canvas])],
{ width: 1280, height: 720 },
new Set([1]),
),
).toEqual([]);
});
it("excludes canvases suppressed by a fully transparent ancestor", () => {
const owner = domNode(1, {
tag: "div",
rect: { x: 0, y: 0, w: 900, h: 600 },
visuallySuppressed: true,
});
const canvas = domNode(2, { parentBackendNodeId: 1 });
expect(
discoverRenderedSurfaces([document([owner, canvas])], { width: 1280, height: 720 }),
).toEqual([]);
});
it("clips canvases to overflow ancestors and drops fully clipped canvases", () => {
const owner = domNode(1, {
tag: "div",
rect: { x: 10, y: 20, w: 100, h: 70 },
overflowX: "hidden",
overflowY: "hidden",
});
const partial = domNode(2, {
parentBackendNodeId: 1,
rect: { x: 55, y: 30, w: 140, h: 60 },
});
const fullyClipped = domNode(3, {
parentBackendNodeId: 1,
rect: { x: 140, y: 30, w: 80, h: 50 },
});
expect(
discoverRenderedSurfaces([document([owner, partial, fullyClipped])], {
width: 1280,
height: 720,
}),
).toEqual([
expect.objectContaining({
backendNodeId: 2,
visibleRect: { x: 55, y: 30, w: 55, h: 60 },
}),
]);
});
});
describe("rendered surface clustering and projection", () => {
it("collapses overlapping canvas layers and attaches them to the nearest retained parent", () => {
const body = domNode(1, { tag: "body", rect: null, localRect: null });
const surfaces = discoverRenderedSurfaces(
[
document([
body,
domNode(2, { parentBackendNodeId: 1, paintOrder: 1 }),
domNode(3, { parentBackendNodeId: 1, paintOrder: 2 }),
]),
],
{ width: 1280, height: 720 },
);
const clustered = clusterRenderedSurfaces(surfaces);
const scene: VomScene = {
viewport: { width: 1280, height: 720 },
nodes: [
{
id: 10,
parentId: null,
backendNodeId: 1,
frameId: "main",
tag: "body",
role: "RootWebArea",
rect: null,
paintOrder: 0,
position: "static",
pointerEvents: "auto",
},
],
};
expect(clustered.groups).toHaveLength(1);
expect(clustered.groups[0]?.memberCount).toBe(2);
expect(clustered.groups[0]?.label).toBeUndefined();
expect(
projectRenderedSurfaces(
scene,
[document([body, ...surfaces.map((s) => domNode(s.backendNodeId))])],
clustered,
),
).toMatchObject({
visualSurfaces: [expect.objectContaining({ parentId: 10, backendNodeId: 3, memberCount: 2 })],
});
});
it("does not merge a contained canvas with a larger independent canvas", () => {
const clustered = clusterRenderedSurfaces([
...discoverRenderedSurfaces(
[
document([
domNode(1, { tag: "main", rect: null, localRect: null }),
domNode(2, { parentBackendNodeId: 1 }),
domNode(3, {
parentBackendNodeId: 1,
rect: { x: 30, y: 40, w: 200, h: 100 },
}),
]),
],
{ width: 1280, height: 720 },
),
]);
expect(clustered.groups).toHaveLength(2);
});
it("keeps the existing inclusive 90 percent stack threshold", () => {
const clustered = clusterRenderedSurfaces([
{
renderingKind: "canvas",
frameId: "main",
backendNodeId: 2,
parentBackendNodeId: 1,
visibleRect: { x: 0, y: 0, w: 100, h: 100 },
paintOrder: 1,
},
{
renderingKind: "canvas",
frameId: "main",
backendNodeId: 3,
parentBackendNodeId: 1,
visibleRect: { x: 5, y: 0, w: 90, h: 100 },
paintOrder: 2,
},
]);
expect(clustered.groups).toEqual([expect.objectContaining({ memberCount: 2 })]);
});
it("does not merge coincident canvases owned by different containers", () => {
const clustered = clusterRenderedSurfaces(
discoverRenderedSurfaces(
[
document([
domNode(1, { tag: "section", rect: null, localRect: null }),
domNode(2, { tag: "section", rect: null, localRect: null }),
domNode(3, { parentBackendNodeId: 1 }),
domNode(4, { parentBackendNodeId: 2 }),
]),
],
{ width: 1280, height: 720 },
),
);
expect(clustered.groups).toHaveLength(2);
});
it("keeps adjacent map tiles and twenty sparklines as independent surfaces", () => {
const surfaces = Array.from({ length: 20 }, (_, index) =>
domNode(100 + index, {
parentBackendNodeId: 1,
rect: { x: index * 12, y: 20, w: 10, h: 8 },
}),
);
const clustered = clusterRenderedSurfaces(
discoverRenderedSurfaces(
[document([domNode(1, { tag: "main", rect: null, localRect: null }), ...surfaces])],
{ width: 1280, height: 720 },
),
);
expect(clustered.groups).toHaveLength(20);
expect(clustered.groups.every((group) => group.memberCount === 1)).toBe(true);
expect(clustered.truncated).toBe(false);
});
it("clusters a large coincident stack without retaining every member", () => {
const surfaces = Array.from({ length: 10_000 }, (_, index) => ({
renderingKind: "canvas" as const,
frameId: "main",
backendNodeId: index + 1,
parentBackendNodeId: 1,
visibleRect: { x: 10, y: 20, w: 800, h: 500 },
paintOrder: index,
}));
const clustered = clusterRenderedSurfaces(surfaces);
expect(clustered.groups).toEqual([
expect.objectContaining({ memberCount: 10_000, representative: surfaces[9_999] }),
]);
expect(clustered.truncated).toBe(false);
});
it("keeps large non-overlapping surface sets linear and independent", () => {
const surfaces = Array.from({ length: 10_000 }, (_, index) => ({
renderingKind: "canvas" as const,
frameId: "main",
backendNodeId: index + 1,
parentBackendNodeId: 1,
visibleRect: { x: index * 20, y: 0, w: 10, h: 10 },
paintOrder: index,
}));
const clustered = clusterRenderedSurfaces(surfaces);
expect(clustered.groups).toHaveLength(10_000);
expect(clustered.groups.every((group) => group.memberCount === 1)).toBe(true);
expect(clustered.truncated).toBe(false);
});
it("keeps the highest-priority surfaces when the computation guard truncates input", () => {
const surfaces = [
{
renderingKind: "canvas" as const,
frameId: "main",
backendNodeId: 1,
parentBackendNodeId: 10,
visibleRect: { x: 0, y: 0, w: 10, h: 10 },
paintOrder: 1,
},
{
renderingKind: "canvas" as const,
frameId: "main",
backendNodeId: 2,
parentBackendNodeId: 10,
visibleRect: { x: 20, y: 0, w: 100, h: 100 },
paintOrder: 1,
},
{
renderingKind: "canvas" as const,
frameId: "main",
backendNodeId: 3,
parentBackendNodeId: 10,
visibleRect: { x: 140, y: 0, w: 8, h: 8 },
paintOrder: 1,
label: "Important status",
},
];
const clustered = clusterRenderedSurfaces(surfaces, { maxSurfaces: 2 });
expect(clustered.groups.map((group) => group.representative.backendNodeId).sort()).toEqual([
2, 3,
]);
expect(clustered).toMatchObject({ truncated: true, omittedCount: 1 });
});
it("stops at the comparison budget without dropping already completed groups", () => {
const surfaces = Array.from({ length: 3 }, (_, index) => ({
renderingKind: "canvas" as const,
frameId: "main",
backendNodeId: index + 1,
parentBackendNodeId: 10,
visibleRect: { x: 0, y: 0, w: 100, h: 100 },
paintOrder: index + 1,
}));
const clustered = clusterRenderedSurfaces(surfaces, { maxComparisons: 1 });
expect(clustered.groups).toEqual([expect.objectContaining({ memberCount: 2 })]);
expect(clustered).toMatchObject({ truncated: true, omittedCount: 1 });
});
});
+147 -97
View File
@@ -5,21 +5,20 @@
// STYLE_COL so capture and visibility policy share one explicit contract.
import type { Rect, Viewport } from "@browser-skill/vom";
import type { CdpTarget } from "@/browser-driver/frame-graph";
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
import { type GeometryProjection, projectRectToViewport } from "../geometry";
import {
type FrameProjectionIssue,
type FrameProjectionState,
projectSnapshotRect,
snapshotViewportRect,
} from "../geometry/coordinate-types";
import { cssViewport, GeometryContext, type LayoutMetrics } from "../geometry/frame-context";
import type { CdpRunner } from "../shared";
import { clearHover, ProbeBudget, waitForHover } from "./hover-perception";
const REQUESTED_STYLES = [
"position",
"pointer-events",
"cursor",
"visibility",
"opacity",
"overflow-x",
"overflow-y",
] as const;
const REQUESTED_STYLES = ["position", "pointer-events", "cursor", "visibility", "opacity"] as const;
const STYLE_COL = Object.fromEntries(
REQUESTED_STYLES.map((name, index) => [name, index]),
) as Record<(typeof REQUESTED_STYLES)[number], number>;
@@ -47,10 +46,6 @@ export interface CapturedNode {
* `textContent`: the live parser always sets it, hand-built fixtures may not.
*/
cursor?: string;
overflowX?: string;
overflowY?: string;
/** True when this node or one of its DOM ancestors makes the subtree fully transparent/hidden. */
visuallySuppressed?: boolean;
/**
* Whether the live DOM snapshot provides a painted, non-hidden box for this
* node. Semantic resolution uses this only for DOM fallback nodes; AX-backed
@@ -83,12 +78,16 @@ export interface CapturedViewModel {
/** Explicit DOMSnapshot frame ancestry; never inferred from backend node ids. */
frameParentIds?: Map<string, string>;
rootFrameId?: string;
/** Failed boundaries and descendants blocked by them; clipping is not a failure. */
frameGeometryIssues?: FrameProjectionIssue[];
/** Backend node ids belonging to the agent overlay host + its shadow subtree. */
excludedBackendNodeIds: Set<number>;
}
export interface CaptureViewModelOptions {
signal?: AbortSignal;
geometry?: GeometryContext;
target?: CdpTarget;
}
function captureAbortError(): Error {
@@ -159,16 +158,6 @@ interface SnapshotReply {
documents?: SnapshotDocument[];
}
interface LayoutMetricsReply {
cssLayoutViewport?: {
clientWidth?: number;
clientHeight?: number;
pageX?: number;
pageY?: number;
};
layoutViewport?: { clientWidth?: number; clientHeight?: number; pageX?: number; pageY?: number };
}
function isFormControlTag(tag: string): boolean {
return tag === "input" || tag === "textarea" || tag === "select";
}
@@ -365,7 +354,8 @@ interface ParseDocumentResult {
interface FrameContext {
frameId?: string;
ownerFrameBackendNodeId: number | null;
projection: GeometryProjection | null;
projection: FrameProjectionState;
target: CdpTarget;
scrollX: number;
scrollY: number;
}
@@ -375,15 +365,6 @@ function str(strings: string[], idx: number | undefined): string {
return strings[idx] ?? "";
}
function devicePixelRatio(metrics: LayoutMetricsReply): number {
const layoutW = metrics.layoutViewport?.clientWidth ?? 0;
const cssW = metrics.cssLayoutViewport?.clientWidth ?? 0;
if (!layoutW || !cssW) return 1;
const dpr = layoutW / cssW;
if (!Number.isFinite(dpr) || dpr <= 0) return 1;
return dpr >= 1 ? dpr : 1;
}
function sparseIndexMap(sparse: SparseArray | undefined): Map<number, number> {
const out = new Map<number, number>();
if (!sparse?.index || !sparse.value) return out;
@@ -809,13 +790,11 @@ export async function collectOverlayExcludedBackendIds(
*
* @param doc - the raw DOMSnapshot document object
* @param strings - the shared string table for the whole snapshot
* @param dpr - device-pixel-ratio from Page.getLayoutMetrics
* @param context - frame coordinate context; output rects are top viewport-relative
*/
function parseDocumentNodes(
doc: SnapshotDocument,
strings: string[],
dpr: number,
context: FrameContext,
): ParseDocumentResult {
const dn = doc.nodes;
@@ -861,8 +840,6 @@ function parseDocumentNodes(
const cursorCol = STYLE_COL.cursor;
const visibilityCol = STYLE_COL.visibility;
const opacityCol = STYLE_COL.opacity;
const overflowXCol = STYLE_COL["overflow-x"];
const overflowYCol = STYLE_COL["overflow-y"];
const inputValues = sparseIndexMap(dn.inputValue);
const textValues = sparseIndexMap(dn.textValue);
const checkedInputs = new Set(dn.inputChecked?.index ?? []);
@@ -888,7 +865,6 @@ function parseDocumentNodes(
// Build CapturedNodes.
const nodes: CapturedNode[] = [];
const visuallySuppressedByNodeIndex = new Array<boolean>(count).fill(false);
for (let n = 0; n < count; n++) {
if (excluded[n]) continue;
const backendNodeId = dn.backendNodeId[n];
@@ -908,24 +884,24 @@ function parseDocumentNodes(
let position = "static";
let pointerEvents = "auto";
let cursor = "auto";
let overflowX = "visible";
let overflowY = "visible";
let rendered = false;
let visuallySuppressed =
parentIdx >= 0 ? visuallySuppressedByNodeIndex[parentIdx] === true : false;
const li = layoutByNode.get(n);
if (li !== undefined) {
const b = dl?.bounds?.[li];
if (b && b.length >= 4 && b[2] > 0 && b[3] > 0) {
localRect = {
x: b[0] / dpr - context.scrollX,
y: b[1] / dpr - context.scrollY,
w: b[2] / dpr,
h: b[3] / dpr,
};
const bounds = context.projection
? projectRectToViewport(localRect, context.projection)
: null;
const input = snapshotViewportRect(
b,
{ target: context.target, frameId: context.frameId },
{ x: context.scrollX, y: context.scrollY },
);
if (input) {
const { x, y, width: w, height: h } = input.rect;
localRect = { x, y, w, h };
}
const bounds =
input && context.projection.status === "available"
? projectSnapshotRect(input, context.projection.projection)
: null;
rect = bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
}
paintOrder = dl?.paintOrders?.[li] ?? 0;
@@ -935,19 +911,12 @@ function parseDocumentNodes(
cursor = str(strings, styleRow[cursorCol]) || "auto";
const visibility = str(strings, styleRow[visibilityCol]) || "visible";
const opacity = str(strings, styleRow[opacityCol]) || "1";
overflowX = str(strings, styleRow[overflowXCol]) || "visible";
overflowY = str(strings, styleRow[overflowYCol]) || "visible";
visuallySuppressed ||=
visibility === "hidden" ||
visibility === "collapse" ||
(Number.parseFloat(opacity) || 0) <= 0;
rendered =
localRect !== null &&
visibility !== "hidden" &&
visibility !== "collapse" &&
(Number.parseFloat(opacity) || 0) > 0;
}
visuallySuppressedByNodeIndex[n] = visuallySuppressed;
// Skip non-element nodes (#text, #cdata-section, etc.) — they carry no
// geometry and are never queried by callers.
@@ -982,9 +951,6 @@ function parseDocumentNodes(
position,
pointerEvents,
cursor,
overflowX,
overflowY,
visuallySuppressed,
rendered,
textContent,
...(formValue !== undefined ? { formValue } : {}),
@@ -1003,20 +969,23 @@ function parseDocumentNodes(
}
interface ParseFrameDocumentsResult {
frameGeometryIssues: FrameProjectionIssue[];
iframeNodes: CapturedIframeNodes;
frameOwnerBackendNodeIds: Map<string, number>;
frameParentIds: Map<string, string>;
excludedBackendNodeIds: Set<number>;
}
function parseChildFrameDocuments(
async function parseChildFrameDocuments(
documents: SnapshotDocument[],
strings: string[],
dpr: number,
parentDocIndex: number,
parentContext: FrameContext,
geometry: GeometryContext,
signal?: AbortSignal,
visited = new Set<number>(),
): ParseFrameDocumentsResult {
): Promise<ParseFrameDocumentsResult> {
const frameGeometryIssues: FrameProjectionIssue[] = [];
const iframeNodes: CapturedIframeNodes = new Map();
const frameOwnerBackendNodeIds = new Map<string, number>();
const frameParentIds = new Map<string, string>();
@@ -1024,24 +993,80 @@ function parseChildFrameDocuments(
const parentDoc = documents[parentDocIndex];
const cdi = sparseIndexMap(parentDoc?.nodes?.contentDocumentIndex);
if (cdi.size === 0) {
return { iframeNodes, frameOwnerBackendNodeIds, frameParentIds, excludedBackendNodeIds };
return {
iframeNodes,
frameOwnerBackendNodeIds,
frameParentIds,
excludedBackendNodeIds,
frameGeometryIssues,
};
}
const parentBackendIds = parentDoc?.nodes?.backendNodeId ?? [];
for (const [nodeArrayIdx, childDocIndex] of cdi) {
if (visited.has(childDocIndex)) continue;
const childDoc = documents[childDocIndex];
if (!childDoc) continue;
const iframeBackendId = parentBackendIds[nodeArrayIdx];
if (iframeBackendId === undefined) continue;
// DOMSnapshot reports the iframe owner's border-box bounds, but child
// document coordinates start at the content-box origin. Preserve the
// frame-local rectangles here and let frame-capture normalize them with
// authoritative content quads once the frame graph is available.
const projection = null;
const children = [...cdi].flatMap(([nodeArrayIdx, childDocIndex]) => {
const childDoc = documents[childDocIndex];
const iframeBackendId = parentBackendIds[nodeArrayIdx];
if (visited.has(childDocIndex) || !childDoc || iframeBackendId === undefined) return [];
const source = { target: parentContext.target, frameId: snapshotFrameId(childDoc, strings) };
const parent = parentContext.projection;
const projection: FrameProjectionState =
parent.status === "available"
? { status: "unavailable", source, ownerBackendNodeId: iframeBackendId }
: { status: "blocked", source, cause: parent.status === "blocked" ? parent.cause : parent };
return [
{
childDocIndex,
childDoc,
iframeBackendId,
source,
projection: projection as FrameProjectionState,
},
];
});
const parentProjection =
parentContext.projection.status === "available"
? parentContext.projection.projection.geometry
: null;
if (parentProjection) {
// Measure only direct siblings. Recursion starts after these workers finish,
// so descendants never hold or recursively acquire a measurement slot.
const edge = parentProjection.edges[0];
const clips = edge
? [edge.destinationQuad, ...(edge.destinationClips ?? [])]
: parentProjection.sourceClips;
let cursor = 0;
let failure: unknown;
await Promise.all(
Array.from({ length: Math.min(4, children.length) }, async () => {
while (cursor < children.length && !signal?.aborted && !failure) {
const child = children[cursor++];
try {
// Owner quads are target-relative; do not apply the parent transform twice.
child.projection = await geometry.snapshotProjection(
child.source,
child.iframeBackendId,
clips,
parentProjection.topViewport,
);
} catch (error) {
failure ??= error;
}
}
}),
);
// Join all active reads, including object cleanup, before propagating abort.
if (failure) throw failure;
}
throwIfAborted(signal);
// Completion order must not change document traversal or result insertion order.
for (const { childDocIndex, childDoc, iframeBackendId, source, projection } of children) {
throwIfAborted(signal);
if (projection.status !== "available") frameGeometryIssues.push(projection);
const childContext: FrameContext = {
frameId: snapshotFrameId(childDoc, strings),
frameId: source.frameId,
target: source.target,
ownerFrameBackendNodeId: iframeBackendId,
projection,
scrollX: childDoc.scrollOffsetX ?? 0,
@@ -1049,7 +1074,7 @@ function parseChildFrameDocuments(
};
const nextVisited = new Set(visited);
nextVisited.add(childDocIndex);
const parsed = parseDocumentNodes(childDoc, strings, dpr, childContext);
const parsed = parseDocumentNodes(childDoc, strings, childContext);
iframeNodes.set(iframeBackendId, parsed.nodes);
if (childContext.frameId) {
frameOwnerBackendNodeIds.set(childContext.frameId, iframeBackendId);
@@ -1057,14 +1082,16 @@ function parseChildFrameDocuments(
}
for (const id of parsed.excludedBackendNodeIds) excludedBackendNodeIds.add(id);
const nested = parseChildFrameDocuments(
const nested = await parseChildFrameDocuments(
documents,
strings,
dpr,
childDocIndex,
childContext,
geometry,
signal,
nextVisited,
);
frameGeometryIssues.push(...nested.frameGeometryIssues);
for (const [nestedFrameId, nestedNodes] of nested.iframeNodes) {
iframeNodes.set(nestedFrameId, nestedNodes);
}
@@ -1077,7 +1104,13 @@ function parseChildFrameDocuments(
for (const id of nested.excludedBackendNodeIds) excludedBackendNodeIds.add(id);
}
return { iframeNodes, frameOwnerBackendNodeIds, frameParentIds, excludedBackendNodeIds };
return {
iframeNodes,
frameOwnerBackendNodeIds,
frameParentIds,
excludedBackendNodeIds,
frameGeometryIssues,
};
}
export async function captureViewModel(
@@ -1086,22 +1119,20 @@ export async function captureViewModel(
options: CaptureViewModelOptions = {},
): Promise<CapturedViewModel> {
throwIfAborted(options.signal);
let metrics: LayoutMetricsReply = {};
const target = options.target ?? { tabId };
const geometry = options.geometry ?? new GeometryContext(cdp, tabId, undefined, options.signal);
let metrics: LayoutMetrics = {};
try {
metrics = await cdp.send<LayoutMetricsReply>(tabId, "Page.getLayoutMetrics", {});
metrics = await geometry.layoutMetrics(target);
} catch (error) {
if (isAbortError(error)) throw error;
console.debug("[bsk capture] layout metrics unavailable", error);
}
throwIfAborted(options.signal);
const dpr = devicePixelRatio(metrics);
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
const viewport: Viewport = {
width: vpSrc.clientWidth ?? 0,
height: vpSrc.clientHeight ?? 0,
};
const scrollX = vpSrc.pageX ?? 0;
const scrollY = vpSrc.pageY ?? 0;
const measured = cssViewport(metrics);
const viewport: Viewport = { width: measured.width, height: measured.height };
const scrollX = measured.scrollX;
const scrollY = measured.scrollY;
await cdp.send(tabId, "DOMSnapshot.enable", {});
throwIfAborted(options.signal);
@@ -1127,19 +1158,37 @@ export async function captureViewModel(
const topContext: FrameContext = {
frameId: snapshotFrameId(doc0, strings),
ownerFrameBackendNodeId: null,
target,
projection: {
sourceClips: [],
edges: [],
topViewport: viewport,
status: "available",
projection: {
source: { target, frameId: snapshotFrameId(doc0, strings) },
geometry: { sourceClips: [], edges: [], topViewport: viewport },
},
},
scrollX,
scrollY,
};
const mainParsed = parseDocumentNodes(doc0, strings, dpr, topContext);
const mainParsed = parseDocumentNodes(doc0, strings, topContext);
const nodes = mainParsed.nodes;
const excludedBackendNodeIds = new Set(mainParsed.excludedBackendNodeIds);
const frameParsed = parseChildFrameDocuments(documents, strings, dpr, 0, topContext);
const ownerIds = new Set<number>();
for (const document of documents) {
for (const index of document.nodes?.contentDocumentIndex?.index ?? []) {
const id = document.nodes?.backendNodeId?.[index];
if (id !== undefined) ownerIds.add(id);
}
}
geometry.registerSnapshotOwners(target, ownerIds);
const frameParsed = await parseChildFrameDocuments(
documents,
strings,
0,
topContext,
geometry,
options.signal,
);
const iframeNodes = frameParsed.iframeNodes;
for (const id of frameParsed.excludedBackendNodeIds) {
excludedBackendNodeIds.add(id);
@@ -1165,6 +1214,7 @@ export async function captureViewModel(
viewport,
iframeNodes,
frameNodes,
frameGeometryIssues: frameParsed.frameGeometryIssues,
frameOwnerBackendNodeIds: frameParsed.frameOwnerBackendNodeIds,
frameParentIds: frameParsed.frameParentIds,
...(topContext.frameId ? { rootFrameId: topContext.frameId } : {}),
+35 -87
View File
@@ -1,5 +1,6 @@
import type { CdpFrame, CdpFrameGraph } from "@/browser-driver/frame-graph";
import { createFrameGeometryContext } from "../frame-geometry";
import { type GeometryProjection, projectRectToViewport } from "../geometry";
import { GeometryContext } from "../geometry/frame-context";
import { type CdpRunner, cdpRunnerForTarget, sendToCdpTarget } from "../shared";
import { type CapturedNode, type CapturedViewModel, captureViewModel } from "./capture";
import {
@@ -12,8 +13,6 @@ import {
export type FrameAxNode = FrameOwnedAxNode;
export type CapturedFrameDocument<T extends FrameAxNode> = FrameDocument<T>;
const FRAME_GEOMETRY_CONCURRENCY = 4;
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw new DOMException("observation aborted", "AbortError");
}
@@ -22,30 +21,19 @@ function isAbortError(err: unknown): boolean {
return err instanceof Error && err.name === "AbortError";
}
async function forEachWithConcurrency<T>(
items: readonly T[],
concurrency: number,
worker: (item: T) => Promise<void>,
): Promise<void> {
let next = 0;
const run = async () => {
while (next < items.length) {
const index = next;
next += 1;
await worker(items[index]);
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run));
}
async function discoverFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFrameGraph | null> {
if (!cdp.getFrameGraph) return null;
try {
return await cdp.getFrameGraph(tabId);
} catch (err) {
console.debug("[bsk observation] frame graph capture failed", err);
return null;
}
function transformFrameNodes(
nodes: CapturedNode[],
projection: GeometryProjection | null,
): CapturedNode[] {
return nodes.map((node) => ({
...node,
rect: projection
? (() => {
const bounds = projectRectToViewport(node.rect, projection);
return bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
})()
: null,
}));
}
async function captureMissingFrameDocuments(
@@ -53,6 +41,7 @@ async function captureMissingFrameDocuments(
tabId: number,
graph: CdpFrameGraph,
captured: CapturedViewModel,
geometry: GeometryContext,
signal?: AbortSignal,
): Promise<void> {
if (!captured.frameNodes) captured.frameNodes = new Map();
@@ -64,7 +53,22 @@ async function captureMissingFrameDocuments(
try {
const child = await captureViewModel(cdpRunnerForTarget(cdp, frame.target), tabId, {
signal,
geometry,
target: frame.target,
});
if (child.frameGeometryIssues?.length) {
(captured.frameGeometryIssues ??= []).push(...child.frameGeometryIssues);
}
let projection: GeometryProjection | null = null;
try {
projection = await geometry.targetProjection(frame.frameId);
} catch (err) {
if (isAbortError(err)) throw err;
console.debug("[bsk observation] child frame geometry projection failed", {
frameId: frame.frameId,
err,
});
}
const rootFrameId = child.rootFrameId ?? frame.frameId;
const childFrames = child.frameNodes ?? new Map<string, CapturedNode[]>();
if (!childFrames.has(rootFrameId)) childFrames.set(rootFrameId, child.nodes);
@@ -75,7 +79,7 @@ async function captureMissingFrameDocuments(
);
}
for (const [childFrameId, nodes] of childFrames) {
captured.frameNodes.set(childFrameId, nodes);
captured.frameNodes.set(childFrameId, transformFrameNodes(nodes, projection));
}
if (frame.ownerBackendNodeId !== undefined) {
captured.iframeNodes.set(
@@ -101,60 +105,6 @@ async function captureMissingFrameDocuments(
}
}
async function normalizeFrameLocalRects(
cdp: CdpRunner,
graph: CdpFrameGraph,
captured: CapturedViewModel,
signal?: AbortSignal,
): Promise<void> {
if (!captured.frameNodes) return;
const geometry = createFrameGeometryContext(cdp, graph);
const frameById = new Map(graph.frames.map((frame) => [frame.frameId, frame]));
await forEachWithConcurrency(
[...captured.frameNodes],
FRAME_GEOMETRY_CONCURRENCY,
async ([frameId, nodes]) => {
if (frameId === graph.rootFrameId || !frameById.has(frameId)) return;
try {
const normalized: CapturedNode[] = [];
for (const node of nodes) {
if (!node.localRect) {
normalized.push({ ...node, rect: null });
} else {
const bounds = await geometry.projectFrameLocalRect(frameId, node.localRect);
normalized.push({
...node,
rect: bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null,
});
}
}
throwIfAborted(signal);
captured.frameNodes?.set(frameId, normalized);
const ownerBackendNodeId =
frameById.get(frameId)?.ownerBackendNodeId ??
captured.frameOwnerBackendNodeIds?.get(frameId);
if (ownerBackendNodeId !== undefined) {
captured.iframeNodes.set(ownerBackendNodeId, normalized);
}
} catch (err) {
if (isAbortError(err)) throw err;
console.debug("[bsk observation] frame-local geometry normalization failed", {
frameId,
err,
});
const unavailable = nodes.map((node) => ({ ...node, rect: null }));
captured.frameNodes?.set(frameId, unavailable);
const ownerBackendNodeId =
frameById.get(frameId)?.ownerBackendNodeId ??
captured.frameOwnerBackendNodeIds?.get(frameId);
if (ownerBackendNodeId !== undefined) {
captured.iframeNodes.set(ownerBackendNodeId, unavailable);
}
}
},
);
}
async function captureAxTrees<T extends FrameAxNode>(
cdp: CdpRunner,
tabId: number,
@@ -217,13 +167,11 @@ export async function captureFrameData<T extends FrameAxNode>(
tabId: number,
captured: CapturedViewModel,
signal?: AbortSignal,
geometry = new GeometryContext(cdp, tabId, undefined, signal),
): Promise<CapturedFrameDocument<T>[]> {
const graph = await discoverFrameGraph(cdp, tabId);
const graph = await geometry.graph();
const frames = graph?.frames ?? [];
if (graph) {
await captureMissingFrameDocuments(cdp, tabId, graph, captured, signal);
await normalizeFrameLocalRects(cdp, graph, captured, signal);
}
if (graph) await captureMissingFrameDocuments(cdp, tabId, graph, captured, geometry, signal);
throwIfAborted(signal);
const batches = await captureAxTrees<T>(cdp, tabId, frames, captured, signal);
return buildFrameDocuments(graph, batches, captured);
@@ -1,207 +0,0 @@
import { type Rect, selectHighestPriorityVisualSurfaces } from "@browser-skill/vom";
import type { ClusteredRenderedSurfaces, RenderedSurface, RenderedSurfaceGroup } from "./types";
const STACK_IOU_RATIO = 0.9;
const STACK_SIZE_RATIO = 0.9;
const SIZE_BAND_BASE = 1 / STACK_SIZE_RATIO;
const POSITION_CELL_RATIO = 0.1;
const MAX_CLUSTER_COMPARISONS = 1_000_000;
const MAX_CLUSTER_SURFACES = 50_000;
export interface ClusterRenderedSurfaceOptions {
maxComparisons?: number;
maxSurfaces?: number;
}
interface IndexedGroup extends RenderedSurfaceGroup {
creationIndex: number;
indexKey: string;
}
function area(rect: Rect): number {
return Math.max(0, rect.w) * Math.max(0, rect.h);
}
function intersectionArea(a: Rect, b: Rect): number {
const width = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
const height = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
return width * height;
}
function stackScore(a: RenderedSurface, b: RenderedSurface): number | null {
const aArea = area(a.visibleRect);
const bArea = area(b.visibleRect);
const intersection = intersectionArea(a.visibleRect, b.visibleRect);
const union = aArea + bArea - intersection;
const widthRatio =
Math.min(a.visibleRect.w, b.visibleRect.w) / Math.max(a.visibleRect.w, b.visibleRect.w);
const heightRatio =
Math.min(a.visibleRect.h, b.visibleRect.h) / Math.max(a.visibleRect.h, b.visibleRect.h);
const iou = union > 0 ? intersection / union : 0;
return iou >= STACK_IOU_RATIO && widthRatio >= STACK_SIZE_RATIO && heightRatio >= STACK_SIZE_RATIO
? iou
: null;
}
function sizeBand(value: number): number {
return Math.floor(Math.log(Math.max(Number.EPSILON, value)) / Math.log(SIZE_BAND_BASE));
}
function bandSize(band: number): number {
return SIZE_BAND_BASE ** band;
}
function spatialKey(rect: Rect): string {
const widthBand = sizeBand(rect.w);
const heightBand = sizeBand(rect.h);
const cellWidth = bandSize(widthBand) * POSITION_CELL_RATIO;
const cellHeight = bandSize(heightBand) * POSITION_CELL_RATIO;
const centerX = rect.x + rect.w / 2;
const centerY = rect.y + rect.h / 2;
return `${widthBand}:${heightBand}:${Math.floor(centerX / cellWidth)}:${Math.floor(centerY / cellHeight)}`;
}
function candidateKeys(rect: Rect): string[] {
const keys: string[] = [];
const centerX = rect.x + rect.w / 2;
const centerY = rect.y + rect.h / 2;
const widthBand = sizeBand(rect.w);
const heightBand = sizeBand(rect.h);
// A >=0.9 size ratio crosses at most one logarithmic band. IoU >=0.9
// likewise keeps centers within one 10%-of-size position cell.
for (let dw = -1; dw <= 1; dw += 1) {
for (let dh = -1; dh <= 1; dh += 1) {
const candidateWidthBand = widthBand + dw;
const candidateHeightBand = heightBand + dh;
const cellWidth = bandSize(candidateWidthBand) * POSITION_CELL_RATIO;
const cellHeight = bandSize(candidateHeightBand) * POSITION_CELL_RATIO;
const cellX = Math.floor(centerX / cellWidth);
const cellY = Math.floor(centerY / cellHeight);
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
keys.push(`${candidateWidthBand}:${candidateHeightBand}:${cellX + dx}:${cellY + dy}`);
}
}
}
}
return keys;
}
function preferredRepresentative(a: RenderedSurface, b: RenderedSurface): RenderedSurface {
const aArea = area(a.visibleRect);
const bArea = area(b.visibleRect);
return bArea > aArea || (bArea === aArea && b.paintOrder > a.paintOrder) ? b : a;
}
function partitionKey(surface: RenderedSurface): string {
return `${surface.frameId}\u0000${surface.parentBackendNodeId ?? "root"}`;
}
function normalizedLimit(value: number | undefined, fallback: number): number {
const resolved = value ?? fallback;
return Number.isFinite(resolved) ? Math.max(0, Math.floor(resolved)) : fallback;
}
export function clusterRenderedSurfaces(
surfaces: RenderedSurface[],
options: ClusterRenderedSurfaceOptions = {},
): ClusteredRenderedSurfaces {
const maxComparisons = normalizedLimit(options.maxComparisons, MAX_CLUSTER_COMPARISONS);
const maxSurfaces = normalizedLimit(options.maxSurfaces, MAX_CLUSTER_SURFACES);
const prioritized = selectHighestPriorityVisualSurfaces(surfaces, maxSurfaces);
const partitions = new Map<string, Map<string, Set<IndexedGroup>>>();
const groups: IndexedGroup[] = [];
let comparisons = 0;
let processed = 0;
let truncated = prioritized.length < surfaces.length;
let comparisonLimitReached = false;
for (const surface of prioritized) {
if (comparisons >= maxComparisons) {
truncated = true;
break;
}
const key = partitionKey(surface);
let index = partitions.get(key);
if (!index) {
index = new Map();
partitions.set(key, index);
}
const candidates = new Set<IndexedGroup>();
for (const candidateKey of candidateKeys(surface.visibleRect)) {
for (const candidate of index.get(candidateKey) ?? []) candidates.add(candidate);
}
let best: { group: IndexedGroup; score: number; areaDelta: number } | null = null;
for (const group of candidates) {
if (comparisons >= maxComparisons) {
truncated = true;
comparisonLimitReached = true;
break;
}
comparisons += 1;
const score = stackScore(group.representative, surface);
if (score === null) continue;
const areaDelta = Math.abs(
area(group.representative.visibleRect) - area(surface.visibleRect),
);
if (
!best ||
score > best.score ||
(score === best.score && areaDelta < best.areaDelta) ||
(score === best.score &&
areaDelta === best.areaDelta &&
group.creationIndex < best.group.creationIndex)
) {
best = { group, score, areaDelta };
}
}
if (comparisonLimitReached) break;
if (best) {
const group = best.group;
const representative = preferredRepresentative(group.representative, surface);
group.memberCount += 1;
group.label ??= surface.label;
if (representative !== group.representative) {
index.get(group.indexKey)?.delete(group);
group.representative = representative;
group.indexKey = spatialKey(representative.visibleRect);
const indexed = index.get(group.indexKey) ?? new Set<IndexedGroup>();
indexed.add(group);
index.set(group.indexKey, indexed);
}
processed += 1;
continue;
}
const indexKey = spatialKey(surface.visibleRect);
const group: IndexedGroup = {
frameId: surface.frameId,
parentBackendNodeId: surface.parentBackendNodeId,
representative: surface,
...(surface.label ? { label: surface.label } : {}),
memberCount: 1,
creationIndex: groups.length,
indexKey,
};
groups.push(group);
const indexed = index.get(indexKey) ?? new Set<IndexedGroup>();
indexed.add(group);
index.set(indexKey, indexed);
processed += 1;
}
return {
groups: groups
.map(({ creationIndex: _creationIndex, indexKey: _indexKey, ...group }) => group)
.sort(
(a, b) =>
a.frameId.localeCompare(b.frameId) ||
a.representative.visibleRect.y - b.representative.visibleRect.y ||
a.representative.visibleRect.x - b.representative.visibleRect.x,
),
truncated,
...(truncated ? { omittedCount: surfaces.length - processed } : {}),
};
}
@@ -1,113 +0,0 @@
import type { Rect, Viewport } from "@browser-skill/vom";
import type { CapturedNode } from "../capture";
import type { CapturedFrameDocument } from "../frame-capture";
import type { SemanticAxNode } from "../semantic-graph";
import type { RenderedSurface } from "./types";
function clean(value: string | undefined): string | undefined {
const normalized = value?.replace(/\s+/g, " ").trim();
return normalized || undefined;
}
function intersectViewport(rect: Rect, viewport: Viewport): Rect | null {
return intersectRect(rect, { x: 0, y: 0, w: viewport.width, h: viewport.height });
}
function intersectRect(a: Rect, b: Rect): Rect | null {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
const right = Math.min(a.x + a.w, b.x + b.w);
const bottom = Math.min(a.y + a.h, b.y + b.h);
if (right <= x || bottom <= y) return null;
return { x, y, w: right - x, h: bottom - y };
}
const CLIPPING_OVERFLOW = new Set(["auto", "clip", "hidden", "scroll"]);
function clipToOverflowAncestors(
node: CapturedNode,
rect: Rect,
nodesByBackend: ReadonlyMap<number, CapturedNode>,
): Rect | null {
let clipped: Rect | null = rect;
let parentBackendNodeId = node.parentBackendNodeId;
let guard = 0;
while (clipped && parentBackendNodeId !== null && guard <= nodesByBackend.size) {
const parent = nodesByBackend.get(parentBackendNodeId);
if (!parent) break;
if (parent.rect) {
const clipX = CLIPPING_OVERFLOW.has(parent.overflowX ?? "visible");
const clipY = CLIPPING_OVERFLOW.has(parent.overflowY ?? "visible");
if (clipX || clipY) {
const clipRect = {
x: clipX ? parent.rect.x : clipped.x,
y: clipY ? parent.rect.y : clipped.y,
w: clipX ? parent.rect.w : clipped.w,
h: clipY ? parent.rect.h : clipped.h,
};
clipped = intersectRect(clipped, clipRect);
}
}
parentBackendNodeId = parent.parentBackendNodeId;
guard += 1;
}
return clipped;
}
function excludedByDomPolicy(
node: CapturedNode,
nodesByBackend: Map<number, CapturedNode>,
excludedBackendNodeIds: ReadonlySet<number>,
): boolean {
let current: CapturedNode | undefined = node;
let guard = 0;
while (current && guard <= nodesByBackend.size) {
if (excludedBackendNodeIds.has(current.backendNodeId)) return true;
if (current.visuallySuppressed === true) return true;
const attrs = current.attrs;
if (
Object.prototype.hasOwnProperty.call(attrs, "hidden") ||
Object.prototype.hasOwnProperty.call(attrs, "inert") ||
(attrs["aria-hidden"] ?? "").toLowerCase() === "true"
) {
return true;
}
current =
current.parentBackendNodeId === null
? undefined
: nodesByBackend.get(current.parentBackendNodeId);
guard += 1;
}
return false;
}
export function discoverRenderedSurfaces(
documents: CapturedFrameDocument<SemanticAxNode>[],
viewport: Viewport,
excludedBackendNodeIds: ReadonlySet<number> = new Set(),
): RenderedSurface[] {
const surfaces: RenderedSurface[] = [];
for (const document of documents) {
const nodesByBackend = new Map(document.domNodes.map((node) => [node.backendNodeId, node]));
for (const node of document.domNodes) {
if (node.tag.toLowerCase() !== "canvas" || node.rendered !== true || !node.rect) continue;
if (excludedByDomPolicy(node, nodesByBackend, excludedBackendNodeIds)) continue;
const viewportRect = intersectViewport(node.rect, viewport);
const visibleRect = viewportRect
? clipToOverflowAncestors(node, viewportRect, nodesByBackend)
: null;
if (!visibleRect) continue;
const label = clean(node.attrs["aria-label"] ?? node.attrs.title);
surfaces.push({
renderingKind: "canvas",
frameId: document.frameId,
backendNodeId: node.backendNodeId,
parentBackendNodeId: node.parentBackendNodeId,
visibleRect,
paintOrder: node.paintOrder,
...(label ? { label } : {}),
});
}
}
return surfaces;
}
@@ -1,8 +0,0 @@
export { type ClusterRenderedSurfaceOptions, clusterRenderedSurfaces } from "./cluster";
export { discoverRenderedSurfaces } from "./discovery";
export { projectRenderedSurfaces } from "./project";
export type {
ClusteredRenderedSurfaces,
RenderedSurface,
RenderedSurfaceGroup,
} from "./types";
@@ -1,108 +0,0 @@
import type { VomScene, VomVisualSurface } from "@browser-skill/vom";
import type { CapturedFrameDocument } from "../frame-capture";
import type { SemanticAxNode } from "../semantic-graph";
import type { ClusteredRenderedSurfaces, RenderedSurfaceGroup } from "./types";
function frameBackendKey(frameId: string, backendNodeId: number): string {
return `${frameId}\u0000${backendNodeId}`;
}
interface ProjectionIndex {
sceneByBackend: ReadonlyMap<string, number>;
documentByFrame: ReadonlyMap<string, CapturedFrameDocument<SemanticAxNode>>;
domByFrame: ReadonlyMap<
string,
ReadonlyMap<number, CapturedFrameDocument<SemanticAxNode>["domNodes"][number]>
>;
rootSceneIdByFrame: ReadonlyMap<string, number>;
}
function nearestSceneParent(index: ProjectionIndex, group: RenderedSurfaceGroup): number | null {
const document = index.documentByFrame.get(group.frameId);
const domByBackend = index.domByFrame.get(group.frameId) ?? new Map();
let parentBackendNodeId = group.representative.parentBackendNodeId;
let guard = 0;
while (parentBackendNodeId !== null && guard <= domByBackend.size) {
const sceneId = index.sceneByBackend.get(frameBackendKey(group.frameId, parentBackendNodeId));
if (sceneId !== undefined) return sceneId;
parentBackendNodeId = domByBackend.get(parentBackendNodeId)?.parentBackendNodeId ?? null;
guard += 1;
}
if (document?.parentFrameId && document.ownerBackendNodeId !== undefined) {
const owner = index.sceneByBackend.get(
frameBackendKey(document.parentFrameId, document.ownerBackendNodeId),
);
if (owner !== undefined) return owner;
}
return index.rootSceneIdByFrame.get(group.frameId) ?? null;
}
function buildProjectionIndex(
scene: VomScene,
documents: CapturedFrameDocument<SemanticAxNode>[],
): ProjectionIndex {
const rootSceneIdByFrame = new Map<string, number>();
for (const node of scene.nodes) {
if (
node.frameId &&
!rootSceneIdByFrame.has(node.frameId) &&
["rootwebarea", "webarea"].includes(node.role?.toLowerCase() ?? "")
) {
rootSceneIdByFrame.set(node.frameId, node.id);
}
}
return {
sceneByBackend: new Map(
scene.nodes.flatMap((node) =>
node.backendNodeId === undefined
? []
: [[frameBackendKey(node.frameId ?? "", node.backendNodeId), node.id] as const],
),
),
documentByFrame: new Map(documents.map((document) => [document.frameId, document])),
domByFrame: new Map(
documents.map((document) => [
document.frameId,
new Map(document.domNodes.map((node) => [node.backendNodeId, node])),
]),
),
rootSceneIdByFrame,
};
}
export function projectRenderedSurfaces(
scene: VomScene,
documents: CapturedFrameDocument<SemanticAxNode>[],
clustered: ClusteredRenderedSurfaces,
): VomScene {
const index = buildProjectionIndex(scene, documents);
const visualSurfaces: VomVisualSurface[] = [];
for (const group of clustered.groups) {
const parentId = nearestSceneParent(index, group);
visualSurfaces.push({
parentId,
backendNodeId: group.representative.backendNodeId,
frameId: group.frameId,
renderingKind: "canvas",
visibleRect: group.representative.visibleRect,
...(group.label ? { label: group.label } : {}),
memberCount: group.memberCount,
});
}
return {
...scene,
...(visualSurfaces.length > 0 ? { visualSurfaces } : {}),
...(clustered.truncated
? {
visualSurfacesTruncated: true,
...(clustered.omittedCount !== undefined
? { omittedVisualSurfaceCount: clustered.omittedCount }
: {}),
}
: {}),
};
}
@@ -1,25 +0,0 @@
import type { Rect } from "@browser-skill/vom";
export interface RenderedSurface {
renderingKind: "canvas";
frameId: string;
backendNodeId: number;
parentBackendNodeId: number | null;
visibleRect: Rect;
paintOrder: number;
label?: string;
}
export interface RenderedSurfaceGroup {
frameId: string;
parentBackendNodeId: number | null;
representative: RenderedSurface;
label?: string;
memberCount: number;
}
export interface ClusteredRenderedSurfaces {
groups: RenderedSurfaceGroup[];
truncated: boolean;
omittedCount?: number;
}
-1
View File
@@ -23,7 +23,6 @@ export type ErrorCode =
export type RpcErrorReason =
| "agent_window_scope"
| "element_not_visible"
| "ref_capability_denied"
| "ref_not_found"
| "selector_not_found"
| "target_not_fillable"
@@ -1,110 +0,0 @@
# Rendered Surface browser fixtures
These pages exercise Canvas discovery through a real browser DOMSnapshot and AX tree. They are deliberately framework- and site-independent.
## Serve
From the repository root:
```bash
python3 -m http.server 4173 --bind 127.0.0.1 \
--directory apps/extension/test-fixtures/rendered-surfaces
```
Use an Agent Window large enough to keep the primary matrix visible:
```bash
bsk session start --width 1440 --height 1100
bsk navigate http://127.0.0.1:4173/index.html --session <id>
bsk observe --session <id>
```
Always stop the session after the run.
## Pages
### `index.html`
Expected individual Surface labels:
- `fixture-basic-large`
- `fixture-tiny-8px`
- `fixture-pointer-events-none`
- `fixture-partial-ax`
- `fixture-contained-outer`
- `fixture-contained-inner`
- `fixture-different-parent-a`
- `fixture-different-parent-b`
Expected one grouped Surface:
- `fixture-layered-group`, with `layers=2`
Expected absent:
- `fixture-display-none`
- `fixture-visibility-hidden`
- `fixture-opacity-zero`
- `fixture-parent-opacity-zero`
- `fixture-hidden-attribute`
- `fixture-inert-parent`
- `fixture-aria-hidden-parent`
- `fixture-zero-size`
- `fixture-offscreen-right`
- `fixture-fully-overflow-clipped`
`fixture-partially-overflow-clipped` must remain present because part of it is visible. Its screenshot must contain only the browser's final composited view.
### `unlabeled.html`
This page contains eight Canvas elements but must render seven generic Surface markers because
the two coincident layers form one group.
- Every marker uses the stable fallback label `canvas visual surface`.
- Exactly one marker has `layers=2`.
- The two contained canvases remain separate markers.
- `id`, `class`, `data-testid`, nearby prose, and fallback AX content never become a Surface label.
- Blank `aria-label` and `title` values behave as no label.
- Fallback AX content does not suppress the corresponding Surface.
### `frames.html`
Expected Surface labels:
- `fixture-same-origin-frame`
- `fixture-cross-origin-frame`
- `fixture-nested-frame`
The cross-origin frame uses `http://localhost:4173` while the parent uses `http://127.0.0.1:4173`. With Chromium site isolation this exercises the OOPIF path without an external service.
### `viewport.html`
Run with a `900x700` Agent Window.
- `fixture-partial-viewport` is partly visible and must be represented.
- `fixture-below-fold` is absent initially.
- After scrolling it into view and observing again, `fixture-below-fold` must be represented with a fresh ref.
### `dynamic.html`
Use the normal DOM buttons and re-observe after each action:
- Initial state: `fixture-dynamic` absent.
- After **Insert canvas**: present.
- After **Resize to zero**: absent.
- After **Restore size**: present with a fresh ref.
- After **Remove canvas**: absent.
### `sparklines.html`
Expected Surface labels `fixture-sparkline-01` through `fixture-sparkline-20` must all be present.
The adjacent, non-overlapping canvases remain twenty independent screenshot-only Surface refs.
## Invariants
- `snapshot` must not emit any of these Surface markers.
- `observe` must emit only currently visible Canvas surfaces.
- Existing DOM/AX controls remain readable alongside Surface markers.
- Missing labels never prevent discovery; naming and discovery are independent.
- A Surface ref allows `screenshot --ref` and rejects ordinary click/fill/hover/select.
- No fixture URL, id, label, or layout constant may appear in production discovery code.
@@ -1,46 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Rendered Surface dynamic matrix</title>
<style>
html, body { margin: 0; font: 14px system-ui, sans-serif; }
body { padding: 16px; }
.controls { display: flex; gap: 8px; margin-bottom: 16px; }
button { padding: 8px 12px; }
canvas { display: block; width: 260px; height: 120px; background: #e8daef; border: 2px solid #7d3c98; }
</style>
</head>
<body>
<h1>Dynamic Canvas lifecycle</h1>
<div class="controls">
<button id="insert" type="button">Insert canvas</button>
<button id="zero" type="button">Resize to zero</button>
<button id="restore" type="button">Restore size</button>
<button id="remove" type="button">Remove canvas</button>
</div>
<div id="mount"></div>
<script>
const mount = document.querySelector("#mount");
const getCanvas = () => mount.querySelector("canvas");
document.querySelector("#insert").addEventListener("click", () => {
if (getCanvas()) return;
const canvas = document.createElement("canvas");
canvas.width = 260;
canvas.height = 120;
canvas.setAttribute("aria-label", "fixture-dynamic");
mount.append(canvas);
});
document.querySelector("#zero").addEventListener("click", () => {
const canvas = getCanvas();
if (canvas) canvas.style.cssText = "width:0;height:0;border:0";
});
document.querySelector("#restore").addEventListener("click", () => {
const canvas = getCanvas();
if (canvas) canvas.removeAttribute("style");
});
document.querySelector("#remove").addEventListener("click", () => getCanvas()?.remove());
</script>
</body>
</html>
@@ -1,28 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body { margin: 0; font: 14px system-ui, sans-serif; }
body { padding: 12px; background: #fdfefe; }
canvas { display: block; width: 220px; height: 90px; background: #d5f5e3; border: 2px solid #1e8449; }
iframe { width: 280px; height: 150px; margin-top: 10px; border: 2px solid #85929e; }
</style>
</head>
<body>
<canvas id="frame-canvas" width="220" height="90"></canvas>
<script>
const params = new URLSearchParams(location.search);
const canvas = document.querySelector("#frame-canvas");
if (params.get("canvas") === "0") canvas.remove();
else canvas.setAttribute("aria-label", params.get("label") || "fixture-frame-default");
if (params.get("nested") === "1") {
const iframe = document.createElement("iframe");
iframe.title = "nested fixture frame";
iframe.src = "frame.html?label=fixture-nested-frame";
document.body.append(iframe);
}
</script>
</body>
</html>
@@ -1,22 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Rendered Surface frame matrix</title>
<style>
html, body { margin: 0; font: 14px system-ui, sans-serif; }
body { padding: 16px; }
.frames { display: grid; grid-template-columns: repeat(3, 320px); gap: 16px; }
iframe { width: 300px; height: 300px; border: 2px solid #5d6d7e; }
</style>
</head>
<body>
<h1>Frame matrix</h1>
<div class="frames">
<iframe title="same-origin fixture" src="frame.html?label=fixture-same-origin-frame"></iframe>
<iframe title="cross-origin fixture" src="http://localhost:4173/frame.html?label=fixture-cross-origin-frame"></iframe>
<iframe title="nested fixture" src="frame.html?canvas=0&nested=1"></iframe>
</div>
</body>
</html>
@@ -1,118 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Rendered Surface discovery matrix</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; font: 14px/1.4 system-ui, sans-serif; }
body { padding: 16px; background: #f4f6f8; color: #17202a; }
h1 { margin: 0 0 12px; font-size: 20px; }
.matrix { display: grid; grid-template-columns: repeat(4, minmax(220px, 1fr)); gap: 12px; }
.case { min-height: 150px; padding: 10px; overflow: hidden; border: 1px solid #aeb6bf; border-radius: 6px; background: white; }
.case h2 { margin: 0 0 8px; font-size: 13px; }
canvas { display: block; background: #d6eaf8; border: 2px solid #2874a6; }
.stack { position: relative; width: 180px; height: 90px; }
.stack canvas { position: absolute; inset: 0; width: 180px; height: 90px; }
.stack canvas + canvas { background: rgb(244 208 63 / 45%); border-color: #b7950b; }
.contained { position: relative; width: 190px; height: 100px; }
.contained > canvas:first-child { width: 190px; height: 100px; }
.contained > canvas:last-child { position: absolute; left: 55px; top: 30px; width: 70px; height: 40px; background: #fadbd8; border-color: #c0392b; }
.same-place { position: relative; width: 180px; height: 90px; }
.same-place > div { position: absolute; inset: 0; }
.same-place canvas { width: 180px; height: 90px; }
.same-place > div:last-child canvas { border-color: #7d3c98; background: rgb(210 180 222 / 35%); }
.clip-box { width: 100px; height: 70px; overflow: hidden; border: 2px dashed #566573; }
.partially-clipped { width: 140px; height: 60px; transform: translateX(45px); }
.fully-clipped { width: 80px; height: 50px; transform: translateX(130px); }
.tiny { width: 8px; height: 8px; border-width: 1px; }
</style>
</head>
<body>
<h1>Rendered Surface discovery matrix</h1>
<main class="matrix">
<section class="case">
<h2>Visible named canvas</h2>
<canvas aria-label="fixture-basic-large" width="200" height="90"></canvas>
</section>
<section class="case">
<h2>Tiny canvas remains addressable</h2>
<canvas class="tiny" aria-label="fixture-tiny-8px" width="8" height="8"></canvas>
</section>
<section class="case">
<h2>Visible but pointer-events none</h2>
<canvas aria-label="fixture-pointer-events-none" width="180" height="80" style="pointer-events: none"></canvas>
</section>
<section class="case">
<h2>Partial AX/fallback content</h2>
<canvas aria-label="fixture-partial-ax" width="180" height="80">
<button type="button">Fallback button must not suppress the surface</button>
</canvas>
</section>
<section class="case">
<h2>Two real rendering layers</h2>
<div class="stack">
<canvas aria-label="fixture-layered-group" width="180" height="90"></canvas>
<canvas width="180" height="90"></canvas>
</div>
</section>
<section class="case">
<h2>Contained canvases are independent</h2>
<div class="contained">
<canvas aria-label="fixture-contained-outer" width="190" height="100"></canvas>
<canvas aria-label="fixture-contained-inner" width="70" height="40"></canvas>
</div>
</section>
<section class="case">
<h2>Same geometry, different parents</h2>
<div class="same-place">
<div><canvas aria-label="fixture-different-parent-a" width="180" height="90"></canvas></div>
<div><canvas aria-label="fixture-different-parent-b" width="180" height="90"></canvas></div>
</div>
</section>
<section class="case">
<h2>Partially overflow-clipped</h2>
<div class="clip-box">
<canvas class="partially-clipped" aria-label="fixture-partially-overflow-clipped" width="140" height="60"></canvas>
</div>
</section>
<section class="case">
<h2>Fully overflow-clipped</h2>
<div class="clip-box">
<canvas class="fully-clipped" aria-label="fixture-fully-overflow-clipped" width="80" height="50"></canvas>
</div>
</section>
<section class="case">
<h2>CSS-hidden variants</h2>
<canvas aria-label="fixture-display-none" width="80" height="40" style="display: none"></canvas>
<canvas aria-label="fixture-visibility-hidden" width="80" height="40" style="visibility: hidden"></canvas>
<canvas aria-label="fixture-opacity-zero" width="80" height="40" style="opacity: 0"></canvas>
</section>
<section class="case">
<h2>Hidden ancestor variants</h2>
<div style="opacity: 0"><canvas aria-label="fixture-parent-opacity-zero" width="80" height="40"></canvas></div>
<div hidden><canvas aria-label="fixture-hidden-attribute" width="80" height="40"></canvas></div>
<div inert><canvas aria-label="fixture-inert-parent" width="80" height="40"></canvas></div>
<div aria-hidden="true"><canvas aria-label="fixture-aria-hidden-parent" width="80" height="40"></canvas></div>
</section>
<section class="case">
<h2>Zero-size and offscreen</h2>
<canvas aria-label="fixture-zero-size" width="80" height="40" style="width: 0; height: 0; border: 0"></canvas>
<canvas aria-label="fixture-offscreen-right" width="80" height="40" style="position: fixed; left: 5000px; top: 0"></canvas>
</section>
</main>
</body>
</html>
@@ -1,28 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Rendered Surface sparkline matrix</title>
<style>
html, body { margin: 0; font: 14px system-ui, sans-serif; }
body { padding: 16px; }
main { display: grid; grid-template-columns: repeat(5, 120px); gap: 12px; }
canvas { display: block; width: 100px; height: 30px; background: #eaf2f8; }
</style>
</head>
<body>
<h1>Twenty independent sparklines</h1>
<main id="sparklines"></main>
<script>
const host = document.querySelector("#sparklines");
for (let index = 1; index <= 20; index += 1) {
const canvas = document.createElement("canvas");
canvas.width = 100;
canvas.height = 30;
canvas.setAttribute("aria-label", `fixture-sparkline-${String(index).padStart(2, "0")}`);
host.append(canvas);
}
</script>
</body>
</html>
@@ -1,68 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Unlabeled Rendered Surface matrix</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; font: 14px/1.4 system-ui, sans-serif; }
body { padding: 16px; background: #f4f6f8; color: #17202a; }
h1 { margin: 0 0 12px; font-size: 20px; }
.matrix { display: grid; grid-template-columns: repeat(3, minmax(240px, 1fr)); gap: 12px; }
.case { min-height: 150px; padding: 10px; overflow: hidden; border: 1px solid #aeb6bf; border-radius: 6px; background: white; }
.case h2 { margin: 0 0 8px; font-size: 13px; }
.case p { margin: 0 0 8px; }
canvas { display: block; background: #d6eaf8; border: 2px solid #2874a6; }
.stack { position: relative; width: 180px; height: 90px; }
.stack canvas { position: absolute; inset: 0; width: 180px; height: 90px; }
.stack canvas + canvas { background: rgb(244 208 63 / 45%); border-color: #b7950b; }
.contained { position: relative; width: 190px; height: 100px; }
.contained > canvas:first-child { width: 190px; height: 100px; }
.contained > canvas:last-child { position: absolute; left: 55px; top: 30px; width: 70px; height: 40px; background: #fadbd8; border-color: #c0392b; }
</style>
</head>
<body>
<h1>Unlabeled Rendered Surface matrix</h1>
<main class="matrix">
<section class="case">
<h2>Only engineering attributes</h2>
<canvas id="must-not-be-a-label" class="must-not-be-a-label" data-testid="must-not-be-a-label" width="200" height="90"></canvas>
</section>
<section class="case">
<h2>Blank explicit names</h2>
<canvas aria-label=" " title=" " width="180" height="80"></canvas>
</section>
<section class="case">
<h2>Nearby prose is not a Canvas name</h2>
<p>must-not-be-inferred-as-a-surface-label</p>
<canvas width="180" height="80"></canvas>
</section>
<section class="case">
<h2>Fallback AX content does not name or suppress the Canvas</h2>
<canvas width="180" height="80">
<button type="button">Fallback control remains separate</button>
</canvas>
</section>
<section class="case">
<h2>Unnamed rendering layers still cluster</h2>
<div class="stack">
<canvas width="180" height="90"></canvas>
<canvas width="180" height="90"></canvas>
</div>
</section>
<section class="case">
<h2>Unnamed contained canvases stay independent</h2>
<div class="contained">
<canvas width="190" height="100"></canvas>
<canvas width="70" height="40"></canvas>
</div>
</section>
</main>
</body>
</html>
@@ -1,22 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Rendered Surface viewport matrix</title>
<style>
html, body { margin: 0; font: 14px system-ui, sans-serif; }
body { min-height: 1800px; padding: 16px; }
canvas { display: block; background: #fdebd0; border: 2px solid #ca6f1e; }
#partial { position: fixed; left: -70px; top: 120px; width: 180px; height: 100px; }
#below { position: absolute; left: 40px; top: 1350px; width: 240px; height: 120px; }
</style>
</head>
<body>
<h1>Viewport matrix</h1>
<p>The orange canvas on the left is intentionally clipped by the viewport.</p>
<canvas id="partial" aria-label="fixture-partial-viewport" width="180" height="100"></canvas>
<canvas id="below" aria-label="fixture-below-fold" width="240" height="120"></canvas>
</body>
</html>
-10
View File
@@ -73,16 +73,6 @@ CSS-only hover menu looks like from here. It hovers a bounded set of likely trig
few seconds and touches the live page; once you know which element hides the menu, `bsk hover <ref>`
is cheaper and more precise.
When `bsk observe` renders
`@eN surface ... [visual-only; requires=image-understanding; ...]`, the ref identifies rendered
canvas content that is not represented by the text observation, not an interactable DOM control.
Keep using any reliable DOM/AX text and controls that appear alongside it. If you can actually
inspect image output, use `bsk screenshot --ref @eN --session <id>` to obtain the visible crop. If
you lack multimodal or image-reading capability, do not take a screenshot and pretend to know its
contents, and never guess coordinates; tell the user that they need to switch to a model with
image-understanding capability. Do not pass a visual surface ref to click, fill, hover, or select;
those commands intentionally reject screenshot-only refs.
Escalate page reading only as needed:
1. `bsk observe` for normal semantic understanding, text, controls, and refs.
+103 -6
View File
@@ -73,6 +73,14 @@ where
P: Serialize + Send + 'static,
R: DeserializeOwned + Send + 'static,
{
let parent_cancel = stdin_cancellation();
if parent_cancel.as_ref().is_some_and(|rx| *rx.borrow()) {
return Err(CliError::from_rpc(RpcError {
code: ErrorCode::Cancelled,
message: "parent closed the cancellation pipe".into(),
data: Some(serde_json::json!({"reason": "cancelled_before_dispatch"})),
}));
}
let rpc_id: RpcId = format!("{}-{}", rpc_id_prefix, random_short_id());
let mut client = IpcClient::connect(sock.clone()).await?;
let rpc_id_for_call = rpc_id.clone();
@@ -88,16 +96,16 @@ where
let outcome = tokio::select! {
biased;
res = &mut main_fut => res?,
sig = wait_for_sigint() => {
sig.context("install SIGINT handler").map_err(CliError::Local)?;
debug!(rpc_id = %rpc_id_for_cancel, "SIGINT: forwarding cancel to daemon");
sig = wait_for_cancel(parent_cancel) => {
sig.context("wait for cancellation").map_err(CliError::Local)?;
debug!(rpc_id = %rpc_id_for_cancel, "forwarding cancel to daemon");
let _ = send_cancel(&sock, &rpc_id_for_cancel).await;
match tokio::time::timeout(CANCEL_RESPONSE_GRACE, &mut main_fut).await {
Ok(res) => res?,
Err(_) => {
debug!(
rpc_id = %rpc_id_for_cancel,
"SIGINT: cancel grace elapsed; synthesising cancelled error"
"cancel grace elapsed; synthesising cancelled error"
);
return Err(CliError::from_rpc(RpcError {
code: ErrorCode::Cancelled,
@@ -112,6 +120,46 @@ where
outcome.map_err(CliError::from_rpc)
}
/// Release caller-owned staging even after cancellation. This deliberately
/// exposes only transfer.release, not a general bypass for business RPCs.
/// The entire batch, including connection setup, shares one bounded budget.
pub fn release_transfers<'a>(
sock: &Path,
transfer_ids: impl IntoIterator<Item = &'a str>,
) -> Result<(), CliError> {
use bsk_protocol::tools::{TransferIdParams, TransferReleaseResult};
const CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
let ids: Vec<_> = transfer_ids.into_iter().collect();
if ids.is_empty() {
return Ok(());
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build runtime for transfer cleanup")?;
rt.block_on(async {
tokio::time::timeout(CLEANUP_TIMEOUT, async {
let mut client = IpcClient::connect(sock).await?;
for id in ids {
client
.call::<_, TransferReleaseResult>(
"transfer-release",
Method::TransferRelease,
Some(TransferIdParams {
transfer_id: id.to_string(),
}),
CLEANUP_TIMEOUT,
)
.await?
.map_err(CliError::from_rpc)?;
}
Ok::<_, CliError>(())
})
.await
.context("transfer cleanup exceeded its total time budget")?
})
}
/// Send a `cancel { rpc_id }` frame over a fresh connection so it
/// lands on the daemon while the original call is still parked.
///
@@ -146,8 +194,57 @@ fn random_short_id() -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Wait for either Ctrl-C or process SIGTERM. Returning here means
/// the user has asked us to stop.
/// Opt-in control pipe for parent processes that cannot send Ctrl-C (Node on
/// Windows). One reader survives the short-lived runtimes used by multi-RPC
/// commands such as upload. Ordinary CLI stdin and terminal Ctrl-C are unchanged.
fn stdin_cancellation() -> Option<tokio::sync::watch::Receiver<bool>> {
if std::env::var("BSK_CANCEL_ON_STDIN_CLOSE").as_deref() != Ok("1") {
return None;
}
static CANCEL: std::sync::OnceLock<tokio::sync::watch::Receiver<bool>> =
std::sync::OnceLock::new();
Some(
CANCEL
.get_or_init(|| {
let (tx, rx) = tokio::sync::watch::channel(false);
std::thread::spawn(move || {
use std::io::Read;
let mut stdin = std::io::stdin().lock();
let mut buf = [0; 64];
loop {
match stdin.read(&mut buf) {
Ok(0) => break,
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
let _ = tx.send(true);
});
rx
})
.clone(),
)
}
async fn wait_for_cancel(
parent_cancel: Option<tokio::sync::watch::Receiver<bool>>,
) -> anyhow::Result<()> {
let parent_closed = async move {
match parent_cancel {
Some(mut rx) => {
let _ = rx.wait_for(|closed| *closed).await;
}
None => std::future::pending::<()>().await,
}
};
tokio::select! {
result = wait_for_sigint() => result,
_ = parent_closed => Ok(()),
}
}
/// Wait for terminal Ctrl-C.
async fn wait_for_sigint() -> anyhow::Result<()> {
tokio::signal::ctrl_c().await.context("listen for SIGINT")?;
Ok(())
+2 -9
View File
@@ -9,8 +9,7 @@ use anyhow::Context;
use base64::Engine;
use bsk_protocol::Method;
use bsk_protocol::tools::{
DownloadParams, DownloadResult, TransferChunkParams, TransferChunkResult, TransferIdParams,
TransferReleaseResult,
DownloadParams, DownloadResult, TransferChunkParams, TransferChunkResult,
};
use clap::Args;
use uuid::Uuid;
@@ -70,13 +69,7 @@ pub fn dispatch(args: DownloadArgs, format: Format) -> Result<(), CliError> {
CliError::Local(anyhow::anyhow!("daemon returned no download transfer id"))
})?;
let write_result = write_transfer(&info.sock_path, &transfer_id, &args.out, args.overwrite);
let _: Result<TransferReleaseResult, CliError> = crate::cli::business_rpc::call(
info.sock_path,
"transfer-release",
Method::TransferRelease,
Some(TransferIdParams { transfer_id }),
Duration::from_secs(5),
);
let _ = crate::cli::business_rpc::release_transfers(&info.sock_path, [transfer_id.as_str()]);
write_result?;
match format {
Format::Json => {
-20
View File
@@ -40,7 +40,6 @@ use bsk_protocol::ErrorCode;
pub mod reason {
pub const AGENT_WINDOW_SCOPE: &str = "agent_window_scope";
pub const ELEMENT_NOT_VISIBLE: &str = "element_not_visible";
pub const REF_CAPABILITY_DENIED: &str = "ref_capability_denied";
pub const REF_NOT_FOUND: &str = "ref_not_found";
pub const SELECTOR_NOT_FOUND: &str = "selector_not_found";
pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable";
@@ -300,13 +299,6 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
),
exit_code: base.exit_code,
},
(ErrorCode::PermissionDenied, reason::REF_CAPABILITY_DENIED) => RenderInfo {
summary: "snapshot ref does not support this operation",
hint: Some(
"visual surface refs are screenshot-only; inspect `bsk screenshot --ref <ref> --session <id>` with image-understanding capability",
),
exit_code: base.exit_code,
},
(ErrorCode::InvalidParams, reason::TARGET_NOT_SELECT) => RenderInfo {
summary: "target element is not a <select>",
hint: Some(
@@ -572,18 +564,6 @@ mod tests {
assert_eq!(info.exit_code, 3);
}
#[test]
fn ref_capability_denied_explains_visual_surface_refs() {
let data = serde_json::json!({ "reason": reason::REF_CAPABILITY_DENIED });
let info = info_for_error(ErrorCode::PermissionDenied, Some(&data));
assert_eq!(info.summary, "snapshot ref does not support this operation");
assert!(
info.hint.unwrap().contains("screenshot-only"),
"expected visual-surface-specific hint"
);
assert_eq!(info.exit_code, 1);
}
#[test]
fn session_busy_overrides_timeout_copy() {
let data = serde_json::json!({ "reason": reason::SESSION_BUSY });
+48 -51
View File
@@ -10,8 +10,7 @@ use base64::Engine;
use bsk_protocol::Method;
use bsk_protocol::tools::{
TransferBeginParams, TransferBeginResult, TransferChunkParams, TransferChunkResult,
TransferIdParams, TransferReadyResult, TransferReleaseResult, UploadFile, UploadMode,
UploadParams, UploadResult,
TransferIdParams, TransferReadyResult, UploadFile, UploadMode, UploadParams, UploadResult,
};
use clap::{Args, ValueEnum};
@@ -69,14 +68,12 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
let (ref_, selector) = split_target(args.target, args.ref_, args.selector)?;
let mut staged = Vec::new();
for path in &args.files {
match stage_file(&info.sock_path, &args.session, path) {
Ok(file) => staged.push(file),
Err(err) => {
for (id, _) in &staged {
let _ = release(&info.sock_path, id);
}
return Err(err);
}
if let Err(err) = stage_file(&info.sock_path, &args.session, path, &mut staged) {
let _ = crate::cli::business_rpc::release_transfers(
&info.sock_path,
staged.iter().map(|(id, _)| id.as_str()),
);
return Err(err);
}
}
let params = UploadParams {
@@ -102,6 +99,17 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
Some(params),
ipc_timeout(args.timeout),
);
// The helper can reject cancellation before handing any request to the
// daemon. In that case staging still belongs to this CLI.
if result.as_ref().is_err_and(|err| {
err.data()
.is_some_and(|data| data["reason"] == "cancelled_before_dispatch")
}) {
let _ = crate::cli::business_rpc::release_transfers(
&info.sock_path,
staged.iter().map(|(id, _)| id.as_str()),
);
}
// Once tool.upload is dispatched, staging ownership belongs to the
// session. A transport timeout cannot prove that Chrome did not attach
// the file, so releasing here could invalidate a late successful attach.
@@ -118,7 +126,12 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
Ok(())
}
fn stage_file(sock: &Path, session: &str, path: &PathBuf) -> Result<(String, String), CliError> {
fn stage_file(
sock: &Path,
session: &str,
path: &PathBuf,
staged: &mut Vec<(String, String)>,
) -> Result<(), CliError> {
let mut file = File::open(path)
.with_context(|| format!("open upload file {}", path.display()))
.map_err(CliError::Local)?;
@@ -149,55 +162,39 @@ fn stage_file(sock: &Path, session: &str, path: &PathBuf) -> Result<(String, Str
}),
Duration::from_secs(10),
)?;
let staged = (|| {
let mut offset = 0u64;
let mut buf = vec![0u8; begin.chunk_size as usize];
loop {
let n = file.read(&mut buf).map_err(|e| CliError::Local(e.into()))?;
if n == 0 {
break;
}
let reply: TransferChunkResult = crate::cli::business_rpc::call(
sock.to_path_buf(),
"transfer-chunk",
Method::TransferChunk,
Some(TransferChunkParams {
transfer_id: begin.transfer_id.clone(),
offset,
data_base64: base64::engine::general_purpose::STANDARD.encode(&buf[..n]),
}),
Duration::from_secs(30),
)?;
offset = reply.next_offset;
// Record ownership immediately so one error path releases both completed
// files and the current partially staged file.
staged.push((begin.transfer_id.clone(), name));
let mut offset = 0u64;
let mut buf = vec![0u8; begin.chunk_size as usize];
loop {
let n = file.read(&mut buf).map_err(|e| CliError::Local(e.into()))?;
if n == 0 {
break;
}
let _: TransferReadyResult = crate::cli::business_rpc::call(
let reply: TransferChunkResult = crate::cli::business_rpc::call(
sock.to_path_buf(),
"transfer-finish",
Method::TransferFinish,
Some(TransferIdParams {
"transfer-chunk",
Method::TransferChunk,
Some(TransferChunkParams {
transfer_id: begin.transfer_id.clone(),
offset,
data_base64: base64::engine::general_purpose::STANDARD.encode(&buf[..n]),
}),
Duration::from_secs(10),
Duration::from_secs(30),
)?;
Ok::<_, CliError>(())
})();
if let Err(err) = staged {
let _ = release(sock, &begin.transfer_id);
return Err(err);
offset = reply.next_offset;
}
Ok((begin.transfer_id, name))
}
fn release(sock: &Path, id: &str) -> Result<TransferReleaseResult, CliError> {
crate::cli::business_rpc::call(
let _: TransferReadyResult = crate::cli::business_rpc::call(
sock.to_path_buf(),
"transfer-release",
Method::TransferRelease,
"transfer-finish",
Method::TransferFinish,
Some(TransferIdParams {
transfer_id: id.to_string(),
transfer_id: begin.transfer_id.clone(),
}),
Duration::from_secs(5),
)
Duration::from_secs(10),
)?;
Ok(())
}
fn ipc_timeout(timeout_ms: u32) -> Duration {
@@ -0,0 +1,342 @@
//! Real Windows CLI cancellation, including caller-owned transfer cleanup.
#![cfg(windows)]
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use bsk::daemon::{file_transfer::TransferRegistry, info::DaemonInfo};
use bsk_protocol::tools::{TransferBeginParams, TransferIdParams};
use bsk_protocol::{ErrorCode, Frame, Method, ResponseBody, ResponseFrame, RpcError};
use serde_json::json;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::windows::named_pipe::ServerOptions;
use tokio::process::Command;
use tokio::sync::{Notify, watch};
fn response<T: serde::Serialize>(result: Result<T, RpcError>) -> ResponseBody {
match result {
Ok(value) => ResponseBody::Ok(serde_json::to_value(value).unwrap()),
Err(err) => ResponseBody::Err(err),
}
}
#[test]
fn stdin_close_cancels_business_but_releases_resources_with_a_bounded_budget() {
// Resolve the production registry's root before starting any runtime threads.
let registry_home = tempfile::tempdir().unwrap();
let original_home = std::env::var_os("BSK_HOME");
unsafe {
std::env::set_var("BSK_HOME", registry_home.path());
}
let transfers = Arc::new(TransferRegistry::new().unwrap());
unsafe {
match original_home {
Some(value) => std::env::set_var("BSK_HOME", value),
None => std::env::remove_var("BSK_HOME"),
}
}
transfers.initialize().unwrap();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(exercise(
transfers,
&registry_home.path().join("run/transfers"),
));
}
async fn exercise(transfers: Arc<TransferRegistry>, staging_root: &Path) {
for mode in [
"wait",
"upload",
"upload-multi",
"upload-before-dispatch",
"download",
"upload-multi-stall",
"ordinary",
] {
let home = tempfile::tempdir().unwrap();
let pipe_name = format!(r"\\.\pipe\bsk-parent-cancel-{}", uuid::Uuid::new_v4());
let info = DaemonInfo::now(std::process::id(), (&pipe_name).into(), 0, "0.2.0");
std::fs::write(
home.path().join("daemon.json"),
serde_json::to_vec(&info).unwrap(),
)
.unwrap();
let mut pipe = ServerOptions::new()
.first_pipe_instance(true)
.create(&pipe_name)
.unwrap();
let registered = Arc::new(Notify::new());
let pending = Arc::new(Mutex::new(None::<String>));
let ids = Arc::new(Mutex::new(Vec::<String>::new()));
let released = Arc::new(Mutex::new(Vec::<String>::new()));
let received = Arc::new(Mutex::new(Vec::<Method>::new()));
let download_id = if mode == "download" {
let staging = transfers.begin_download("fixture").unwrap();
let browser_dir = home.path().join("BrowserSkill").join(&staging.transfer_id);
std::fs::create_dir_all(&browser_dir).unwrap();
let file = browser_dir.join("download.txt");
std::fs::write(&file, b"download fixture").unwrap();
transfers
.import_download(&staging.transfer_id, &file)
.unwrap();
ids.lock().unwrap().push(staging.transfer_id.clone());
Some(staging.transfer_id)
} else {
None
};
let (cancel, cancelled) = watch::channel(false);
let ready = Arc::clone(&registered);
let name = pipe_name.clone();
let registry = Arc::clone(&transfers);
let server_ids = Arc::clone(&ids);
let server_released = Arc::clone(&released);
let server_received = Arc::clone(&received);
let server = tokio::spawn(async move {
let mut connections = tokio::task::JoinSet::new();
loop {
pipe.connect().await.unwrap();
let next = ServerOptions::new().create(&name).unwrap();
let connection = std::mem::replace(&mut pipe, next);
let ready = Arc::clone(&ready);
let pending = Arc::clone(&pending);
let cancel = cancel.clone();
let mut cancelled = cancelled.clone();
let name = name.clone();
let registry = Arc::clone(&registry);
let ids = Arc::clone(&server_ids);
let released = Arc::clone(&server_released);
let received = Arc::clone(&server_received);
let download_id = download_id.clone();
connections.spawn(async move {
let (read, mut write) = tokio::io::split(connection);
let mut reader = BufReader::new(read);
let mut line = String::new();
while reader.read_line(&mut line).await.unwrap_or(0) > 0 {
let Frame::Request(req) = serde_json::from_str::<Frame>(&line).unwrap()
else {
panic!("expected request");
};
line.clear();
received.lock().unwrap().push(req.method.clone());
let params = req.params.clone().unwrap_or_default();
let body = match req.method {
Method::SystemStatus => ResponseBody::Ok(json!({
"daemon_version": "0.2.0", "protocol_version": "1.1",
"pid": std::process::id(), "uptime_secs": 0,
"ws_port": 0, "sock_path": name,
"browsers": [], "sessions": [], "version_skew_browsers": []
})),
Method::TransferBegin => {
let begin = registry
.begin_upload(serde_json::from_value(params).unwrap())
.unwrap();
ids.lock().unwrap().push(begin.transfer_id.clone());
response(Ok(begin))
}
Method::ToolDownload => ResponseBody::Ok(json!({
"tab_id": 1, "suggested_filename": "download.txt",
"byte_size": 16, "transfer_id": download_id,
})),
Method::ToolWaitMs if mode == "ordinary" => {
ResponseBody::Ok(json!({"waited_ms": 1}))
}
Method::TransferChunk
if mode == "upload-before-dispatch"
|| (mode.contains("multi")
&& ids.lock().unwrap().len() == 1) =>
{
response(
registry.write_chunk(serde_json::from_value(params).unwrap()),
)
}
Method::TransferFinish if mode != "upload-before-dispatch" => response(
registry.finish_upload(serde_json::from_value(params).unwrap()),
),
Method::ToolWaitMs
| Method::TransferChunk
| Method::TransferRead
| Method::TransferFinish => {
*pending.lock().unwrap() = Some(req.id.clone());
ready.notify_one();
cancelled.wait_for(|value| *value).await.unwrap();
if mode == "upload-before-dispatch" {
// Finish won the race with cancellation, but tool.upload must
// not be dispatched and the CLI still owns the staging.
response(
registry
.finish_upload(serde_json::from_value(params).unwrap()),
)
} else {
ResponseBody::Err(RpcError {
code: ErrorCode::Cancelled,
message: "parent cancellation reached daemon".into(),
data: None,
})
}
}
Method::Cancel => {
let target = params["rpc_id"].as_str().unwrap();
assert_eq!(pending.lock().unwrap().as_deref(), Some(target));
cancel.send(true).unwrap();
ResponseBody::Ok(json!({"cancelled": true}))
}
Method::TransferRelease => {
let params: TransferIdParams =
serde_json::from_value(params).unwrap();
released.lock().unwrap().push(params.transfer_id.clone());
if mode.contains("stall") {
std::future::pending::<()>().await;
}
response(Ok(registry.release(params)))
}
other => panic!("unexpected request after cancellation: {other:?}"),
};
let response = Frame::Response(ResponseFrame { id: req.id, body });
let mut bytes = serde_json::to_vec(&response).unwrap();
bytes.push(b'\n');
if write.write_all(&bytes).await.is_err() {
break;
}
}
});
}
});
let mut cmd = Command::new(env!("CARGO_BIN_EXE_bsk"));
cmd.env("BSK_HOME", home.path())
.env("BSK_AUTO_UPDATE", "off")
.env_remove("BSK_CANCEL_ON_STDIN_CLOSE")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.creation_flags(0x0800_0000)
.kill_on_drop(true);
if mode.starts_with("upload") {
let source = home.path().join("upload.txt");
// Declare 300 MiB without allocating or transferring that much data.
std::fs::File::create(&source)
.unwrap()
.set_len(if mode == "upload-before-dispatch" {
16
} else {
300 * 1024 * 1024
})
.unwrap();
cmd.args(["upload", "--session", "fixture", "--selector", "input"]);
if mode.contains("multi") {
let first = home.path().join("first.txt");
std::fs::write(&first, b"first file").unwrap();
cmd.arg("--file").arg(first);
}
cmd.arg("--file").arg(source);
} else if mode == "download" {
let out = home.path().join("out.txt");
std::fs::write(&out, b"existing output").unwrap();
cmd.args([
"download",
"--session",
"fixture",
"--selector",
"a",
"--overwrite",
"--out",
])
.arg(out);
} else {
cmd.args(["wait-ms", if mode == "ordinary" { "1ms" } else { "60s" }]);
}
cmd.arg("--json");
if mode == "ordinary" {
cmd.stdin(std::process::Stdio::null());
} else {
cmd.env("BSK_CANCEL_ON_STDIN_CLOSE", "1")
.stdin(std::process::Stdio::piped());
}
let mut child = cmd.spawn().unwrap();
if mode != "ordinary" {
tokio::time::timeout(Duration::from_secs(10), registered.notified())
.await
.expect("CLI registered business RPC");
child
.stdin
.as_mut()
.unwrap()
.write_all(b"still connected")
.await
.unwrap();
drop(child.stdin.take());
}
let start = Instant::now();
let output = tokio::time::timeout(Duration::from_secs(8), child.wait_with_output())
.await
.expect("CLI must settle promptly, including cleanup")
.unwrap();
server.abort();
let _ = server.await;
assert_eq!(
output.status.code(),
Some(if mode == "ordinary" { 0 } else { 2 }),
"{mode}: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if mode != "ordinary" {
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(body["code"], "cancelled");
if mode != "upload-before-dispatch" {
assert_eq!(body["message"], "parent cancellation reached daemon");
}
let received = received.lock().unwrap();
let cancel = received.iter().position(|m| *m == Method::Cancel).unwrap();
assert!(
received[cancel + 1..]
.iter()
.all(|m| *m == Method::TransferRelease),
"only cleanup may follow cancellation: {received:?}"
);
}
let ids = ids.lock().unwrap();
if mode.contains("stall") {
assert_eq!(released.lock().unwrap().len(), 1);
assert!(start.elapsed() >= Duration::from_secs(4));
// The CLI cannot force an unresponsive daemon to clean up.
transfers.release_session("fixture");
} else {
assert_eq!(
*released.lock().unwrap(),
*ids,
"{mode}: every allocation must be released"
);
for id in ids.iter() {
assert!(!staging_root.join(id).exists(), "staging leaked: {id}");
}
if mode.starts_with("upload") {
let retry = transfers
.begin_upload(TransferBeginParams {
session_id: "fixture".into(),
name: "retry.txt".into(),
byte_size: 300 * 1024 * 1024,
})
.expect("same session must have room for another 300 MiB upload");
transfers.release(TransferIdParams {
transfer_id: retry.transfer_id,
});
}
}
if mode == "download" {
assert_eq!(
std::fs::read(home.path().join("out.txt")).unwrap(),
b"existing output"
);
assert!(
!std::fs::read_dir(home.path())
.unwrap()
.flatten()
.any(|entry| entry.file_name().to_string_lossy().ends_with(".part"))
);
}
}
}
+41 -15
View File
@@ -21,6 +21,7 @@ $ErrorActionPreference = "Stop"
$Repo = if ($env:BSK_REPO) { $env:BSK_REPO } else { "Tencent/BrowserSkill" }
$InstallDir = if ($env:BSK_INSTALL_DIR) { $env:BSK_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
$InstallDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($InstallDir)
$GitHub = "https://github.com/${Repo}"
function Write-Log {
@@ -64,7 +65,7 @@ function Get-PlatformTriple {
function Add-ToUserPath {
param([string]$Dir)
$currentUserPath = [Environment]::GetEnvironmentVariable("PATH", "User") -split ";" | Where-Object { $_ }
$currentUserPath = @([Environment]::GetEnvironmentVariable("PATH", "User") -split ";" | Where-Object { $_ })
if ($currentUserPath -contains $Dir) {
Write-Log "$Dir is already in your user PATH"
@@ -90,30 +91,59 @@ function Add-ToSessionPath {
# ── Git Bash (bash environment) PATH helper ──────────────────────────────────
function Add-ToBashProfile {
param([string]$Dir)
param([string]$Dir, [string]$BashRc = (Join-Path $HOME ".bashrc"))
# Convert Windows path (e.g. C:\Users\foo\.local\bin) to Git-Bash Unix-style (/c/Users/foo/.local/bin)
$unixPath = $Dir -replace '\\', '/'
if ($unixPath -match '^([A-Z]):(.*)$') {
$unixPath = '/' + $matches[1].ToLower() + $matches[2]
}
$bashRc = Join-Path $HOME ".bashrc"
$exportLine = "export PATH=""${unixPath}:`$PATH"" # bsk CLI"
# Single-quote the literal directory; only the existing PATH is expanded.
$shellQuote = "'" + [char]34 + "'" + [char]34 + "'"
$quotedPath = "'" + $unixPath.Replace("'", $shellQuote) + "'"
$exportLine = "export PATH=${quotedPath}:`"`$PATH`" # bsk CLI"
if (Test-Path $bashRc) {
$content = Get-Content $bashRc -Raw -ErrorAction SilentlyContinue
if ($content -match [regex]::Escape($unixPath)) {
if (Test-Path -LiteralPath $BashRc) {
$content = [System.IO.File]::ReadAllText($BashRc)
if ($content.Contains($exportLine)) {
Write-Log "$unixPath is already in ~/.bashrc"
return
}
}
Add-Content $bashRc "`n$exportLine" -Encoding ASCII
# Explicit BOM-less UTF-8 also works in Windows PowerShell 5.1.
[System.IO.File]::AppendAllText($BashRc, "`n$exportLine`n", (New-Object System.Text.UTF8Encoding($false)))
Write-Log "added ${unixPath} to ~/.bashrc"
}
# ── Main ──────────────────────────────────────────────────────────────────────
# Stage on the destination volume before stopping the daemon. Never truncate
# the installed executable: failed replacement must leave it usable.
function Install-Binary {
param([string]$Source, [string]$Target)
$staged = "$Target.install-$([Guid]::NewGuid().ToString('N'))"
try {
[System.IO.File]::Copy($Source, $staged)
if ([System.IO.File]::Exists($Target)) {
# Use the downloaded CLI: older versions have broken Windows
# liveness checks. Stop verifies daemon identity before terminating it.
& $Source daemon stop
if ($LASTEXITCODE -ne 0) { throw "could not stop bsk daemon; existing installation was not replaced" }
# PowerShell 5.1 converts $null to an empty path for string parameters.
[System.IO.File]::Replace($staged, $Target, [NullString]::Value)
Write-Log "daemon will restart automatically on the next browser command"
}
else {
[System.IO.File]::Move($staged, $Target)
}
}
finally {
if ([System.IO.File]::Exists($staged)) { [System.IO.File]::Delete($staged) }
}
}
function Main {
$platform = Get-PlatformTriple
@@ -184,7 +214,7 @@ function Main {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
Copy-Item -Path (Join-Path $tempDir "bsk.exe") -Destination (Join-Path $InstallDir "bsk.exe") -Force
Install-Binary -Source (Join-Path $tempDir "bsk.exe") -Target (Join-Path $InstallDir "bsk.exe")
Write-Log "installed bsk to $InstallDir\bsk.exe"
@@ -199,12 +229,8 @@ function Main {
# Verify
$bskPath = Join-Path $InstallDir "bsk.exe"
if (Get-Command bsk -ErrorAction SilentlyContinue) {
& bsk --version
}
else {
Write-Log "verify install: & ""$bskPath"" --version"
}
& $bskPath --version
if ($LASTEXITCODE -ne 0) { throw "installed bsk failed verification" }
Write-Log "done"
Write-Host ""
+138 -154
View File
@@ -1,13 +1,51 @@
# dsh-plugin-browserskill
# BrowserSkill for DeepSeek Harness
npm: [`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)
[![npm version](https://img.shields.io/npm/v/@wxg-prc-cpg/browser-skill-dsh-plugin)](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) tool plugin that exposes
[BrowserSkill](https://github.com/Tencent/BrowserSkill) (`bsk`) browser automation to the model.
Use [BrowserSkill](https://github.com/Tencent/BrowserSkill) in
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) to browse
websites, fill forms, and capture screenshots through native `browser_*` tools.
Browser tasks run in Agent Windows, with a live view in the dsh Web UI.
Each action maps to one `bsk <cmd> --json` invocation: the plugin spawns the bsk CLI, parses its
structured JSON output, and returns a canonical typed value. The bsk daemon, browser, and browser
extension keep owning the actual browser control — this package is a thin, well-typed bridge.
## Installation
Before installing the plugin:
- Install [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) and
[pnpm](https://pnpm.io/installation), which dsh uses to manage plugins.
- Install the `bsk` CLI and connect the BrowserSkill extension in Chrome or Edge.
Follow the [BrowserSkill setup guide](https://github.com/Tencent/BrowserSkill#quick-start).
- Make sure `bsk` is on the `PATH` used to start dsh, or set `bskPath` in the plugin configuration.
Install the plugin into the `web` profile, then start it:
```sh
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
dsh --profile web
```
Replace `web` with your profile name if you use a different profile. The plugin
includes the `browser-skill` skill; no separate `bsk install-skill` step is needed.
In a conversation, try:
```text
/browser-skill open example.com and summarize the page.
```
By default, the browser tools become available when the skill is invoked.
## Updating
Installed plugins do not update automatically. To upgrade this plugin to npm's
`latest` version, including versions outside the profile's saved dependency range:
```sh
dsh plugin --profile web update @wxg-prc-cpg/browser-skill-dsh-plugin --latest
```
Restart that dsh profile after upgrading. This command updates the plugin; update
the `bsk` CLI and browser extension separately when a release requires it.
## Tools
@@ -20,23 +58,7 @@ extension keep owning the actual browser control — this package is a thin, wel
| `browser_tabs` | `list`, `create`, `select`, `close`, `borrow`, `return` | Manage Agent Window tabs and temporarily borrow user tabs. |
| `browser_assist` | `resize`, `emulate`, `request-help` | Resize or emulate the browser and pause for human-only steps. |
`bsk evaluate` and `bsk record` are intentionally not exposed by this plugin: arbitrary page
evaluation is a higher-risk capability, while recording is long-running and needs a dedicated
DeepSeek Harness lifecycle before it can be added safely.
## Agent skill (progressive disclosure)
Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's
official skill seam (`ctx.skills.register`): the catalog entry (name + routing description) is
resident in `<available_skills>`, and the body is loaded only when the model invokes the `skill`
tool. Its single source is the DSH-specific `skill/SKILL.md`, which documents only structured
`browser_*` calls and their plugin semantics. The repository-root CLI skill is intentionally not
concatenated: its command examples belong to a different execution interface and would bypass
the plugin's ownership, live observation UI, cancellation, and cleanup path if followed directly.
The build rejects internal CLI-name leakage, command-line code blocks, unknown browser tools, and
missing supported browser tools before embedding the Markdown. Registration and every pre-step
catalog snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill
seam degrade silently.
Arbitrary page-script evaluation and interaction recording are not supported.
## Multi-session model
@@ -55,136 +77,73 @@ an explicit `session` argument naming a foreign or unknown id is rejected, the `
`browser_session` shows plugin-created sessions only (no daemon-wide view), and stop/unload cleanup
can never touch a session owned by another program.
## Installation
The plugin follows the standard dsh bundle layout (`dsh.bundle` manifest + `cordis.patch.yml`):
```sh
dsh plugin --profile <name> add @wxg-prc-cpg/browser-skill-dsh-plugin
dsh --profile <name>
```
Prerequisite: the `bsk` CLI must be installed and on `PATH`, and the BrowserSkill browser extension
(Chrome or Edge) must be connected — see the
[BrowserSkill README](https://github.com/Tencent/BrowserSkill). When bsk is missing, tool calls fail
with install guidance instead of a bare spawn error.
## Configuration
All fields are optional and validated through the plugin's Schemastery `Config`:
After installing the plugin, edit your profile's `cordis.patch.yml`. For the `web`
profile, the default location is `~/.dsh/profiles/web/cordis.patch.yml`. If you set
`DSH_HOME`, use `$DSH_HOME/profiles/web/cordis.patch.yml` instead. Replace `web` with
your profile name as needed.
If the file contains only comments and `[]`, keep the comments and replace `[]`
with the YAML below. If it already contains patch entries, add this entry to the
existing list or edit its existing `id: browserskill` entry. Keep a single
top-level YAML list. This overrides the plugin registered by the installed bundle:
```yaml
# cordis.patch.yml override example
- insert:
- id: browserskill
name: "@wxg-prc-cpg/browser-skill-dsh-plugin"
config:
bskPath: bsk # path to the bsk binary (default: resolve from PATH)
defaultTimeoutMs: 120000
maxSessions: 5
# observationEnabled: true # live PiP/overlay observation (below)
# thumbnailIntervalMs: 1500 # frame cadence while a session is active
# idleIntervalMs: 8000 # idle cadence / recent-activity window
# lazyTools: true # reveal browser_* tools only after the skill is invoked
- id: browserskill
config:
bskPath: bsk
defaultTimeoutMs: 120000
maxSessions: 5
observationEnabled: true
thumbnailIntervalMs: 1500
idleIntervalMs: 8000
lazyTools: true
```
- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the six
`browser_*` tool schemas stay OUT of the system prompt (zero schema tokens) until the
`browser-skill` skill is actually invoked — the skill catalog entry is the only
advertisement. One successful invocation (model tool call, or a `/browser-skill` user
gesture) registers the whole suite for the rest of the process; repeated invocations are
no-ops, and sessions resumed with a past invocation in their durable log reveal the suite
on entry. Set `false` for the legacy always-on registration.
Change `bskPath` to the full path of your CLI binary if it is not on dsh's `PATH`.
A patch replaces the entry's entire `config` object, so keep all overrides you need
together in that object.
## Observation overlay (PiP mini-window)
Configuration changes follow `dsh.profile.patchReload` in the profile's
`package.json`: `live` (the default for `web`) applies changes when you save the
patch file; `startup` requires restarting the profile. Restart after upgrading
the plugin in either case.
When the plugin runs inside the dsh Web UI, an **observation overlay** floats over the app
(registered into the `shell.overlay` seat): a breathing thumbnail per owned session plus its
current action and elapsed time. The card docks at the top-right of the content area (clear of
the composer and the shell's header controls) and wears the **BrowserSkill product family's own
look**: the overlay reuses `@browser-skill/ui` components (`Button`, `cn`) and its oklch design
tokens (`--card`, `--primary`, `--destructive`, `--ring`, …), status dots spec'd after the
extension popup's connection indicator, and Remix icons. The BSK utility sheet is compiled
scoped under the `.bsk-obs` root class (`scripts/build-client-css.mjs`), so the overlay looks
like the BrowserSkill extension without leaking a single selector into the host shell — and the
shell's theme cannot bleed back in.
All fields are optional; omitted fields use the defaults below:
- **Lifecycle**: hidden while the plugin owns no sessions; appears on the first
`browser_session` start action; disappears when all sessions stop (or the plugin unloads).
- **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page
frame (while viewed, refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on
stage while the next one loads, and is kept on errors so the card does not flash),
and a compact icon toolbar (Interrupt + Pop out, hover for the label).
- **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics
as the chat Stop button — the current action fails, the agent run may continue). Strip items
carry their own hover interrupt button.
- **Multi-session strip**: every session gets a tile (mini frame + id + status dot); focus
auto-follows the most recently active session; clicking a tile pins focus (pin badge, click
again to release); errored sessions get a red edge without stealing focus; sessions the daemon
lost are greyed out; prolonged daemon/browser outage shows "browser unavailable" and greys
the interrupt button until captures recover.
- **Drag & resize**: drag the header to move the card; drag any of the four
corners to resize (min 240×180, max 80% of the viewport; no visible grip).
Both are remembered for the page lifetime.
- **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user
gesture, per browser rules), sized from the current card; closing the PiP falls back to the
in-page card with state intact. Browsers without Document PiP simply hide the button.
- **Screenshot demand**: the client requests periodic screenshots while an observation view is
visible, using the PiP document's visibility when popped out. Hidden or collapsed views can
keep subscribing to state without requesting screenshots. The first screenshot subscriber
starts capture scheduling; the last one's departure cancels timers and skips queued captures.
An already running capture may finish. Demand applies to the service's owned sessions, not just
the selected session.
- **Wire**: `GET /bsk-observation/events` is the live synchronization channel (SSE). Every connection,
including reconnects, starts with `{ type: "snapshot", sessions, available }`, followed by
`upsert`, `remove`, `reset`, and `availability` events on the same stream. Replace local state
with each snapshot before applying subsequent events. Use `?thumbnails=0` for state-only
subscriptions or `?thumbnails=1` to request screenshots; omitting the parameter defaults to
requesting screenshots for compatibility. Closing the stream releases its screenshot demand.
`GET /bsk-observation/state` remains a one-time `{ sessions, available }` read and does not
request screenshots. It has no ordering guarantee relative to a separate SSE connection;
live clients should initialize and resynchronize from the stream's snapshot instead.
The host also serves `POST /bsk-observation/interrupt`, `POST /bsk-observation/stop`, and
`GET /bsk-observation/thumbnail/<attachmentId>` through the dsh `webServer` route seam
(dsh 0.1's Typert Remote pipeline is closed to out-of-tree packages). All commands for one
session — tool calls and frame captures alike — run through a per-session FIFO, because the
daemon accepts only one unfinished command per session.
- **Programmatic subscription**: `ObservationService.subscribe(listener, { thumbnails: false })`
subscribes to state without requesting screenshots. On an active service, the listener receives
the initial `snapshot` synchronously, before `subscribe` returns, then receives subsequent
changes. `thumbnails` defaults to `true`, so callers should choose it explicitly. The returned
unsubscribe function is safe to call repeatedly and releases this subscription's screenshot
demand. Subscribing after service disposal is a no-op.
- **Trust model**: these routes expose live screenshots (and an interrupt write), so they
replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host
must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must
match the Host, `sec-fetch-site: cross-site` is refused, and POST requires an
`application/json` body (cross-site simple requests can never satisfy that). The channel is
therefore built for **loopback-only serving** — binding the dsh web server to `0.0.0.0` and
reaching it through a LAN address will (deliberately) fail the fence; do not put these
routes behind a non-loopback reverse proxy without adding your own authentication.
- Configure with `observationEnabled` / `thumbnailIntervalMs` / `idleIntervalMs`.
| Option | Default | Purpose |
| --- | --- | --- |
| `bskPath` | `bsk` | Path to the CLI binary. |
| `defaultTimeoutMs` | `120000` | Default command timeout in milliseconds. |
| `maxSessions` | `5` | Maximum concurrent sessions started by this plugin. |
| `observationEnabled` | `true` | Enable live browser observation. |
| `thumbnailIntervalMs` | `1500` | Screenshot interval for active sessions, in milliseconds. |
| `idleIntervalMs` | `8000` | Screenshot interval for idle sessions and the recent-activity window, in milliseconds. |
| `lazyTools` | `true` | Reveal the browser tools when the skill is invoked. Set `false` to register them at startup. |
## Behavior notes
With `lazyTools: true`, only the skill's catalog entry is initially advertised to
the model. The six `browser_*` tool schemas are added to the system prompt after
the `browser-skill` skill is successfully invoked, either by the model or through
`/browser-skill`. Set `lazyTools: false` to make the tools available immediately.
- **Cancellation**: aborting a tool call (`exec.signal`) kills the underlying bsk child process,
matching BrowserSkill's cooperative tool-cancellation model.
- **UI cards**: calls render as terminal cards (command line as title, output as the completed
card). Screenshots additionally attach the image itself when the host mounts an attachment store
and the active model route declares image input; otherwise the PNG path is returned.
- **Web UI toolview (browser half)**: the package is dual-face. `dsh.client` (platform `web`) ships
`lib/client.cjs`, which registers a keyed `tool.call.toolview` view for `browser_inspect`. The
custom view keeps a terminal block for every inspect action and, when a screenshot result carries
an image block, resolves the durable attachment through the client session's authorized
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
The other five tools keep the stock terminal card. The bundle follows the dsh client
contract: a CJS closure factory handed to `window.__ModuleLoader__.load`, platform modules
(`react`, `dsh-client-ui-*`) external, everything else inlined, CSS Modules compiled by
lightningcss.
- **Errors**: non-zero bsk exits surface the CLI's JSON error envelope (`code`, `message`, `hint`)
so the model gets the daemon's actionable guidance.
- **Long-running work** (e.g. `bsk record`) is not backgrounded via `ctx.jobs` yet — tracked as a
follow-up.
## Live browser view
The dsh Web UI shows the plugin's browser sessions in a floating panel. If your
profile provides the `dsh-better-sidebar` integration, the view appears in a
**Browser Skill** sidebar tab instead.
- See the current action, elapsed time, and recent screenshot for each session.
- Select a session to focus on it. The sidebar view follows the current conversation.
- Use **Interrupt** to cancel the current browser command. The agent may continue
with another action afterward.
- Drag or resize the floating panel, or use **Pop out** to open a Picture-in-Picture
window in browsers that support it.
- Periodic screenshots are requested while a browser observation view is visible.
Configure the active and idle intervals with the options above.
The observation endpoints require a loopback address such as `localhost` or
`127.0.0.1`. Access through a LAN hostname or non-loopback reverse proxy is not supported.
## Development
@@ -195,22 +154,47 @@ pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin test # unit tests mock b
pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin build # tsdown -> lib/
```
See the [development notes](https://github.com/Tencent/BrowserSkill/blob/main/packages/dsh-plugin-browserskill/docs/development.md)
for skill registration, tool results, and observation APIs in the current source.
## Publishing
The GitHub Actions workflow **Release dsh plugin** publishes this package to npm
as `@wxg-prc-cpg/browser-skill-dsh-plugin`. The Cordis plugin id, client
bundle registration, and `cordis.patch.yml` name are the same specifier so
dsh can import and materialize the plugin without a ModuleLoader id mismatch.
The [Release dsh plugin workflow](https://github.com/Tencent/BrowserSkill/blob/main/.github/workflows/release-dsh-plugin.yml)
publishes the package and this README to npm. Pushing a `dsh-plugin-vX.Y.Z` tag
triggers it; ordinary commits to `main` do not.
Trigger it by pushing a tag that matches `package.json`'s `version`:
1. Commit this README and any other changes intended for the release.
2. From the repository root, run the
[release script](https://github.com/Tencent/BrowserSkill/blob/main/scripts/release.mjs)
with a new stable version, replacing `<version>` below. The script updates the
CLI, extension, and DSH plugin versions, commits the version changes, and creates
their release tags:
```sh
git tag dsh-plugin-v0.1.2
git push origin dsh-plugin-v0.1.2
```
```sh
node scripts/release.mjs <version>
```
Or run the workflow from the Actions tab (`workflow_dispatch`). The job reads the
`NPM_TOKEN` secret from the `npm-publish` GitHub Environment.
3. Push the version commit to the release branch, then push the DSH plugin tag
created by the script, using the same `<version>`:
```sh
git push origin HEAD
git push origin dsh-plugin-v<version>
```
This publishes the DSH plugin. Push the CLI and extension tags separately when
those components are ready for release.
The workflow checks the version, runs typechecks and tests, builds the package,
and publishes it to npm.
You can also run the workflow manually from GitHub Actions on the intended release
ref. Both triggers require an unpublished version and the `NPM_TOKEN` secret in the
`npm-publish` GitHub Environment.
npm updates the package README only when a new version is published, including
for documentation-only changes. Published versions cannot be overwritten. See
[npm's README update rules](https://docs.npmjs.com/about-package-readme-files/).
## License
@@ -0,0 +1,90 @@
# DSH plugin development notes
For installation, usage, and configuration, see the [plugin README](../README.md).
These notes describe the current source on this branch; a published npm version may
not include later changes.
## Tool execution
The plugin runs the `bsk` CLI and parses its JSON output. The daemon and browser
extension perform the browser operations. The published package name is also the
Cordis plugin id, client bundle registration id, and `cordis.patch.yml` plugin name.
Keep these identifiers aligned so dsh can load both halves of the plugin.
## Agent skill
Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's
official skill seam (`ctx.skills.register`): the catalog entry (name + routing description) is
resident in `<available_skills>`, and the body is loaded only when the model invokes the `skill`
tool. Its single source is the DSH-specific `skill/SKILL.md`, which documents only structured
`browser_*` calls and their plugin semantics. The repository-root CLI skill is intentionally not
concatenated: its command examples belong to a different execution interface and would bypass
the plugin's ownership, live observation UI, cancellation, and cleanup path if followed directly.
The build rejects internal CLI-name leakage, command-line code blocks, unknown browser tools, and
missing supported browser tools before embedding the Markdown. Registration and every pre-step
catalog snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill
seam degrade silently.
With `lazyTools: true`, the six tool schemas stay out of the system prompt until
the skill is successfully invoked. Registration happens once and lasts until the
plugin is unloaded. Repeated invocations do not register duplicate tools. Entering
a resumed conversation whose history contains a successful skill invocation also
registers the tools. Setting `lazyTools: false` registers them at plugin startup.
## Observation subscriptions and routes
- **Screenshot demand**: the client requests periodic screenshots while an observation view is
visible, using the PiP document's visibility when popped out. Hidden or collapsed views can
keep subscribing to state without requesting screenshots. The first screenshot subscriber
starts capture scheduling; the last one's departure cancels timers and skips queued captures.
An already running capture may finish. Demand applies to the service's owned sessions, not just
the selected session.
- **Wire**: `GET /bsk-observation/events` is the live synchronization channel (SSE). Every connection,
including reconnects, starts with `{ type: "snapshot", sessions, available }`, followed by
`upsert`, `remove`, `reset`, and `availability` events on the same stream. Replace local state
with each snapshot before applying subsequent events. Use `?thumbnails=0` for state-only
subscriptions or `?thumbnails=1` to request screenshots; omitting the parameter defaults to
requesting screenshots for compatibility. Closing the stream releases its screenshot demand.
`GET /bsk-observation/state` remains a one-time `{ sessions, available }` read and does not
request screenshots. It has no ordering guarantee relative to a separate SSE connection;
live clients should initialize and resynchronize from the stream's snapshot instead.
The host also serves `POST /bsk-observation/interrupt`, `POST /bsk-observation/stop`, and
`GET /bsk-observation/thumbnail/<attachmentId>` through the dsh `webServer` route seam
(dsh 0.1's Typert Remote pipeline is closed to out-of-tree packages). All commands for one
session — tool calls and frame captures alike — run through a per-session FIFO, because the
daemon accepts only one unfinished command per session.
- **Programmatic subscription**: `ObservationService.subscribe(listener, { thumbnails: false })`
subscribes to state without requesting screenshots. On an active service, the listener receives
the initial `snapshot` synchronously, before `subscribe` returns, then receives subsequent
changes. `thumbnails` defaults to `true`, so callers should choose it explicitly. The returned
unsubscribe function is safe to call repeatedly and releases this subscription's screenshot
demand. Subscribing after service disposal is a no-op.
- **Trust model**: these routes expose live screenshots (and an interrupt write), so they
replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host
must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must
match the Host, `sec-fetch-site: cross-site` is refused, and POST requires an
`application/json` body (cross-site simple requests can never satisfy that). The channel is
therefore built for **loopback-only serving** — binding the dsh web server to `0.0.0.0` and
reaching it through a LAN address will (deliberately) fail the fence; do not put these
routes behind a non-loopback reverse proxy without adding your own authentication.
## Tool results and cancellation
- **Cancellation**: aborting a tool call (`exec.signal`) kills the underlying bsk child process,
matching BrowserSkill's cooperative tool-cancellation model.
- **UI cards**: calls render as terminal cards (command line as title, output as the completed
card). Screenshots additionally attach the image itself when the host mounts an attachment store
and the active model route declares image input; otherwise the PNG path is returned.
- **Web UI toolview (browser half)**: the package is dual-face. `dsh.client` (platform `web`) ships
`lib/client.cjs`, which registers a keyed `tool.call.toolview` view for `browser_inspect`. The
custom view keeps a terminal block for every inspect action and, when a screenshot result carries
an image block, resolves the durable attachment through the client session's authorized
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
The other five tools keep the stock terminal card. The bundle follows the dsh client
contract: a CJS closure factory handed to `window.__ModuleLoader__.load`, platform modules
(`react`, `dsh-client-ui-*`) external, everything else inlined, CSS Modules compiled by
lightningcss.
- **Errors**: non-zero bsk exits surface the CLI's JSON error envelope (`code`, `message`, `hint`)
so the model gets the daemon's actionable guidance.
- **Long-running work** (e.g. `bsk record`) is not backgrounded via `ctx.jobs` yet — tracked as a
follow-up.
@@ -47,7 +47,6 @@
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-tool",
"@deepseek-ai/dsh-client-ui-layout"
],
@@ -69,7 +68,6 @@
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
@@ -82,9 +80,6 @@
"@deepseek-ai/dsh-attachment": {
"optional": true
},
"@deepseek-ai/dsh-client-runtime": {
"optional": true
},
"@deepseek-ai/dsh-client-ui-attachment": {
"optional": true
},
@@ -7,6 +7,7 @@
*/
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
// Development-only types for the injected services; newer hosts no longer ship this package.
import type { ClientContext, ISessions, SessionId } from "@deepseek-ai/dsh-client-runtime/client";
// Type-only: pulls the 'shell.overlay' SlotMap merge into scope.
import type {} from "@deepseek-ai/dsh-client-ui-layout/client";
+51 -10
View File
@@ -5,7 +5,7 @@
* map the CLI's JSON error envelope onto a thrown `BskError`.
*/
import { type ChildProcess, spawn } from "node:child_process";
import { type ChildProcess, type SpawnOptionsWithoutStdio, spawn } from "node:child_process";
/** Shape of the JSON error envelope `bsk --json` prints on failure. */
export interface BskErrorBody {
@@ -53,7 +53,11 @@ export interface BskRunOptions {
}
/** Minimal spawn signature so tests can substitute a fake child process. */
export type SpawnImpl = (command: string, args: string[]) => ChildProcess;
export type SpawnImpl = (
command: string,
args: string[],
options?: SpawnOptionsWithoutStdio,
) => ChildProcess;
export interface BskRunner {
/** Run `bsk <args...> --json` and collect its output. */
@@ -64,29 +68,66 @@ export interface BskRunner {
killFor(tag: string): number;
}
// Business RPCs translate SIGINT into the daemon's cancel(rpc_id) protocol.
// Give that bounded reconciliation path time to settle before the hard kill.
// Business RPCs translate Ctrl-C / opt-in stdin EOF into cancel(rpc_id).
// Allow reconciliation before hard-killing an old or unresponsive CLI.
const KILL_GRACE_MS = 3000;
// Windows IPC may spend 5s connecting, 2s cancelling, 2s settling,
// and up to 5s releasing the entire batch of caller-owned transfers.
const WINDOWS_KILL_GRACE_MS = 15_000;
const SESSION_BUSY_RETRY_DELAY_MS = 100;
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
const live = new Map<ChildProcess, string | undefined>();
const windows = process.platform === "win32";
const cancelling = new Set<ChildProcess>();
function killChild(child: ChildProcess): void {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGINT");
const force = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, KILL_GRACE_MS);
if (child.exitCode !== null || child.signalCode !== null || cancelling.has(child)) return;
cancelling.add(child);
// Node kills Windows children outright for SIGINT. EOF asks the CLI to
// send its existing cancel RPC and wait for browser reconciliation.
if (windows && child.stdin) child.stdin.end();
else child.kill("SIGINT");
const force = setTimeout(
() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
},
windows ? WINDOWS_KILL_GRACE_MS : KILL_GRACE_MS,
);
force.unref();
child.once("close", () => {
clearTimeout(force);
cancelling.delete(child);
});
}
return {
run(args, options = {}) {
if (options.signal?.aborted) {
return Promise.resolve({
code: null,
stdout: "",
stderr: "",
timedOut: false,
aborted: true,
});
}
return new Promise<BskRunResult>((resolve, reject) => {
let child: ChildProcess;
try {
child = spawnImpl(bskPath, [...args, "--json"]);
child = spawnImpl(
bskPath,
[...args, "--json"],
windows
? {
windowsHide: true,
env: { ...process.env, BSK_CANCEL_ON_STDIN_CLOSE: "1" },
}
: undefined,
);
// A child exiting while cancellation closes stdin may report EPIPE.
// Its close/error event remains the authority for the run result.
child.stdin?.on("error", () => {});
} catch (error) {
reject(error);
return;
@@ -489,9 +489,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
? "Capture an indented aria-tree snapshot of the session's active tab. Interactive elements " +
"carry @eN refs for browser_interact actions. Prefer browser_inspect action=observe for a richer semantic view."
: "Produce a semantic VOM observation of the session's active tab (roles, states, perception " +
"probes) with @eN refs for browser_interact actions. Visual-only surfaces require image " +
"understanding and their refs are screenshot-only; keep using any DOM/AX semantics rendered " +
"alongside them. Read-only: never submits input.";
"probes) with @eN refs for browser_interact actions. Read-only: never submits input.";
register(
defineTool({
name,
@@ -756,17 +754,12 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
name: "inspect.screenshot",
description:
"Capture a PNG screenshot of the session's active tab, or crop to a snapshot ref element. " +
"This is the supported operation for visual surface refs emitted by inspect.observe. Only use " +
"it to interpret a visual-only surface when you can inspect image output. If you lack multimodal " +
"or image-reading capability, tell the user to switch to a model with image understanding; do " +
"not guess content or coordinates. " +
"Returns the image itself when the deployment supports image input, otherwise a file path.",
parameters: {
session: SESSION_PARAM,
ref: {
type: "string",
description:
"Ref (@e3) from the last snapshot/observe; crops to that DOM element or visual surface.",
description: "Snapshot ref (@e3) from the last snapshot/observe; crops to that element.",
},
},
output: {
@@ -1,6 +1,7 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type BskRunResult,
createBskRunner,
@@ -92,6 +93,77 @@ describe("createBskRunner", () => {
});
});
describe("Windows parent cancellation", () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
beforeEach(() => Object.defineProperty(process, "platform", { value: "win32" }));
afterEach(() => {
Object.defineProperty(process, "platform", originalPlatform);
vi.useRealTimers();
});
it("opts in to stdin cancellation and waits for CLI reconciliation", async () => {
const child = Object.assign(new FakeChild(), { stdin: new PassThrough() });
const spawn = vi.fn(() => child as unknown as ChildProcess);
const runner = createBskRunner("bsk", spawn);
const abort = new AbortController();
const result = runner.run(["snapshot"], { signal: abort.signal });
expect(spawn.mock.calls[0]).toEqual([
"bsk",
["snapshot", "--json"],
{
windowsHide: true,
env: { ...process.env, BSK_CANCEL_ON_STDIN_CLOSE: "1" },
},
]);
abort.abort();
expect(child.stdin.writableEnded).toBe(true);
expect(child.killedWith).toEqual([]);
// Closing an already-exited child's pipe must not crash the host.
child.stdin.emit("error", Object.assign(new Error("closed"), { code: "EPIPE" }));
child.finish(2, '{"code":"cancelled"}');
expect(await result).toMatchObject({ code: 2, aborted: true });
});
it("bounds cancellation of an old or unresponsive CLI", async () => {
vi.useFakeTimers();
const child = Object.assign(new FakeChild(), { stdin: new PassThrough() });
const runner = createBskRunner("bsk", fakeSpawn([child]));
const result = runner.run(["snapshot"], { timeoutMs: 5 });
await vi.advanceTimersByTimeAsync(5);
expect(child.stdin.writableEnded).toBe(true);
expect(child.killedWith).toEqual([]);
await vi.advanceTimersByTimeAsync(10_000);
expect(child.killedWith).toEqual([]);
await vi.advanceTimersByTimeAsync(5_000);
expect(child.killedWith).toEqual(["SIGKILL"]);
expect(await result).toMatchObject({ timedOut: true });
});
it("cancels only the requested tag and clears the fallback after exit", async () => {
vi.useFakeTimers();
const a = Object.assign(new FakeChild(), { stdin: new PassThrough() });
const b = Object.assign(new FakeChild(), { stdin: new PassThrough() });
const runner = createBskRunner("bsk", fakeSpawn([a, b]));
const first = runner.run(["snapshot"], { tag: "a" });
const second = runner.run(["snapshot"], { tag: "b" });
expect(runner.killFor("a")).toBe(1);
expect(a.stdin.writableEnded).toBe(true);
expect(b.stdin.writableEnded).toBe(false);
a.finish(2);
b.finish(0);
await Promise.all([first, second]);
expect(vi.getTimerCount()).toBe(0);
});
it("does not start work for an already-aborted call", async () => {
const spawn = vi.fn();
const runner = createBskRunner("bsk", spawn);
const signal = AbortSignal.abort();
expect(await runner.run(["snapshot"], { signal })).toMatchObject({ aborted: true });
expect(spawn).not.toHaveBeenCalled();
});
});
describe("parseBskJson", () => {
const base: BskRunResult = { code: 0, stdout: "", stderr: "", timedOut: false, aborted: false };
@@ -25,7 +25,6 @@ const CLIENT_EXTERNALS: readonly string[] = [
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-client-ui-attachment",
"@deepseek-ai/dsh-client-schema-form",
"@deepseek-ai/dsh-client-runtime/client",
];
/** Wire/type layers with no shared runtime identity: safe to inline. */
-153
View File
@@ -29,159 +29,6 @@ function coreRefs(refs: ReturnType<typeof renderVom>["refs"]) {
}
describe("renderVom single-layer page", () => {
it("renders visual surfaces as screenshot-only refs", () => {
const input = scene([
node({ id: 1, role: "RootWebArea", frameId: "main" }),
node({ id: 2, parentId: 1, role: "Iframe", frameId: "main", backendNodeId: 20 }),
]);
input.visualSurfaces = [
{
parentId: 2,
backendNodeId: 99,
frameId: "child",
renderingKind: "canvas",
visibleRect: { x: 10, y: 20, w: 800, h: 500 },
label: "Data grid",
memberCount: 2,
},
];
const out = renderVom(input);
expect(out.text).toContain("[visual surfaces]");
expect(out.text).toContain(
'@e1 surface "Data grid" [bounds=10,20,800,500; rendering=canvas; layers=2; visual-only; screenshot-only; requires=image-understanding]',
);
expect(out.refs).toContainEqual(
expect.objectContaining({
ref: "e1",
backendNodeId: 99,
kind: "surface",
visibleRect: { x: 10, y: 20, w: 800, h: 500 },
capabilities: ["screenshot"],
}),
);
});
it("renders an unnamed visual surface with a stable generic label", () => {
const input = scene([node({ id: 1, role: "RootWebArea", frameId: "main" })]);
input.visualSurfaces = [
{
parentId: 1,
backendNodeId: 99,
frameId: "main",
renderingKind: "canvas",
visibleRect: { x: 10, y: 20, w: 800, h: 500 },
memberCount: 1,
},
];
const out = renderVom(input);
expect(out.text).toContain(
'@e1 surface "canvas visual surface" [bounds=10,20,800,500; rendering=canvas; visual-only; screenshot-only; requires=image-understanding]',
);
expect(out.refs).toContainEqual(
expect.objectContaining({
ref: "e1",
name: "canvas visual surface",
kind: "surface",
capabilities: ["screenshot"],
}),
);
});
it("appends visual refs after DOM refs so canvas density cannot renumber controls", () => {
const input = scene([
node({ id: 1, role: "RootWebArea", frameId: "main" }),
node({ id: 2, parentId: 1, role: "button", name: "Submit", tag: "button" }),
]);
input.visualSurfaces = [
{
parentId: 1,
backendNodeId: 99,
frameId: "main",
renderingKind: "canvas",
visibleRect: { x: 10, y: 20, w: 80, h: 50 },
memberCount: 1,
},
];
const withoutVisuals = renderVom({ ...input, visualSurfaces: [] });
const withVisuals = renderVom(input);
expect(withoutVisuals.refs[0]).toMatchObject({ ref: "e1", backendNodeId: 2, kind: "dom" });
expect(withVisuals.refs[0]).toMatchObject({ ref: "e1", backendNodeId: 2, kind: "dom" });
expect(withVisuals.refs[1]).toMatchObject({ ref: "e2", backendNodeId: 99, kind: "surface" });
expect(withVisuals.text.indexOf('@e1 button "Submit"')).toBeLessThan(
withVisuals.text.indexOf("[visual surfaces]"),
);
});
it("uses the appendix token budget and prefers explicitly labeled surfaces", () => {
const input = scene([node({ id: 1, role: "RootWebArea", frameId: "main" })]);
input.visualSurfaces = Array.from({ length: 12 }, (_, index) => ({
parentId: 1,
backendNodeId: 100 + index,
frameId: "main",
renderingKind: "canvas" as const,
visibleRect: { x: index * 10, y: 20, w: 8, h: 8 },
...(index === 11 ? { label: "Important status" } : {}),
memberCount: 1,
}));
const out = renderVom(input);
const surfaceRefs = out.refs.filter((ref) => ref.kind === "surface");
expect(surfaceRefs).toHaveLength(12);
expect(surfaceRefs[0]).toMatchObject({ backendNodeId: 111, name: "Important status" });
expect(out.truncated).toBe(false);
});
it("renders twenty independent sparklines without changing DOM refs", () => {
const input = scene([
node({ id: 1, role: "RootWebArea", frameId: "main" }),
node({ id: 2, parentId: 1, role: "button", name: "Submit", tag: "button" }),
]);
input.visualSurfaces = Array.from({ length: 20 }, (_, index) => ({
parentId: 1,
backendNodeId: 100 + index,
frameId: "main",
renderingKind: "canvas" as const,
visibleRect: { x: index * 12, y: 20, w: 10, h: 8 },
memberCount: 1,
}));
const out = renderVom(input);
expect(out.refs[0]).toMatchObject({ ref: "e1", backendNodeId: 2, kind: "dom" });
expect(out.refs.filter((ref) => ref.kind === "surface")).toHaveLength(20);
expect(out.truncated).toBe(false);
});
it("does not let a visual surface exhaust the budget before later DOM content", () => {
const input = scene([
node({ id: 1, role: "RootWebArea", name: "Doc", frameId: "main" }),
node({ id: 2, parentId: 1, role: "button", name: "First", tag: "button" }),
node({ id: 3, parentId: 1, role: "button", name: "Later", tag: "button" }),
]);
input.visualSurfaces = [
{
parentId: 1,
backendNodeId: 99,
frameId: "main",
renderingKind: "canvas",
visibleRect: { x: 10, y: 20, w: 800, h: 500 },
memberCount: 1,
},
];
const out = renderVom(input, { maxTokens: 30 });
expect(out.text).toContain('@e2 button "Later"');
expect(out.refs.filter((ref) => ref.kind === "surface")).toHaveLength(0);
expect(out.truncated).toBe(true);
});
it("does not derive handle context across frame scopes", () => {
const out = renderVom(
scene([
@@ -1,35 +0,0 @@
import { describe, expect, it } from "vitest";
import {
compareVisualSurfacePriority,
selectHighestPriorityVisualSurfaces,
} from "../visual-surface-priority";
function surface(backendNodeId: number, area: number, label?: string) {
return {
backendNodeId,
frameId: "main",
visibleRect: { x: backendNodeId, y: 0, w: area, h: 1 },
...(label ? { label } : {}),
};
}
describe("visual surface priority selection", () => {
it("matches a full priority sort when selecting a bounded prefix", () => {
const surfaces = Array.from({ length: 600 }, (_, index) => surface(index, index + 1));
surfaces[599] = surface(599, 1, "Important status");
const selected = selectHighestPriorityVisualSurfaces(surfaces, 512);
const expected = [...surfaces].sort(compareVisualSurfacePriority).slice(0, 512);
expect(selected).toEqual(expected);
expect(selected[0]?.backendNodeId).toBe(599);
});
it("normalizes empty, fractional, and unbounded limits", () => {
const surfaces = [surface(1, 1), surface(2, 2), surface(3, 3)];
expect(selectHighestPriorityVisualSurfaces(surfaces, 0)).toEqual([]);
expect(selectHighestPriorityVisualSurfaces(surfaces, 1.9)).toEqual([surface(3, 3)]);
expect(selectHighestPriorityVisualSurfaces(surfaces, Number.POSITIVE_INFINITY)).toHaveLength(3);
});
});
-7
View File
@@ -15,13 +15,6 @@ export type {
VomNode,
VomOptions,
VomRef,
VomRefCapability,
VomResult,
VomScene,
VomVisualSurface,
} from "./types";
export {
compareVisualSurfacePriority,
selectHighestPriorityVisualSurfaces,
type VisualSurfacePriorityInput,
} from "./visual-surface-priority";
+15 -123
View File
@@ -9,9 +9,7 @@ import type {
VomOptions,
VomResult,
VomScene,
VomVisualSurface,
} from "./types";
import { selectHighestPriorityVisualSurfaces } from "./visual-surface-priority";
const SKIP_ROLES = new Set(["generic", "none", "presentation", "inlinetextbox"]);
@@ -89,8 +87,6 @@ const MAX_CUSTOM_RECOVERY_AREA = 180_000;
const MAX_RECOVERABLE_SINGLE_INTENT_AREA = 360_000;
const MAX_HANDLE_CONTEXT_ITEMS = 3;
const MAX_SURFACE_ITEMS = 12;
const DEFAULT_VISUAL_APPENDIX_TOKENS = 2_048;
const MAX_VISUAL_SURFACE_REFS = 512;
const MAX_SCOPE_LINES = 40;
const RECOVERY_HANDLER_ATTRS = ["onclick", "onmousedown", "onkeydown", "onkeyup", "onkeypress"];
const NATIVE_TAGS = new Set(["button", "input", "select", "textarea"]);
@@ -797,117 +793,34 @@ interface RenderState {
duplicateRefNames: Set<string>;
surfaceMap: Map<number, CondSurface>;
scopeMap: Map<number, ActiveScopeBlock>;
visualSurfaces: VomVisualSurface[];
visualSurfacesTruncated: boolean;
omittedVisualSurfaceCount?: number;
}
function tryAppendLine(state: RenderState, line: string, tokenLimit = state.maxTokens): boolean {
const nextTokens = state.tokens + estimateTokens(line);
if (nextTokens > tokenLimit) return false;
state.lines.push(line);
state.tokens = nextTokens;
return true;
}
function visualSurfaceLine(surface: VomVisualSurface, ref: string): string {
const label = cleaned(surface.label) ?? `${surface.renderingKind} visual surface`;
const layers = surface.memberCount > 1 ? `; layers=${surface.memberCount}` : "";
const { x, y, w, h } = surface.visibleRect;
const bounds = [x, y, w, h].map(Math.round).join(",");
return ` @${ref} surface ${JSON.stringify(label)} [bounds=${bounds}; rendering=${surface.renderingKind}${layers}; visual-only; screenshot-only; requires=image-understanding]`;
}
function selectVisualSurfaces(
surfaces: readonly VomVisualSurface[],
nodesById: ReadonlyMap<number, VomNode>,
): { selected: VomVisualSurface[]; eligibleCount: number } {
let eligibleCount = 0;
function* eligibleSurfaces(): Generator<VomVisualSurface> {
for (const surface of surfaces) {
if (surface.parentId !== null && !nodesById.has(surface.parentId)) continue;
eligibleCount += 1;
yield surface;
}
}
return {
selected: selectHighestPriorityVisualSurfaces(eligibleSurfaces(), MAX_VISUAL_SURFACE_REFS),
eligibleCount,
};
}
function emitVisualAppendix(state: RenderState): void {
if (state.stopped) return;
const { selected, eligibleCount } = selectVisualSurfaces(state.visualSurfaces, state.nodesById);
if (eligibleCount === 0 && !state.visualSurfacesTruncated) return;
const header = "[visual surfaces]";
const appendixStartTokens = state.tokens;
const appendixTokenLimit = Number.isFinite(state.maxTokens)
? state.maxTokens
: appendixStartTokens + DEFAULT_VISUAL_APPENDIX_TOKENS;
let headerEmitted = false;
let emitted = 0;
for (const surface of selected) {
if (emitted >= MAX_VISUAL_SURFACE_REFS) break;
const ref = `e${state.nextRef}`;
const line = visualSurfaceLine(surface, ref);
const requiredTokens = estimateTokens(line) + (headerEmitted ? 0 : estimateTokens(header));
if (state.tokens + requiredTokens > appendixTokenLimit) break;
if (!headerEmitted) {
if (!tryAppendLine(state, header, appendixTokenLimit)) break;
headerEmitted = true;
}
if (!tryAppendLine(state, line, appendixTokenLimit)) break;
const label = cleaned(surface.label) ?? `${surface.renderingKind} visual surface`;
state.refs.push({
ref,
backendNodeId: surface.backendNodeId,
frameId: surface.frameId,
visibleRect: surface.visibleRect,
role: "surface",
name: label,
kind: "surface",
capabilities: ["screenshot"],
line: state.lines.length - 1,
});
state.nextRef += 1;
emitted += 1;
}
const locallyOmitted = eligibleCount - emitted;
const upstreamOmitted = state.omittedVisualSurfaceCount ?? 0;
const omitted = locallyOmitted + upstreamOmitted;
if (omitted > 0 || state.visualSurfacesTruncated) {
state.truncated = true;
if (headerEmitted) {
const count =
omitted > 0 ? `${state.visualSurfacesTruncated ? "at least " : ""}${omitted}` : "some";
tryAppendLine(state, ` … ${count} additional visual surfaces omitted`, appendixTokenLimit);
}
}
}
function emitActiveScopeBlock(scope: ActiveScopeBlock, depth: number, state: RenderState): void {
if (state.stopped || scope.lines.length === 0) return;
const indent = " ".repeat(depth);
const header = `${indent}[§ active: ${scope.label}]`;
if (!tryAppendLine(state, header)) {
const headerTokens = estimateTokens(header);
if (state.tokens + headerTokens > state.maxTokens) {
state.truncated = true;
state.stopped = true;
return;
}
state.lines.push(header);
state.tokens += headerTokens;
for (const text of scope.lines.slice(0, MAX_SCOPE_LINES)) {
if (state.stopped) return;
const clean = cleaned(text);
if (!clean) continue;
const line = `${indent} ${clean}`;
if (!tryAppendLine(state, line)) {
const nextTokens = state.tokens + estimateTokens(line);
if (nextTokens > state.maxTokens) {
state.truncated = true;
state.stopped = true;
return;
}
state.lines.push(line);
state.tokens = nextTokens;
}
}
@@ -942,11 +855,15 @@ function renderTree(children: Map<number | null, VomNode[]>, state: RenderState)
const context = ref ? handleContext(node, state) : [];
const surface = state.surfaceMap.get(node.id);
const line = renderNodeLine(node, depth, ref, context, surface, state.redactValues);
if (!tryAppendLine(state, line)) {
const nextTokens = state.tokens + estimateTokens(line);
if (nextTokens > state.maxTokens) {
state.truncated = true;
state.stopped = true;
break;
}
state.lines.push(line);
state.tokens = nextTokens;
if (ref) {
const name = cleaned(node.name);
state.refs.push({
@@ -956,8 +873,6 @@ function renderTree(children: Map<number | null, VomNode[]>, state: RenderState)
...(node.role ? { role: node.role } : {}),
...(name ? { name } : {}),
...(context.length > 0 ? { ctx: context.join(" > ") } : {}),
kind: "dom",
capabilities: ["interact", "screenshot"],
line: state.lines.length - 1,
});
state.nextRef += 1;
@@ -979,9 +894,6 @@ function renderNodes(
initialLines: string[],
surfaces: CondSurface[] = [],
activeScopeBlocks: ActiveScopeBlock[] = [],
visualSurfaces: VomVisualSurface[] = [],
visualSurfacesTruncated = false,
omittedVisualSurfaceCount?: number,
): RenderState {
const children = buildChildren(nodes);
const state: RenderState = {
@@ -1001,9 +913,6 @@ function renderNodes(
duplicateRefNames: duplicateReferenceNames(nodes),
surfaceMap: new Map(surfaces.map((surface) => [surface.triggerId, surface])),
scopeMap: new Map(activeScopeBlocks.map((scope) => [scope.triggerId, scope])),
visualSurfaces,
visualSurfacesTruncated,
omittedVisualSurfaceCount,
};
renderTree(children, state);
@@ -1215,21 +1124,8 @@ function renderDoubleLayer(scene: VomScene, layer: BlockingLayer, options: VomOp
"@layers 2 focus=L1",
`L1 ${layer.kind} cover=${Math.round(layer.coverage * 100)}%`,
];
const visibleSurface = (parentId: number | null): boolean =>
parentId === null || included.has(parentId);
const state = renderNodes(
visibleNodes,
options,
header,
scene.surfaces,
scene.activeScopeBlocks,
scene.visualSurfaces?.filter((surface) => visibleSurface(surface.parentId)),
scene.visualSurfacesTruncated,
scene.omittedVisualSurfaceCount,
);
const occlusionLine = renderPageOcclusionLine(hiddenCount);
if (!tryAppendLine(state, occlusionLine)) state.truncated = true;
emitVisualAppendix(state);
const state = renderNodes(visibleNodes, options, header, scene.surfaces, scene.activeScopeBlocks);
state.lines.push(renderPageOcclusionLine(hiddenCount));
return {
text: state.lines.join("\n"),
@@ -1256,11 +1152,7 @@ export function renderVom(scene: VomScene, options: VomOptions = {}): VomResult
],
scene.surfaces,
scene.activeScopeBlocks,
scene.visualSurfaces,
scene.visualSurfacesTruncated,
scene.omittedVisualSurfaceCount,
);
emitVisualAppendix(state);
return {
text: state.lines.join("\n"),
-22
View File
@@ -61,12 +61,6 @@ export interface VomScene {
rootFrameId?: string;
surfaces?: CondSurface[];
activeScopeBlocks?: ActiveScopeBlock[];
/** Non-semantic rendered regions discovered alongside the AX/DOM tree. */
visualSurfaces?: VomVisualSurface[];
/** Upstream surface discovery/aggregation stopped at a resource guardrail. */
visualSurfacesTruncated?: boolean;
/** Exact omitted count when upstream could determine it. */
omittedVisualSurfaceCount?: number;
}
export interface VomOptions {
@@ -100,22 +94,6 @@ export interface VomRef {
ref: string;
backendNodeId: number;
frameId?: string;
/** Observation-time top-viewport crop for screenshot-only surfaces. */
visibleRect?: Rect;
kind?: "dom" | "surface";
capabilities?: VomRefCapability[];
}
export type VomRefCapability = "interact" | "screenshot";
export interface VomVisualSurface {
parentId: number | null;
backendNodeId: number;
frameId: string;
renderingKind: "canvas";
visibleRect: Rect;
label?: string;
memberCount: number;
}
export interface RenderedRef extends VomRef {
@@ -1,80 +0,0 @@
export interface VisualSurfacePriorityInput {
label?: string;
visibleRect: { x: number; y: number; w: number; h: number };
frameId: string;
backendNodeId: number;
}
function hasLabel(label: string | undefined): boolean {
return (label?.trim().length ?? 0) > 0;
}
function area(surface: VisualSurfacePriorityInput): number {
return Math.max(0, surface.visibleRect.w) * Math.max(0, surface.visibleRect.h);
}
/** Negative means `a` should be emitted before `b`. */
export function compareVisualSurfacePriority(
a: VisualSurfacePriorityInput,
b: VisualSurfacePriorityInput,
): number {
return (
Number(hasLabel(b.label)) - Number(hasLabel(a.label)) ||
area(b) - area(a) ||
a.frameId.localeCompare(b.frameId) ||
a.visibleRect.y - b.visibleRect.y ||
a.visibleRect.x - b.visibleRect.x ||
a.backendNodeId - b.backendNodeId
);
}
function siftWorstUp<T extends VisualSurfacePriorityInput>(heap: T[], start: number): void {
let index = start;
while (index > 0) {
const parent = Math.floor((index - 1) / 2);
if (compareVisualSurfacePriority(heap[index], heap[parent]) <= 0) break;
[heap[index], heap[parent]] = [heap[parent], heap[index]];
index = parent;
}
}
function siftWorstDown<T extends VisualSurfacePriorityInput>(heap: T[], start: number): void {
let index = start;
while (true) {
const left = index * 2 + 1;
if (left >= heap.length) return;
const right = left + 1;
let worst = left;
if (right < heap.length && compareVisualSurfacePriority(heap[right], heap[left]) > 0) {
worst = right;
}
if (compareVisualSurfacePriority(heap[worst], heap[index]) <= 0) return;
[heap[index], heap[worst]] = [heap[worst], heap[index]];
index = worst;
}
}
/** Selects the best `limit` surfaces and returns them in priority order. */
export function selectHighestPriorityVisualSurfaces<T extends VisualSurfacePriorityInput>(
surfaces: Iterable<T>,
limit: number,
): T[] {
if (!Number.isFinite(limit)) return [...surfaces].sort(compareVisualSurfacePriority);
const normalizedLimit = Math.max(0, Math.floor(limit));
if (normalizedLimit === 0) return [];
if (Array.isArray(surfaces) && surfaces.length <= normalizedLimit) {
return [...surfaces].sort(compareVisualSurfacePriority);
}
const heap: T[] = [];
for (const surface of surfaces) {
if (heap.length < normalizedLimit) {
heap.push(surface);
siftWorstUp(heap, heap.length - 1);
} else if (compareVisualSurfacePriority(surface, heap[0]) < 0) {
heap[0] = surface;
siftWorstDown(heap, 0);
}
}
return heap.sort(compareVisualSurfacePriority);
}
+135
View File
@@ -0,0 +1,135 @@
#Requires -Version 5.1
param([string]$BskPath = (Join-Path $PSScriptRoot "../target/debug/bsk.exe"))
$ErrorActionPreference = "Stop"
# Load definitions, never Main: no downloads or changes to the real user PATH.
$installer = Join-Path $PSScriptRoot "../install.ps1"
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($installer, [ref]$tokens, [ref]$errors)
if ($errors.Count) { throw ($errors | Out-String) }
foreach ($statement in $ast.EndBlock.Statements) {
if ($statement -is [System.Management.Automation.Language.FunctionDefinitionAst]) {
$definition = $statement.Extent.Text
if ($statement.Name -eq "Add-ToUserPath") {
$definition = $definition.Replace('[Environment]::GetEnvironmentVariable("PATH", "User")', '$script:UserPath')
$definition = $definition.Replace('[Environment]::SetEnvironmentVariable("PATH", $newUserPath, "User")', '$script:UserPath = $newUserPath')
}
Invoke-Expression $definition
}
}
function Assert-Equal($Actual, $Expected) {
if ($Actual -cne $Expected) { throw "expected [$Expected], got [$Actual]" }
}
function Assert-Fails([scriptblock]$Action) {
$failed = $false
try { & $Action } catch { $failed = $true }
if (-not $failed) { throw "expected failure" }
}
$dir = 'C:\Users\Alice\.local\bin'
foreach ($existing in @('', 'C:\WindowsApps', 'C:\A;C:\B')) {
$script:UserPath = $existing
Add-ToUserPath $dir
$expected = if ($existing) { "$existing;$dir" } else { $dir }
Assert-Equal $script:UserPath $expected
Add-ToUserPath $dir
Assert-Equal $script:UserPath $expected
}
$root = Join-Path ([IO.Path]::GetTempPath()) ("bsk-install-test-" + [Guid]::NewGuid().ToString('N'))
[IO.Directory]::CreateDirectory($root) | Out-Null
$oldBskHome = $env:BSK_HOME
$oldAutoUpdate = $env:BSK_AUTO_UPDATE
$daemon = $null
try {
$utf8 = New-Object System.Text.UTF8Encoding($false)
$bashRc = Join-Path $root ".bashrc"
$existing = "# existing 中文 configuration" + [char]10
[IO.File]::WriteAllText($bashRc, $existing, $utf8)
$special = 'C:\Users\张三 space $HOME $(echo unsafe) ! & [x] O''Brien\.local\bin'
Add-ToBashProfile $special $bashRc
$first = [IO.File]::ReadAllText($bashRc)
if (-not $first.StartsWith($existing)) { throw "existing bashrc content changed" }
if (-not $first.Contains('张三')) { throw "Unicode path was lost" }
Add-ToBashProfile $special $bashRc
Assert-Equal ([IO.File]::ReadAllText($bashRc)) $first
$bytes = [IO.File]::ReadAllBytes($bashRc)
if ($bytes[0] -eq 0xEF) { throw "unexpected UTF-8 BOM" }
$bash = [IO.Path]::GetFullPath((Join-Path (Split-Path (Get-Command git).Source -Parent) "../bin/bash.exe"))
if (-not (Test-Path -LiteralPath $bash)) { throw "Git Bash is required for quoting regression" }
$env:BSK_TEST_RC = $bashRc
$probe = Join-Path $root 'probe.sh'
[IO.File]::WriteAllText($probe, 'source "$BSK_TEST_RC"; printf "%s\n" "${PATH%%:*}"', $utf8)
$actual = & $bash --noprofile --norc $probe
if ($LASTEXITCODE -ne 0) { throw "Git Bash failed" }
Assert-Equal $actual ('/c' + $special.Substring(2).Replace('\', '/'))
$env:BSK_HOME = Join-Path $root "home"
$env:BSK_AUTO_UPDATE = "off"
[IO.Directory]::CreateDirectory($env:BSK_HOME) | Out-Null
$sourceDir = Join-Path $root "download"
[IO.Directory]::CreateDirectory($sourceDir) | Out-Null
$source = Join-Path $sourceDir "bsk.exe"
[IO.File]::Copy((Resolve-Path -LiteralPath $BskPath).Path, $source)
$targetDir = Join-Path $root "中文 space [x] & install"
[IO.Directory]::CreateDirectory($targetDir) | Out-Null
$target = Join-Path $targetDir "bsk.exe"
Install-Binary $source $target
Assert-Equal (Get-FileHash -LiteralPath $target).Hash (Get-FileHash -LiteralPath $source).Hash
$daemon = Start-Process -FilePath $target -ArgumentList @('daemon', 'start', '--foreground', '--port', '0') -WindowStyle Hidden -PassThru
$infoPath = Join-Path $env:BSK_HOME "daemon.json"
$deadline = [DateTime]::UtcNow.AddSeconds(15)
while (-not [IO.File]::Exists($infoPath)) {
if ($daemon.HasExited -or [DateTime]::UtcNow -gt $deadline) { throw "daemon failed to start" }
Start-Sleep -Milliseconds 50
}
# PE overlays distinguish old/new executables without another build.
$stream = [IO.File]::OpenWrite($source)
try {
$stream.Seek(0, [IO.SeekOrigin]::End) | Out-Null
$marker = $utf8.GetBytes("installer replacement regression")
$stream.Write($marker, 0, $marker.Length)
} finally { $stream.Dispose() }
Install-Binary $source $target
if (-not $daemon.WaitForExit(5000)) { throw "old daemon still running" }
Assert-Equal (Get-FileHash -LiteralPath $target).Hash (Get-FileHash -LiteralPath $source).Hash
& $target --version
if ($LASTEXITCODE -ne 0) { throw "replacement is not executable" }
# Refuse replacement when daemon identity cannot be verified.
[IO.File]::WriteAllText($infoPath, 'invalid daemon metadata')
$before = (Get-FileHash -LiteralPath $target).Hash
Assert-Fails { Install-Binary $source $target }
Assert-Equal (Get-FileHash -LiteralPath $target).Hash $before
[IO.File]::Delete($infoPath)
# A remaining lock must fail without truncation or staging debris.
$lock = [IO.File]::Open($target, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None)
try { Assert-Fails { Install-Binary $source $target } } finally { $lock.Dispose() }
Assert-Equal (Get-FileHash -LiteralPath $target).Hash $before
if (@(Get-ChildItem -LiteralPath $targetDir -Filter "*.install-*").Count) { throw "staging files leaked" }
Write-Host "Windows installer regressions passed ($($PSVersionTable.PSVersion))"
}
catch {
Write-Host ($_ | Out-String)
Write-Host $_.ScriptStackTrace
throw
}
finally {
if ($daemon -and -not $daemon.HasExited) {
Stop-Process -Id $daemon.Id -Force
$daemon.WaitForExit(5000) | Out-Null
}
$env:BSK_HOME = $oldBskHome
$env:BSK_AUTO_UPDATE = $oldAutoUpdate
Remove-Item Env:BSK_TEST_RC -ErrorAction SilentlyContinue
# Resolve and verify before recursive cleanup; only this fixture is removed.
$resolvedRoot = [IO.Path]::GetFullPath($root)
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
if (-not $resolvedRoot.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or
-not ([IO.Path]::GetFileName($resolvedRoot)).StartsWith('bsk-install-test-')) { throw "invalid cleanup path" }
Remove-Item -LiteralPath $resolvedRoot -Recurse -Force
}
-10
View File
@@ -73,16 +73,6 @@ CSS-only hover menu looks like from here. It hovers a bounded set of likely trig
few seconds and touches the live page; once you know which element hides the menu, `bsk hover <ref>`
is cheaper and more precise.
When `bsk observe` renders
`@eN surface ... [visual-only; requires=image-understanding; ...]`, the ref identifies rendered
canvas content that is not represented by the text observation, not an interactable DOM control.
Keep using any reliable DOM/AX text and controls that appear alongside it. If you can actually
inspect image output, use `bsk screenshot --ref @eN --session <id>` to obtain the visible crop. If
you lack multimodal or image-reading capability, do not take a screenshot and pretend to know its
contents, and never guess coordinates; tell the user that they need to switch to a model with
image-understanding capability. Do not pass a visual surface ref to click, fill, hover, or select;
those commands intentionally reject screenshot-only refs.
Escalate page reading only as needed:
1. `bsk observe` for normal semantic understanding, text, controls, and refs.