Compare commits

...
Author SHA1 Message Date
Ljy-0827 7469d3506a fix(vom): add new texts and fix bugs 2026-09-08 15:16:10 +08:00
Ljy-0827 f528f95bd6 fix(vom): add new texts and fix bugs 2026-09-08 14:34:40 +08:00
Ljy-0827 309d270045 fix(vom): add fixtures, remove fixed canvas numbers under budget, improve efficiency 2026-09-08 13:13:25 +08:00
Ljy-0827 aeece8d7e4 fix(vom): bug fix 2026-09-08 10:54:33 +08:00
Ljy-0827 905ba6a8a6 feat(vom): two channels to deal with canvas 2026-09-08 10:54:33 +08:00
Ljy-0827 672b17cea8 feat(vom): fixture test 2026-09-08 10:54:33 +08:00
Ljy-0827 320c610c76 fix(vom): canvas recognition 2026-09-08 10:54:33 +08:00
Ljy-0827 ff1216526d feat(vom): vom can distinguish important canvas area 2026-09-08 10:54:33 +08:00
Zhang GH 3c5f838c56 Merge pull request #192 from Tencent/fix/windows-update-reliability
fix(update): make Windows self-updates reliable
2026-09-07 20:37:32 +08:00
iuyo5678 9337bd557b test(update): use blocking connections in Windows release server 2026-09-07 20:27:04 +08:00
iuyo5678 162a31ae92 fix(update): make Windows self-updates reliable 2026-09-07 20:04:15 +08:00
Zhang GH 2f84234a31 Merge pull request #191 from Tencent/fix/windows-ipc-process-liveness
fix(windows): preserve pipe continuity and verify process liveness
2026-09-07 19:06:38 +08:00
iuyo5678 72c10aab75 fix(windows): preserve pipe continuity and verify process liveness 2026-09-07 18:25:32 +08:00
Zhang GH 1eeb55af02 Merge pull request #190 from Tencent/fix/dsh-observation-sync-and-capture
fix(dsh): synchronize observation state and manage thumbnail capture
2026-09-07 17:46:31 +08:00
drakezhang 4992a54864 docs(dsh): clarify observation subscription and snapshot contracts 2026-09-07 17:38:35 +08:00
drakezhang 8b195eb0df test(extension): return tab id from navigation recovery mock 2026-09-07 17:26:01 +08:00
drakezhang eae4db1729 Merge main for navigation recovery test compatibility 2026-09-07 17:25:22 +08:00
drakezhang e961395be9 fix(dsh): synchronize observation state and manage thumbnail capture 2026-09-07 16:24:04 +08:00
Zhang GH a6d1d02acc Merge pull request #179 from Tencent/fix/cdp-extension-frame-recovery
fix: recover navigation from cross-extension CDP restrictions
2026-09-07 16:05:46 +08:00
Zhang GH 5ebe609c40 Merge pull request #189 from Tencent/fix/verify-fill-before-success
fix(extension): verify fill results before reporting success
2026-09-07 15:59:21 +08:00
drakezhang 321494d93f test(fill): cover caret positioning and clarify append behavior 2026-09-07 15:56:13 +08:00
drakezhang f2184e74c5 fix(fill): support background input and automatic append 2026-09-07 15:28:21 +08:00
drakezhang eeb633d68e fix(extension): verify fill results before reporting success 2026-09-07 13:25:54 +08:00
Zhang GH 6d181105f2 Merge pull request #188 from Tencent/fix/match-form-state-by-element
fix(extension): match form state by element identity
2026-09-07 12:57:42 +08:00
drakezhang ebad0ed1b5 fix(extension): match form state by element identity 2026-09-07 12:21:01 +08:00
Zhang GH 498f8fb4a7 Merge pull request #187 from Tencent/fix/release-automation-state-on-tab-return
Fix/release automation state on tab return
2026-09-07 12:12:41 +08:00
drakezhang fd3ce202a1 fix: recover navigation from cross-extension CDP restrictions
Classify Chrome extension-frame access failures and provide an actionable CLI hint. Recover navigate and reload only when their initial CDP setup is denied, using browser navigation events and verifying that CDP access returns.

Preserve normal navigation, Agent Window checks, transfer error details, and network-idle semantics. Never replay an already-dispatched navigation.
2026-09-03 18:21:21 +08:00
72 changed files with 7118 additions and 488 deletions
+21
View File
@@ -37,6 +37,27 @@ jobs:
- name: Run Rust tests
run: cargo test --workspace --locked
windows-daemon:
name: Windows daemon and update regression tests
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Run Windows named-pipe tests
run: cargo test -p bsk --lib --locked daemon::ipc::windows::tests
- name: Run Windows process liveness tests
run: cargo test -p bsk --lib --locked daemon::lockfile::tests
- 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
frontend:
name: Frontend lint, typecheck, tests, build
runs-on: ubuntu-latest
@@ -13,6 +13,8 @@ describe("RefStore", () => {
backendNodeId: 42,
tabId: 7,
generation: 0,
kind: "dom",
capabilities: ["interact", "screenshot"],
});
expect(s.size()).toBe(1);
expect(s.isEmpty()).toBe(false);
@@ -48,4 +50,41 @@ 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,3 +1,5 @@
import type { Rect } from "@browser-skill/vom";
/**
* Per-session map from `@e<N>` snapshot refs to a CDP node address.
*
@@ -12,12 +14,17 @@
* 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;
}
@@ -28,8 +35,19 @@ 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;
@@ -70,13 +88,20 @@ 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,
});
}
@@ -94,14 +119,20 @@ 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,
};
}
@@ -49,6 +49,47 @@ describe("ToolDispatcher", () => {
vi.unstubAllGlobals();
});
it.each([
"tool.snapshot",
"tool.evaluate",
"tool.get_html",
"tool.console",
])("annotates Chrome extension-access failures from %s", async (method) => {
const tab = { id: 7, windowId: 4242, active: true, url: "https://example.test" };
vi.stubGlobal("chrome", {
tabs: {
get: vi.fn(async () => tab),
query: vi.fn(async () => [tab]),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(),
},
});
await sessions.start("test");
const error = new Error("Cannot access a chrome-extension:// URL of different extension");
const cdp = {
send: vi.fn().mockRejectedValue(error),
ensureConsoleCapture: vi.fn().mockRejectedValue(error),
consoleEntriesSince: vi.fn(),
} as unknown as TestDispatcherCdp;
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start();
deliver(makeRequest(method, { session_id: "test", expression: "document.title" }));
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({
error: {
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
},
});
dispatcher.stop();
});
it("uses the configured recording runtime for start and stop", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import type { RpcError } from "@/transport/types";
import { cdpError, classifyCdpError } from "../errors";
const denied = "Cannot access a chrome-extension:// URL of different extension";
describe("CDP extension access errors", () => {
it("classifies both Chrome Error instances and string failures", () => {
for (const error of [new Error(denied), denied]) {
expect(cdpError(error)).toEqual({
code: "cdp_failed",
message: denied,
data: { reason: "cdp_extension_access_denied" },
});
}
});
it("preserves transfer outcome and cleanup instructions when CDP was denied", () => {
const error: RpcError = {
code: "cdp_failed",
message: denied,
data: {
reason: "transfer_outcome_unknown",
effect_state: "unknown",
cleanup_state: "failed",
},
};
expect(classifyCdpError(error)).toBe(error);
});
it("leaves other error codes and unrelated debugger failures unchanged", () => {
for (const error of [
{ code: "permission_denied", message: denied },
{ code: "cdp_failed", message: "Another debugger is already attached" },
] satisfies RpcError[]) {
expect(classifyCdpError(error)).toBe(error);
}
});
});
@@ -0,0 +1,573 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import { handleFill } from "../interaction";
interface ScriptParams {
functionDeclaration: string;
arguments?: Array<{ value: unknown }>;
}
async function setup(markup = '<input value="old">') {
document.body.innerHTML = markup;
const element = document.body.firstElementChild as HTMLInputElement;
const manager = new SessionManager({
agentWindow: {
create: async () => 100,
remove: async () => {},
ensureActiveTab: async () => 4,
},
});
const ctx = await manager.start("aa11");
ctx.refStore.set("e1", 12, { tabId: 4 });
const script = vi.fn(async (params: ScriptParams): Promise<unknown> => {
try {
const fn = new Function(`return (${params.functionDeclaration})`)();
return {
result: {
value: await fn.apply(
element,
(params.arguments ?? []).map((arg) => arg.value),
),
},
};
} catch {
return { exceptionDetails: { text: "test page script failed" } };
}
});
// Number/email controls have a browser caret even though their selection
// getters return null. Model that caret instead of assuming it is at the end.
const hiddenCaret = { start: 0, end: 0 };
const moveToEnd = vi.fn(async () => {
if (typeof element.selectionStart === "number") {
element.setSelectionRange(element.value.length, element.value.length);
} else {
hiddenCaret.start = hiddenCaret.end = element.value.length;
}
});
const insert = vi.fn(async (text: string) => {
const focused = document.activeElement as HTMLInputElement;
if (focused.readOnly || focused.disabled) return;
const start = focused.selectionStart ?? hiddenCaret.start;
const end = focused.selectionEnd ?? hiddenCaret.end;
const value = focused.value.slice(0, start) + text + focused.value.slice(end);
focused.value = focused.maxLength >= 0 ? value.slice(0, focused.maxLength) : value;
hiddenCaret.start = hiddenCaret.end = Math.min(start + text.length, focused.value.length);
focused.dispatchEvent(new Event("input", { bubbles: true }));
});
const release = vi.fn(async () => ({}));
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
switch (method) {
case "DOM.describeNode":
return {
node: {
nodeName: element.tagName,
attributes: Array.from(element.attributes).flatMap((attr) => [attr.name, attr.value]),
},
};
case "DOM.scrollIntoViewIfNeeded":
return {};
case "DOM.focus":
element.focus();
return {};
case "DOM.resolveNode":
return { object: { objectId: "fill-target" } };
case "Runtime.callFunctionOn":
return script(params as ScriptParams);
case "Input.insertText":
await insert((params as { text: string }).text);
return {};
case "Input.dispatchKeyEvent":
if ((params as { commands?: string[] }).commands?.includes("moveToEndOfDocument")) {
await moveToEnd();
}
return {};
case "Runtime.releaseObject":
return release();
default:
throw new Error(`unexpected ${method}`);
}
});
return {
element,
hiddenCaret,
moveToEnd,
script,
insert,
release,
send,
fill: (value = "hello", clearBefore = true, signal?: AbortSignal) =>
handleFill(
manager,
{ session_id: "aa11", ref: "e1", value, clear_before: clearBefore },
{
cdp: { send: send as CdpRunner["send"] },
tabsApi: {
get: async (id) => ({ id, windowId: 100, active: true }) as chrome.tabs.Tab,
query: async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab],
},
signal,
},
),
};
}
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
});
describe("fill result verification", () => {
it.each([
"readonly",
"disabled",
])("preserves a %s field before rejecting it", async (attribute) => {
const h = await setup(`<input ${attribute} value="old">`);
expect(await h.fill()).toMatchObject({
code: "invalid_params",
data: { reason: "target_not_fillable" },
});
expect(h.element.value).toBe("old");
expect(h.insert).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it.each([
"checkbox",
"radio",
"range",
"date",
"file",
])("rejects an unsupported %s input before clearing", async (type) => {
const h = await setup(`<input type="${type}">`);
const before = h.element.value;
expect(await h.fill()).toMatchObject({
code: "invalid_params",
data: { reason: "target_not_fillable" },
});
expect(h.element.value).toBe(before);
expect(h.insert).not.toHaveBeenCalled();
});
it("rejects an overlong value before clearing", async () => {
const h = await setup('<input maxlength="3" value="old">');
expect(await h.fill()).toMatchObject({
code: "invalid_params",
message: /maxlength/,
data: { reason: "fill_value_invalid" },
});
expect(h.element.value).toBe("old");
expect(h.insert).not.toHaveBeenCalled();
});
it.each([
true,
false,
])("classifies a DOM.focus rejection on a disabled=%s target", async (disabled) => {
const h = await setup(`<input ${disabled ? "disabled" : ""} value="old">`);
const send = h.send.getMockImplementation()!;
h.send.mockImplementation(async (tabId, method, params) => {
if (method === "DOM.focus") throw new Error("Element is not focusable");
return send(tabId, method, params);
});
expect(await h.fill()).toMatchObject({
code: disabled ? "invalid_params" : "cdp_failed",
data: { reason: disabled ? "target_not_fillable" : "fill_failed" },
});
expect(h.element.value).toBe("old");
expect(h.insert).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it("fills a background document without requiring foreground browser focus", async () => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
expect(await h.fill()).toMatchObject({ value_length: 5 });
expect(h.element.value).toBe("hello");
});
it("restores focus once after clearing without typing into another field", async () => {
const h = await setup();
const other = document.createElement("input");
document.body.append(other);
h.element.addEventListener("input", () => other.focus());
expect(await h.fill()).toMatchObject({ value_length: 5 });
expect(h.element.value).toBe("hello");
expect(other.value).toBe("");
expect(h.send.mock.calls.filter(([, method]) => method === "DOM.focus")).toHaveLength(2);
});
it.each([
"focus",
"value",
])("rechecks %s after restoring focus, without retrying again", async (action) => {
const h = await setup();
const other = document.createElement("input");
document.body.append(other);
h.element.addEventListener(
"input",
() => {
other.focus();
h.element.addEventListener("focus", () => {
if (action === "focus") queueMicrotask(() => other.focus());
else h.element.value = "changed";
});
},
{ once: true },
);
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: action === "focus" ? "fill_focus_lost" : "fill_target_changed" },
});
expect(other.value).toBe("");
expect(h.insert).not.toHaveBeenCalled();
expect(h.send.mock.calls.filter(([, method]) => method === "DOM.focus")).toHaveLength(2);
});
it.each([
"remove",
"readonly",
"restore",
"microtask-restore",
])("stops when clearing causes the page to %s the target", async (action) => {
const h = await setup();
h.element.addEventListener("input", () => {
if (action === "remove") h.element.remove();
if (action === "readonly") h.element.readOnly = true;
if (action === "restore") h.element.value = "restored";
if (action === "microtask-restore")
queueMicrotask(() => {
h.element.value = "restored";
});
});
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_target_changed" },
});
expect(h.insert).not.toHaveBeenCalled();
});
it("fails when insertText is acknowledged without changing the value", async () => {
const h = await setup();
h.insert.mockImplementation(async () => {});
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.release).toHaveBeenCalledOnce();
});
it("detects truncation when maxlength changes after preparation", async () => {
const h = await setup();
h.element.addEventListener(
"input",
() => {
h.element.maxLength = 3;
},
{ once: true },
);
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.element.value).toBe("hel");
});
it.each([
"input",
"change",
"microtask-change",
])("detects a value reverted by the %s handler", async (event) => {
const h = await setup();
h.element.addEventListener(event === "microtask-change" ? "change" : event, () => {
if (event === "microtask-change")
queueMicrotask(() => {
h.element.value = "";
});
else h.element.value = "";
});
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ });
});
it("compares content rather than length without including field values in the error", async () => {
const h = await setup('<input type="password">');
h.element.addEventListener("change", () => {
h.element.value = "other-secret";
});
const result = await h.fill("given-secret");
expect(result).toMatchObject({ code: "cdp_failed", message: /expected value/ });
expect(JSON.stringify(result)).not.toMatch(/given-secret|other-secret/);
});
it("does not report success for a target replaced by a change handler", async () => {
const h = await setup();
h.element.addEventListener("change", () => h.element.replaceWith(h.element.cloneNode()));
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_target_changed" },
});
});
it.each([
1, 2, 3, 4, 5,
])("rejects script exceptions at phase %s and releases the target", async (phase) => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const run = h.script.getMockImplementation()!;
let count = 0;
h.script.mockImplementation(async (params) =>
++count === phase ? { exceptionDetails: { text: "page secret" } } : run(params),
);
const result = await h.fill();
expect(result).toMatchObject({ code: "cdp_failed", data: { reason: "fill_failed" } });
expect(JSON.stringify(result)).not.toContain("page secret");
if (phase <= 3) expect(h.insert).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it.each([1, 2, 3, 5])("rejects missing results at phase %s", async (phase) => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const run = h.script.getMockImplementation()!;
let count = 0;
h.script.mockImplementation(async (params) =>
++count === phase ? { result: { type: "undefined" } } : run(params),
);
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
if (phase <= 3) expect(h.insert).not.toHaveBeenCalled();
});
it.each(["input", "change"])("checks state after nested microtasks from %s", async (event) => {
const h = await setup();
h.element.addEventListener(event, () => {
queueMicrotask(() =>
queueMicrotask(() => {
h.element.value = "reverted";
}),
);
});
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
if (event === "input") expect(h.insert).not.toHaveBeenCalled();
});
it.each([
"<input>",
'<input type="password">',
"<textarea></textarea>",
])("verifies successful text in %s", async (markup) => {
const h = await setup(markup);
expect(await h.fill("你好🙂")).toMatchObject({ value_length: 4 });
expect(h.element.value).toBe("你好🙂");
expect(h.release).toHaveBeenCalledOnce();
});
it("verifies append against the old value plus the requested text", async () => {
const h = await setup();
h.element.focus();
h.element.setSelectionRange(3, 3);
expect(await h.fill("!", false)).toMatchObject({ value_length: 4 });
expect(h.element.value).toBe("old!");
});
it.each([
["<textarea></textarea>", "a\r\nb", "a\nb"],
["<input>", "a\nb", "a b"],
["<input>", "a\r\nb", "a b"],
[
'<input type="email" multiple>',
" a@example.com , b@example.com ",
"a@example.com,b@example.com",
],
])("accepts native normalization for %s", async (markup, requested, normalized) => {
if (markup.startsWith("<textarea")) {
// Happy DOM does not implement Chrome's textarea line-ending normalization.
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
vi.spyOn(HTMLTextAreaElement.prototype, "value", "set").mockImplementation(function (
this: HTMLTextAreaElement,
value,
) {
setter.call(this, value.replace(/\r\n?/g, "\n"));
});
}
const h = await setup(markup);
h.insert.mockImplementation(async () => {
h.element.value = normalized;
});
expect(await h.fill(requested)).toMatchObject({ value_length: normalized.length });
});
it("does not count an editable padding break as an extra typed character", async () => {
const h = await setup('<div contenteditable="true" tabindex="0"></div>');
// These two layout/editability properties are missing in Happy DOM.
let innerText = "";
Object.defineProperty(h.element, "isContentEditable", { get: () => true });
Object.defineProperty(h.element, "innerText", { get: () => innerText });
h.insert.mockImplementation(async () => {
h.element.innerHTML = "one<div><br></div>";
innerText = "one\n\n";
});
expect(await h.fill("one\n")).toMatchObject({ value_length: 4 });
});
it.each([
[0, 0],
[1, 1],
[0, 3],
])("appends after moving selection %s:%s to the end", async (start, end) => {
const h = await setup();
h.element.focus();
h.element.setSelectionRange(start, end);
expect(await h.fill("🙂", false)).toMatchObject({ value_length: 5 });
expect(h.element.value).toBe("old🙂");
});
it.each(
["number", "email"].flatMap((type) =>
[
[0, 0],
[1, 1],
[0, 2],
].map(([start, end]) => ({ type, start, end })),
),
)("appends to $type from selection $start:$end without using its unsupported API", async ({
type,
start,
end,
}) => {
const h = await setup(`<input type="${type}" value="12">`);
Object.assign(h.hiddenCaret, { start, end });
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
const selection = vi.spyOn(h.element, "setSelectionRange").mockImplementation(() => {
throw new DOMException("unsupported", "InvalidStateError");
});
expect(await h.fill("3", false)).toMatchObject({ value_length: 3 });
expect(h.element.value).toBe("123");
expect(selection).not.toHaveBeenCalled();
});
it.each([
"number",
"email",
])("does not report successful %s append when the caret command is ignored", async (type) => {
const h = await setup(`<input type="${type}" value="12">`);
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
h.moveToEnd.mockResolvedValueOnce(undefined);
expect(await h.fill("3", false)).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.element.value).toBe("312");
expect(h.insert).toHaveBeenCalledOnce();
});
it("rejects invalid number text before clearing", async () => {
const h = await setup('<input type="number" value="12">');
expect(await h.fill("abc")).toMatchObject({
code: "invalid_params",
data: { reason: "fill_value_invalid" },
});
expect(h.element.value).toBe("12");
expect(h.insert).not.toHaveBeenCalled();
});
it("reports page formatting as unconfirmed without retrying the write", async () => {
const h = await setup();
h.element.addEventListener("change", () => {
h.element.value = h.element.value.toUpperCase();
});
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.element.value).toBe("HELLO");
expect(h.insert).toHaveBeenCalledOnce();
});
it.each([
["focus", false],
["focus", true],
["value", false],
["value", true],
["cancel", false],
["cancel", true],
["throw", false],
["throw", true],
] as const)("stops when positioning triggers %s (clear=%s) and releases the key", async (action, clearBefore) => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const controller = new AbortController();
const other = document.createElement("input");
document.body.append(other);
const send = h.send.getMockImplementation()!;
h.send.mockImplementation(async (tabId, method, params) => {
const reply = await send(tabId, method, params);
if (
method === "Input.dispatchKeyEvent" &&
(params as { type: string }).type === "rawKeyDown"
) {
if (action === "focus") other.focus();
if (action === "value") h.element.value = "changed";
if (action === "cancel") controller.abort();
if (action === "throw") throw new Error("dispatch failed");
}
return reply;
});
const result = await h.fill("!", clearBefore, controller.signal);
expect(result).toMatchObject({ code: action === "cancel" ? "cancelled" : "cdp_failed" });
expect(h.insert).not.toHaveBeenCalled();
expect(other.value).toBe("");
expect(h.send).toHaveBeenCalledWith(
4,
"Input.dispatchKeyEvent",
expect.objectContaining({ type: "keyUp" }),
);
expect(h.release).toHaveBeenCalledOnce();
});
it.each([true, false])("verifies an empty request with clear_before=%s", async (clearBefore) => {
const h = await setup();
expect(await h.fill("", clearBefore)).toMatchObject({ value_length: clearBefore ? 0 : 3 });
expect(h.element.value).toBe(clearBefore ? "" : "old");
expect(h.insert).not.toHaveBeenCalled();
});
it("cancels after insertion without sending change events", async () => {
const h = await setup();
const controller = new AbortController();
h.insert.mockImplementation(async () => controller.abort());
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
expect(h.script).toHaveBeenCalledTimes(2);
expect(h.release).toHaveBeenCalledOnce();
});
it("cancels after resolving the remote object without clearing the field", async () => {
const h = await setup();
const controller = new AbortController();
const send = h.send.getMockImplementation()!;
h.send.mockImplementation(async (tabId, method, params) => {
const result = await send(tabId, method, params);
if (method === "DOM.resolveNode") controller.abort();
return result;
});
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
expect(h.element.value).toBe("old");
expect(h.script).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it("honors cancellation during verification", async () => {
const h = await setup();
const controller = new AbortController();
h.element.addEventListener("change", () => controller.abort());
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
expect(h.release).toHaveBeenCalledOnce();
});
it("keeps the verified outcome when releasing the remote object fails", async () => {
const h = await setup();
h.release.mockRejectedValueOnce(new Error("target closed"));
expect(await h.fill()).toMatchObject({ value_length: 5 });
});
});
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { resolveFrameProjection, resolveNodeGeometry } from "../frame-geometry";
import {
createFrameGeometryContext,
resolveFrameProjection,
resolveNodeGeometry,
} from "../frame-geometry";
import {
clipPolygon,
polygonArea,
@@ -37,6 +41,53 @@ 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 },
@@ -123,6 +123,29 @@ 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");
@@ -540,6 +563,21 @@ describe("handleHover", () => {
});
});
function successfulFillScript(params: unknown) {
const script = params as { arguments?: Array<{ value: unknown }>; functionDeclaration: string };
const args = script.arguments ?? [];
return {
result: {
value:
args.length === 2
? { before: "", expected: args[0].value }
: script.functionDeclaration.startsWith("function(expected)")
? { connected: true, matches: true, valueLength: String(args[0].value).length }
: "ready",
},
};
}
describe("handleFill", () => {
it("returns not_found for unknown ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
@@ -586,7 +624,8 @@ describe("handleFill", () => {
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
"Runtime.callFunctionOn": successfulFillScript,
"Runtime.releaseObject": () => ({}),
"Input.insertText": () => ({}),
});
const res = await handleFill(
@@ -600,13 +639,12 @@ describe("handleFill", () => {
expect(res.used_ref).toBe("e1");
const insert = fake.sent.find((c) => c.method === "Input.insertText");
expect(insert?.params).toEqual({ text: "hello" });
// clear_before defaults to true → callFunctionOn invoked once to
// clear, once to fire input/change after typing.
// Foreground replacement needs no extra caret-positioning round trip.
const callFns = fake.sent.filter((c) => c.method === "Runtime.callFunctionOn");
expect(callFns.length).toBeGreaterThanOrEqual(2);
expect(callFns).toHaveLength(4);
});
it("clear_before=false skips the wipe call", async () => {
it("passes clear_before=false to preparation and verifies the result", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 1, { tabId: 4 });
@@ -618,12 +656,12 @@ describe("handleFill", () => {
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }),
"Runtime.callFunctionOn": (p) => {
const fn = (p as { functionDeclaration?: string }).functionDeclaration ?? "";
// No "this.value = ''" clearing on a no-clear path; only the
// post-input dispatchEvent.
expect(fn).not.toMatch(/this\.value\s*=\s*''/);
return { result: { type: "undefined" } };
const args = (p as { arguments?: Array<{ value: unknown }> }).arguments ?? [];
if (args.length === 2) expect(args[1].value).toBe(false);
return successfulFillScript(p);
},
"Input.dispatchKeyEvent": () => ({}),
"Runtime.releaseObject": () => ({}),
"Input.insertText": () => ({}),
});
const res = await handleFill(
@@ -650,7 +688,8 @@ describe("handleFill", () => {
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
"Runtime.callFunctionOn": successfulFillScript,
"Runtime.releaseObject": () => ({}),
"Input.insertText": () => ({}),
});
const res = await handleFill(
@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { BrowserNavigationApi } from "../browser-navigation";
import { handleNavigate, handleReload } from "../navigation";
import type { CdpRunner } from "../shared";
const denied = "Cannot access a chrome-extension:// URL of different extension";
const url = "https://example.test/recovered";
function event<T extends (...args: never[]) => void>() {
const listeners = new Set<T>();
return {
listeners,
addListener: (listener: T) => listeners.add(listener),
removeListener: (listener: T) => listeners.delete(listener),
fire: (...args: Parameters<T>) => {
for (const listener of [...listeners]) listener(...args);
},
};
}
async function fixture() {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 4),
},
});
await manager.start("test");
type Listener = Parameters<BrowserNavigationApi["onCommitted"]["addListener"]>[0];
const events = {
onCommitted: event<Listener>(),
onDOMContentLoaded: event<Listener>(),
onCompleted: event<Listener>(),
onErrorOccurred: event<Listener>(),
};
const cdpEvents = event<Parameters<NonNullable<CdpRunner["onEvent"]>>[0]>();
const state = { blocked: true, destinationBlocked: false, emitIdle: true };
const send = vi.fn(async (_tabId: number, method: string) => {
if (state.blocked) throw new Error(denied);
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "main", loaderId: "new-loader" } } };
}
if (method === "Runtime.evaluate") return { result: { value: "complete" } };
if (method === "Page.setLifecycleEventsEnabled" && state.emitIdle) {
cdpEvents.fire({ tabId: 4 }, "Page.lifecycleEvent", {
frameId: "main",
loaderId: "new-loader",
name: "networkIdle",
});
}
if (method === "Page.navigate") return { frameId: "main", loaderId: "new-loader" };
return {};
});
const cdp: CdpRunner = {
send: send as CdpRunner["send"],
onEvent: (listener) => {
cdpEvents.addListener(listener);
return { dispose: () => cdpEvents.removeListener(listener) };
},
};
const main = { tabId: 4, frameId: 0, documentId: "new-document" };
const navigate = async () => {
state.blocked = state.destinationBlocked;
events.onCommitted.fire(main);
events.onDOMContentLoaded.fire(main);
events.onCompleted.fire(main);
};
const browserNavigation = { ...events, update: vi.fn(navigate), reload: vi.fn(navigate) };
const tab = { id: 4, windowId: 100, active: true, url } as chrome.tabs.Tab;
const tabsApi = { get: vi.fn(async () => tab), query: vi.fn(async () => [tab]) };
const deps = { cdp, tabsApi, browserNavigation, defaultTimeoutMs: 1000 };
const expectCleanedUp = () => {
for (const entry of Object.values(events)) expect(entry.listeners.size).toBe(0);
expect(cdpEvents.listeners.size).toBe(0);
};
return { manager, deps, state, send, events, main, cdpEvents, expectCleanedUp };
}
describe("navigation after Chrome denies extension-frame access", () => {
it.each([
"load",
"commit",
"domcontentloaded",
"networkidle",
] as const)("recovers the same tab with the requested %s checkpoint", async (waitUntil) => {
const f = await fixture();
const result = await handleNavigate(
f.manager,
{
session_id: "test",
url,
wait_until: waitUntil,
},
f.deps,
);
expect(result).toMatchObject({ tab_id: 4, final_url: url, reached: waitUntil });
expect(f.deps.browserNavigation.update).toHaveBeenCalledExactlyOnceWith(4, { url });
expect(f.send.mock.calls.some(([, method]) => method === "Page.navigate")).toBe(false);
expect(f.send).toHaveBeenCalledWith(4, "Page.enable", {});
f.expectCleanedUp();
});
it("recovers reload through tabs.reload and preserves hard reload", async () => {
const f = await fixture();
const result = await handleReload(f.manager, { session_id: "test", hard: true }, f.deps);
expect(result).toMatchObject({ tab_id: 4, reached: "load" });
expect(f.deps.browserNavigation.reload).toHaveBeenCalledExactlyOnceWith(4, {
bypassCache: true,
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("reports the restriction when reload leaves the conflicting frame present", async () => {
const f = await fixture();
f.state.destinationBlocked = true;
const result = await handleReload(f.manager, { session_id: "test" }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
});
expect(f.deps.browserNavigation.reload).toHaveBeenCalledOnce();
f.expectCleanedUp();
});
it("does not fall back for unrelated debugger failures", async () => {
const f = await fixture();
f.send.mockRejectedValue(new Error("Another debugger is already attached"));
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
message: "Another debugger is already attached",
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("does not replay navigation when a dispatched CDP command fails", async () => {
const f = await fixture();
f.state.blocked = false;
const send = f.send.getMockImplementation()!;
f.send.mockImplementation(async (tabId, method) => {
if (method === "Page.navigate") throw new Error(denied);
return send(tabId, method);
});
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("keeps the Agent Window guard in front of the recovery", async () => {
const f = await fixture();
f.deps.tabsApi.get.mockResolvedValue({
id: 4,
windowId: 200,
active: true,
url,
} as chrome.tabs.Tab);
const result = await handleNavigate(f.manager, { session_id: "test", tab_id: 4, url }, f.deps);
expect(result).toMatchObject({ code: "permission_denied" });
expect(f.send).not.toHaveBeenCalled();
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
});
it("ignores old-document, subframe, and other-tab completion events", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {});
let settled = false;
const work = handleNavigate(f.manager, { session_id: "test", url }, f.deps).then((result) => {
settled = true;
return result;
});
await vi.waitFor(() => expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce());
f.events.onCompleted.fire(f.main);
f.events.onCommitted.fire({ ...f.main, tabId: 9 });
f.events.onCommitted.fire({ ...f.main, frameId: 1 });
f.events.onCompleted.fire(f.main);
await Promise.resolve();
expect(settled).toBe(false);
f.events.onCommitted.fire(f.main);
f.events.onCompleted.fire({ ...f.main, documentId: "old-document" });
await Promise.resolve();
expect(settled).toBe(false);
f.state.blocked = false;
f.events.onCompleted.fire(f.main);
expect(await work).toMatchObject({ reached: "load" });
f.expectCleanedUp();
});
it("does not infer network idle from a browser load event", async () => {
const f = await fixture();
f.state.emitIdle = false;
const result = await handleNavigate(
f.manager,
{
session_id: "test",
url,
wait_until: "networkidle",
timeout_ms: 10,
},
f.deps,
);
expect(result).toMatchObject({ reached: "timeout" });
f.expectCleanedUp();
});
it("cleans up browser listeners on timeout", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {});
const result = await handleNavigate(
f.manager,
{ session_id: "test", url, timeout_ms: 10 },
f.deps,
);
expect(result).toMatchObject({ reached: "timeout" });
f.expectCleanedUp();
});
it("does not navigate an already-cancelled request", async () => {
const f = await fixture();
const result = await handleNavigate(
f.manager,
{ session_id: "test", url },
{
...f.deps,
signal: AbortSignal.abort(),
},
);
expect(result).toMatchObject({ code: "cancelled" });
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("cleans up browser listeners when cancelled after dispatch", async () => {
const f = await fixture();
const ac = new AbortController();
f.deps.browserNavigation.update.mockImplementation(async () => {
ac.abort();
});
const result = await handleNavigate(
f.manager,
{ session_id: "test", url },
{ ...f.deps, signal: ac.signal },
);
expect(result).toMatchObject({ code: "cancelled" });
expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce();
f.expectCleanedUp();
});
it("reports browser navigation errors and releases listeners", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {
f.events.onErrorOccurred.fire({ ...f.main, error: "net::ERR_NAME_NOT_RESOLVED" });
});
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({ code: "cdp_failed", message: "net::ERR_NAME_NOT_RESOLVED" });
f.expectCleanedUp();
});
});
@@ -36,6 +36,14 @@ 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;
@@ -86,6 +94,7 @@ 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();
@@ -120,6 +129,20 @@ 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");
@@ -300,6 +323,250 @@ describe("handleScreenshot", () => {
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/ });
});
it("returns not_found for unknown ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
@@ -2833,6 +3100,229 @@ 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");
@@ -24,10 +24,14 @@ 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"],
});
});
@@ -53,6 +57,8 @@ describe("resolveSnapshotRef", () => {
tabId: 4,
frameId: "child-frame",
cdpSessionId: "child-session",
kind: "dom",
capabilities: ["interact", "screenshot"],
});
const expected = {
@@ -60,6 +66,8 @@ 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);
@@ -105,6 +113,28 @@ 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",
},
});
});
});
@@ -0,0 +1,37 @@
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);
});
});
@@ -0,0 +1,110 @@
import type { RpcError, WaitUntil } from "@/transport/types";
import { cdpError } from "./errors";
interface NavigationEvent {
tabId: number;
frameId: number;
documentId?: string;
error?: string;
}
interface NavigationEvents {
addListener(listener: (details: NavigationEvent) => void): void;
removeListener(listener: (details: NavigationEvent) => void): void;
}
/** Normal browser navigation remains available when chrome.debugger is denied. */
export interface BrowserNavigationApi {
update(tabId: number, props: { url: string }): Promise<unknown>;
reload(tabId: number, props: { bypassCache: boolean }): Promise<void>;
onCommitted: NavigationEvents;
onDOMContentLoaded: NavigationEvents;
onCompleted: NavigationEvents;
onErrorOccurred: NavigationEvents;
}
export const chromeBrowserNavigationApi: BrowserNavigationApi = {
update: (tabId, props) => chrome.tabs.update(tabId, props),
reload: (tabId, props) => chrome.tabs.reload(tabId, props),
get onCommitted() {
return chrome.webNavigation.onCommitted;
},
get onDOMContentLoaded() {
return chrome.webNavigation.onDOMContentLoaded;
},
get onCompleted() {
return chrome.webNavigation.onCompleted;
},
get onErrorOccurred() {
return chrome.webNavigation.onErrorOccurred;
},
};
export type NavigationOutcome =
| { reached: "match" | "timeout" | "cancelled"; lastLifecycle?: string }
| { reached: "failed"; error: RpcError };
/** Subscribe before dispatch and ignore the departing document's load events. */
export async function navigateWithBrowserApi(
api: BrowserNavigationApi,
tabId: number,
action: () => Promise<unknown>,
waitUntil: Exclude<WaitUntil, "networkidle">,
timeoutMs: number,
signal?: AbortSignal,
): Promise<NavigationOutcome> {
if (signal?.aborted) return { reached: "cancelled" };
if (timeoutMs <= 0) return { reached: "timeout" };
let committed = false;
let documentId: string | undefined;
let lastLifecycle: string | undefined;
let finish!: (outcome: NavigationOutcome) => void;
const outcome = new Promise<NavigationOutcome>((resolve) => {
finish = resolve;
});
const isMainFrame = (details: NavigationEvent) =>
details.tabId === tabId && details.frameId === 0;
const onCommitted = (details: NavigationEvent) => {
if (!isMainFrame(details)) return;
committed = true;
documentId = details.documentId;
lastLifecycle = "commit";
if (waitUntil === "commit") finish({ reached: "match", lastLifecycle });
};
const onDOMContentLoaded = (details: NavigationEvent) => {
if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId))
return;
lastLifecycle = "DOMContentLoaded";
if (waitUntil === "domcontentloaded") finish({ reached: "match", lastLifecycle });
};
const onCompleted = (details: NavigationEvent) => {
if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId))
return;
finish({ reached: "match", lastLifecycle: "load" });
};
const onError = (details: NavigationEvent) => {
if (!isMainFrame(details)) return;
finish({ reached: "failed", error: cdpError(details.error ?? "browser navigation failed") });
};
const onAbort = () => finish({ reached: "cancelled", lastLifecycle });
const subscriptions = [
[api.onCommitted, onCommitted],
[api.onDOMContentLoaded, onDOMContentLoaded],
[api.onCompleted, onCompleted],
[api.onErrorOccurred, onError],
] as const;
for (const [event, listener] of subscriptions) event.addListener(listener);
signal?.addEventListener("abort", onAbort, { once: true });
const timer = setTimeout(() => finish({ reached: "timeout", lastLifecycle }), timeoutMs);
try {
if (signal?.aborted) return { reached: "cancelled" };
await action();
return await outcome;
} catch (error) {
return { reached: "failed", error: cdpError(error) };
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
for (const [event, listener] of subscriptions) event.removeListener(listener);
}
}
+2 -1
View File
@@ -36,6 +36,7 @@ import { isRequestFrame } from "@/transport/types";
import { handleConsole } from "./console";
import { handleDownload } from "./download";
import { type EmulateCdpRunner, handleEmulate } from "./emulate";
import { classifyCdpError } from "./errors";
import { handleEvaluate } from "./evaluate";
import { handleRequestHelp } from "./human-loop";
import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction";
@@ -239,7 +240,7 @@ export class ToolDispatcher {
if (sessionId) this.onBrowserControlResumed?.(sessionId);
const result = await this.invoke(req, ac.signal);
if (isRpcError(result)) {
body = { id: req.id, error: result };
body = { id: req.id, error: classifyCdpError(result) };
} else {
body = { id: req.id, result };
if (req.method === "tool.session_start") {
+28
View File
@@ -13,6 +13,34 @@ import type {
export type { RpcErrorData, RpcErrorReason };
/** Chrome checks the whole tab's frame tree, including other extensions' frames. */
export function isCdpExtensionAccessDenied(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Cannot access a chrome-extension:// URL of different extension");
}
/** Preserve more specific reasons, especially file-transfer outcome/cleanup details. */
export function classifyCdpError(error: RpcError): RpcError {
if (
error.code !== "cdp_failed" ||
error.data?.reason ||
!isCdpExtensionAccessDenied(error.message)
) {
return error;
}
return {
...error,
data: { ...error.data, reason: "cdp_extension_access_denied" },
};
}
export function cdpError(error: unknown): RpcError {
return classifyCdpError({
code: "cdp_failed",
message: error instanceof Error ? error.message : String(error),
});
}
export interface TransferErrorOptions {
effectState: TransferEffectState;
phase: string;
+186 -50
View File
@@ -1,4 +1,9 @@
import { type CdpFrame, type CdpFrameGraph, type CdpTarget } from "@/browser-driver/frame-graph";
import {
type CdpFrame,
type CdpFrameGraph,
type CdpTarget,
cdpTargetKey,
} from "@/browser-driver/frame-graph";
import type { RpcError } from "@/transport/types";
import { nodeContentRegion, scrollNodeIntoView } from "./element-geometry";
import {
@@ -10,6 +15,7 @@ import {
parseCdpQuad,
polygonArea,
polygonCentroid,
projectRectToViewport,
projectRegionToViewport,
type Quad,
type Region,
@@ -84,6 +90,41 @@ async function targetViewport(cdp: CdpRunner, target: CdpTarget): Promise<Size |
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,
@@ -98,26 +139,150 @@ async function ownerContentQuad(
return parseCdpQuad(result.model?.content);
}
async function sameTargetFrameClips(
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,
byId: Map<string, CdpFrame>,
frame: CdpFrame,
targetRoot: CdpFrame,
): Promise<Polygon[] | null> {
const clips: Polygon[] = [];
let current = frame;
const seen = new Set<string>();
while (current.frameId !== targetRoot.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 ownerContentQuad(cdp, parent, current.ownerBackendNodeId);
if (!clip) return null;
clips.push(clip);
current = parent;
}
return clips;
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(
@@ -125,36 +290,7 @@ export async function resolveFrameProjection(
graph: CdpFrameGraph,
frameId: string,
): Promise<GeometryProjection | null> {
const byId = frameMap(graph);
const frame = byId.get(frameId);
if (!frame) return null;
let targetRoot = targetRootFrame(byId, frame);
if (!targetRoot) return null;
const sourceViewport = await targetViewport(cdp, targetRoot.target);
if (!sourceViewport) return null;
const sourceClips = await sameTargetFrameClips(cdp, byId, frame, targetRoot);
if (!sourceClips) return null;
const edges: ProjectiveEdge[] = [];
while (targetRoot.parentFrameId) {
const parent = byId.get(targetRoot.parentFrameId);
if (!parent || targetRoot.ownerBackendNodeId === undefined) return null;
const destinationQuad = await ownerContentQuad(cdp, parent, targetRoot.ownerBackendNodeId);
if (!destinationQuad) return null;
const source =
edges.length === 0 ? sourceViewport : await targetViewport(cdp, targetRoot.target);
if (!source) return null;
const parentTargetRoot = targetRootFrame(byId, parent);
if (!parentTargetRoot) return null;
const destinationClips = await sameTargetFrameClips(cdp, byId, parent, parentTargetRoot);
if (!destinationClips) return null;
edges.push({ sourceViewport: source, destinationQuad, destinationClips });
targetRoot = parentTargetRoot;
}
const topViewport =
edges.length === 0 ? sourceViewport : await targetViewport(cdp, targetRoot.target);
return topViewport ? { sourceClips, edges, topViewport } : null;
return createFrameGeometryContext(cdp, graph).resolveFrameProjection(frameId);
}
async function loadFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFrameGraph | null> {
-19
View File
@@ -245,22 +245,3 @@ export function projectRectToViewport(
if (!rect) return null;
return regionBounds(projectRegionToViewport([rectPolygon(rect)], projection));
}
export function childFrameProjection(
parent: GeometryProjection,
ownerRectInParent: { x: number; y: number; w: number; h: number },
): GeometryProjection {
const destinationQuad = rectPolygon(ownerRectInParent);
return {
sourceClips: [],
edges: [
{
sourceViewport: { width: ownerRectInParent.w, height: ownerRectInParent.h },
destinationQuad,
destinationClips: parent.sourceClips,
},
...parent.edges,
],
topViewport: parent.topViewport,
};
}
+281 -43
View File
@@ -169,7 +169,7 @@ export async function resolveBackendNode(
};
}
if (hasRef) {
const resolved = resolveSnapshotRef(ctx, params.ref as string, target.tabId);
const resolved = resolveSnapshotRef(ctx, params.ref as string, target.tabId, "interact");
if (isRpcError(resolved)) return resolved;
return {
backendNodeId: resolved.backendNodeId,
@@ -609,6 +609,53 @@ type SelectMutationResult =
// tool.fill
// ---------------------------------------------------------------------------
type FillFailureReason =
| "target_not_fillable"
| "fill_value_invalid"
| "fill_target_changed"
| "fill_focus_lost"
| "fill_value_mismatch"
| "fill_failed";
type FillPreparation =
| { before: string; expected: string }
| { reason: FillFailureReason; error: string };
type FillReadiness = "ready" | "background" | "fill_target_changed" | "fill_focus_lost";
function fillError(reason: FillFailureReason, message: string): RpcError {
return rpcError(
reason === "target_not_fillable" || reason === "fill_value_invalid"
? "invalid_params"
: "cdp_failed",
reason,
message,
);
}
interface FillScriptReply<T> {
result?: { value?: T };
exceptionDetails?: unknown;
}
const FILL_EDITABLE_FUNCTION = `function() {
const tag = this.tagName.toLowerCase();
const supported = tag === 'input'
? ['text', 'search', 'tel', 'url', 'email', 'password', 'number'].includes(this.type)
: tag === 'textarea' || this.isContentEditable;
return this.isConnected && supported && !this.readOnly && !this.matches(':disabled');
}`;
// Chrome renders a trailing editable newline with an empty <div><br></div>.
// innerText includes the padding break; it is not an extra typed character.
const FILL_VALUE_FUNCTION = `function() {
if (!this.isContentEditable) return this.value;
const value = this.innerText;
const tail = this.lastChild;
const padding = tail && tail.nodeName === 'DIV' && tail.childNodes.length === 1 && tail.firstChild.nodeName === 'BR';
return padding && value.endsWith('\\n\\n') ? value.slice(0, -1) : value;
}`;
export async function handleFill(
manager: SessionManager,
params: FillParams,
@@ -660,12 +707,8 @@ export async function handleFill(
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
return fillError("fill_failed", err instanceof Error ? err.message : String(err));
}
if (throwIfAborted(deps.signal)) {
@@ -678,57 +721,252 @@ export async function handleFill(
const clearBefore = params.clear_before ?? true;
try {
if (clearBefore) {
// Clear input/textarea value or wipe contenteditable innerText,
// then fire `input` so frameworks observe the empty state.
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
objectId,
functionDeclaration: `function() {
if (this.isContentEditable) { this.textContent = ''; }
else {
const proto = this instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
if (descriptor && descriptor.set) descriptor.set.call(this, '');
else this.value = '';
}
this.dispatchEvent(new Event('input', { bubbles: true }));
}`,
returnByValue: true,
});
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
try {
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (error) {
// Chrome rejects DOM.focus for disabled controls, including inherited
// fieldset state. Classify the live target before treating this as a
// browser failure; the normal path needs no extra round trip.
const editable = await nodeCdp.send<FillScriptReply<boolean>>(
target.tabId,
"Runtime.callFunctionOn",
{ objectId, functionDeclaration: FILL_EDITABLE_FUNCTION, returnByValue: true },
);
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (!editable.exceptionDetails && editable.result?.value === false) {
return fillError(
"target_not_fillable",
"fill target is not editable or its input type is unsupported",
);
}
throw error;
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Check editability before clearing. Keep the expected result tied to
// this object, including the existing value on the append path.
const prepared = await nodeCdp.send<FillScriptReply<FillPreparation>>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: `function(value, clearBefore) {
const tag = this.tagName.toLowerCase();
const native = tag === 'input' || tag === 'textarea';
if (!(${FILL_EDITABLE_FUNCTION}).call(this)) return { reason: 'target_not_fillable', error: 'fill target is not editable or its input type is unsupported' };
if (this.getRootNode().activeElement !== this) return { reason: 'fill_focus_lost', error: 'fill target does not have focus' };
const before = clearBefore ? '' : (${FILL_VALUE_FUNCTION}).call(this);
let expected = before + value;
if (native) {
// Use the browser's own value sanitization (e.g. textarea
// line endings) without changing the live control.
const normalizer = this.ownerDocument.createElement(tag);
if (tag === 'input') {
normalizer.type = this.type;
normalizer.multiple = this.multiple;
}
// insertText treats line breaks in a single-line input as spaces.
if (tag === 'input') expected = expected.replace(/\\r\\n|\\r|\\n/g, ' ');
normalizer.value = expected;
if (expected !== '' && normalizer.value === '' && this.type === 'number') {
return { reason: 'fill_value_invalid', error: 'fill value is not valid for a number input' };
}
expected = normalizer.value;
if (this.type !== 'number' && this.maxLength >= 0 && expected.length > this.maxLength) {
return { reason: 'fill_value_invalid', error: 'fill value exceeds the target maxlength' };
}
} else {
expected = expected.replace(/\\r\\n?/g, '\\n');
}
if (clearBefore) {
if (native) {
const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value').set.call(this, '');
} else {
this.textContent = '';
}
this.dispatchEvent(new Event('input', { bubbles: true }));
}
return { before, expected };
}`,
arguments: [{ value: params.value }, { value: clearBefore }],
returnByValue: true,
},
);
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (prepared.exceptionDetails) {
return fillError("fill_failed", "fill preparation script failed");
}
const preparation = prepared.result?.value;
if (preparation && "error" in preparation) {
return fillError(preparation.reason, preparation.error);
}
if (typeof preparation?.before !== "string" || typeof preparation?.expected !== "string") {
return fillError("fill_failed", "fill preparation returned an unexpected result");
}
// A separate call runs after the clearing event's microtasks drain.
// DOM focus works in background tabs. Use document.hasFocus() only to
// detect deferred focus events, never to reject background input.
const checkReady = async (): Promise<FillReadiness | RpcError> => {
const reply = await nodeCdp.send<FillScriptReply<FillReadiness>>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: `function(before) {
if (!(${FILL_EDITABLE_FUNCTION}).call(this) ||
(${FILL_VALUE_FUNCTION}).call(this) !== before) return 'fill_target_changed';
if (this.getRootNode().activeElement !== this) return 'fill_focus_lost';
return this.ownerDocument.hasFocus() ? 'ready' : 'background';
}`,
arguments: [{ value: preparation.before }],
returnByValue: true,
},
);
const state = reply.result?.value;
if (
reply.exceptionDetails ||
(state !== "ready" &&
state !== "background" &&
state !== "fill_target_changed" &&
state !== "fill_focus_lost")
) {
return fillError(
"fill_failed",
"fill readiness script failed or returned an unexpected result",
);
}
return state;
};
let ready = await checkReady();
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Restore focus once only if clearing left this same target editable
// and unchanged. Recheck after focus handlers have run.
if (clearBefore && ready === "fill_focus_lost") {
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
ready = await checkReady();
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (typeof ready !== "string") return ready;
if (ready !== "ready" && ready !== "background") {
return fillError(ready, "fill target changed or lost focus before typing");
}
if (params.value !== "" && (!clearBefore || ready === "background")) {
// Native editing commands also support number/email inputs, whose
// selection APIs cannot set a caret, and multiline contenteditables.
// Also do this for background replacement: CDP input focuses the
// renderer, delivering deferred focus events before it inserts text.
// Use a command-only event so this does not invoke an End shortcut.
try {
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
type: "rawKeyDown",
commands: ["moveToEndOfDocument"],
});
} finally {
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp" });
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Focus and key handlers can change the target or move focus too.
ready = await checkReady();
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (typeof ready !== "string") return ready;
if (ready !== "ready" && ready !== "background") {
return fillError(ready, "fill target changed or lost focus while positioning the caret");
}
}
// CDP `Input.insertText` handles IME / multi-byte input out of the
// box, much more reliably than per-key `dispatchKeyEvent`.
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
if (params.value !== "") {
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Fire `input` + `change` so React / Vue controlled inputs commit.
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
// Keep the existing notifications, then read in a separate call so
// nested microtasks queued by the page cannot race the verification.
const notified = await nodeCdp.send<FillScriptReply<unknown>>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: `function() {
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}`,
returnByValue: true,
},
);
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (notified.exceptionDetails) {
return fillError("fill_failed", "fill notification script failed");
}
const verified = await nodeCdp.send<
FillScriptReply<{ connected: boolean; matches: boolean; valueLength: number }>
>(target.tabId, "Runtime.callFunctionOn", {
objectId,
functionDeclaration: `function() {
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}`,
functionDeclaration: `function(expected) {
const value = (${FILL_VALUE_FUNCTION}).call(this);
return { connected: this.isConnected, matches: value === expected, valueLength: value.length };
}`,
arguments: [{ value: preparation.expected }],
returnByValue: true,
});
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
const verification = verified.result?.value;
if (
verified.exceptionDetails ||
typeof verification?.connected !== "boolean" ||
typeof verification?.matches !== "boolean" ||
typeof verification?.valueLength !== "number"
) {
return fillError(
"fill_failed",
"fill verification script failed or returned an unexpected result",
);
}
if (!verification.connected) {
return fillError("fill_target_changed", "fill target was removed or replaced during input");
}
if (!verification.matches) {
return fillError(
"fill_value_mismatch",
"fill could not verify the expected value; observe the page before deciding whether to retry",
);
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
used_ref: node.usedRef,
used_selector: node.usedSelector,
value_length: verification.valueLength,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
return fillError("fill_failed", err instanceof Error ? err.message : String(err));
} finally {
await nodeCdp.send(target.tabId, "Runtime.releaseObject", { objectId }).catch(() => undefined);
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
used_ref: node.usedRef,
used_selector: node.usedSelector,
value_length: params.value.length,
});
}
// ---------------------------------------------------------------------------
+116 -10
View File
@@ -28,7 +28,13 @@ import type {
RpcError,
WaitUntil,
} from "@/transport/types";
import {
type BrowserNavigationApi,
chromeBrowserNavigationApi,
navigateWithBrowserApi,
} from "./browser-navigation";
import { attachDialogs, markDialogCursor } from "./dialogs";
import { cdpError, isCdpExtensionAccessDenied } from "./errors";
import {
type CdpRunner,
type ChromeTabsApi,
@@ -42,6 +48,7 @@ import {
export interface NavigationDeps {
cdp: CdpRunner;
tabsApi: ChromeTabsApi;
browserNavigation?: BrowserNavigationApi;
/** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */
signal?: AbortSignal;
/** Override default timeout when the caller omits `timeout_ms`. */
@@ -479,6 +486,81 @@ async function readTabUrl(api: ChromeTabsApi, tabId: number): Promise<string | u
}
}
/** Recover only a failed preflight; never replay an already-dispatched navigation. */
async function recoverBrowserNavigation(
deps: NavigationDeps,
tabId: number,
action: (api: BrowserNavigationApi) => Promise<unknown>,
waitUntil: WaitUntil,
timeoutMs: number,
): Promise<ReloadResult | RpcError> {
const abort = linkedAbortSignal(deps.signal);
const deadline = Date.now() + timeoutMs;
const api = deps.browserNavigation ?? chromeBrowserNavigationApi;
try {
let outcome = await navigateWithBrowserApi(
api,
tabId,
() => action(api),
waitUntil === "networkidle" ? "commit" : waitUntil,
timeoutMs,
abort.signal,
);
if (outcome.reached === "failed") return outcome.error;
if (outcome.reached === "cancelled" || abort.signal.aborted) {
return { code: "cancelled", message: "navigation recovery aborted" };
}
if (outcome.reached === "match") {
// A completed reload is not a recovery if the restricted frame remains.
await deps.cdp.send(tabId, "Page.enable", {});
if (abort.signal.aborted)
return { code: "cancelled", message: "navigation recovery aborted" };
if (waitUntil === "networkidle") {
// webNavigation cannot prove network idle. Subscribe on the new
// document before enabling CDP lifecycle events after its commit.
const frame = await readMainFrameInfo(deps.cdp, tabId);
if (!frame.frameId) return cdpError("no main frame after navigation");
const remaining = deadline - Date.now();
if (remaining <= 0) {
outcome = { reached: "timeout", lastLifecycle: "commit" };
} else {
const wait = startLifecycleWait(
deps.cdp,
tabId,
frame.frameId,
"networkIdle",
remaining,
abort.signal,
{ loaderId: frame.loaderId ?? undefined },
);
await deps.cdp.send(tabId, "Page.setLifecycleEventsEnabled", { enabled: true });
outcome = await wait.promise;
}
}
}
if (outcome.reached === "cancelled" || abort.signal.aborted) {
return { code: "cancelled", message: "navigation recovery aborted" };
}
return {
tab_id: tabId,
final_url: await readTabUrl(deps.tabsApi, tabId),
reached: outcome.reached === "match" ? waitUntil : "timeout",
...(outcome.reached === "timeout"
? {
error_text: `timed out waiting for ${waitUntil} during navigation recovery after ${timeoutMs}ms`,
}
: {}),
};
} catch (error) {
return abort.signal.aborted
? { code: "cancelled", message: "navigation recovery aborted" }
: cdpError(error);
} finally {
abort.abort();
abort.cleanup();
}
}
// ---------------------------------------------------------------------------
// tool.navigate
// ---------------------------------------------------------------------------
@@ -505,7 +587,20 @@ export async function handleNavigate(
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await ensureCdpReady(deps.cdp, target.tabId);
try {
await ensureCdpReady(deps.cdp, target.tabId);
} catch (error) {
if (!isCdpExtensionAccessDenied(error)) throw error;
const recovered = await recoverBrowserNavigation(
deps,
target.tabId,
(api) => api.update(target.tabId, { url: params.url }),
waitUntil,
timeoutMs,
);
if (isRpcError(recovered)) return recovered;
return attachDialogs(deps.cdp, target.tabId, dialogCursor, { ...recovered, url: params.url });
}
const expected = cdpLifecycleName(waitUntil);
const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId);
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
@@ -574,10 +669,7 @@ export async function handleNavigate(
}`,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
return cdpError(err);
}
}
@@ -727,7 +819,24 @@ export async function handleReload(
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await ensureCdpReady(deps.cdp, target.tabId);
try {
await ensureCdpReady(deps.cdp, target.tabId);
} catch (error) {
if (!isCdpExtensionAccessDenied(error)) throw error;
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
const recovered = await recoverBrowserNavigation(
deps,
target.tabId,
(api) => api.reload(target.tabId, { bypassCache: ignoreCache }),
waitUntil,
timeoutMs,
);
if (isRpcError(recovered)) return recovered;
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
...recovered,
previous_url: previousUrl,
});
}
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
const expected = cdpLifecycleName(waitUntil);
@@ -776,10 +885,7 @@ export async function handleReload(
}`,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
return cdpError(err);
}
}
+120 -28
View File
@@ -8,6 +8,7 @@ import {
applyVomInteractionRecovery,
type CondSurface,
isVomReferenceNode,
type Rect,
renderVom,
type VomNode,
type VomOptions,
@@ -47,6 +48,13 @@ 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,
@@ -62,6 +70,11 @@ import {
type CaptureVomObservationResult,
projectRecordSafeObservation,
} from "./vom/record-safe-observation";
import {
clusterRenderedSurfaces,
discoverRenderedSurfaces,
projectRenderedSurfaces,
} from "./vom/rendered-surfaces";
import {
buildSemanticGraph,
buildSemanticVomScene,
@@ -93,6 +106,13 @@ 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.
@@ -107,8 +127,8 @@ export function stripDataUrlPrefix(dataUrl: string): string {
/**
* Parse a PNG's IHDR chunk and return `(width, height)`. Returns
* `null` on any malformed input so callers fall back to `0/0` instead
* of throwing.
* `null` on malformed input so callers can reject dimensions that are
* not authoritative instead of guessing from a CSS-space rectangle.
*
* 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
@@ -120,9 +140,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;
if (bin.charCodeAt(0) !== 0x89 || bin.charCodeAt(1) !== 0x50 || bin.charCodeAt(2) !== 0x4e) {
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;
const u32 = (off: number) =>
(bin.charCodeAt(off) << 24) |
(bin.charCodeAt(off + 1) << 16) |
@@ -185,6 +205,8 @@ 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");
@@ -192,33 +214,74 @@ async function captureElementScreenshot(
cdp,
tabId,
{ target, backendNodeId, ...(frameId ? { frameId } : {}) },
{ scrollIntoView: true },
{ scrollIntoView: !visualSurface },
);
if (isRpcError(geometry)) return geometry;
if (signal?.aborted) return cancelled("screenshot");
const rect = geometry.topBounds;
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;
try {
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: 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" };
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 dims = parsePngDimensions(image_base64) ?? {
width: Math.round(rect.width),
height: Math.round(rect.height),
return {
code: "cdp_failed",
message: "Page.captureScreenshot exceeded the visual surface pixel budget",
};
return { image_base64, width: dims.width, height: dims.height };
} catch (err) {
return {
code: "cdp_failed",
@@ -313,8 +376,14 @@ export async function handleScreenshot(
if (!deps.cdp) {
return { code: "cdp_failed", message: "screenshot ref capture requires CDP" };
}
const node = resolveSnapshotRef(ctx, ref, target.tabId);
const node = resolveSnapshotRef(ctx, ref, target.tabId, "screenshot");
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);
@@ -333,6 +402,8 @@ export async function handleScreenshot(
nodeTarget,
node.backendNodeId,
node.frameId,
node.kind === "surface",
node.visibleRect,
signal,
),
deps.sendToTab,
@@ -364,7 +435,10 @@ export async function handleScreenshot(
if (isRpcError(captured)) return captured;
if (signal?.aborted) return cancelled("screenshot");
const image_base64 = captured;
const dims = parsePngDimensions(image_base64) ?? { width: 0, height: 0 };
const dims = parsePngDimensions(image_base64);
if (!dims) {
return { code: "cdp_failed", message: "screenshot capture returned invalid PNG data" };
}
return withShotDialogs({
image_base64,
width: dims.width,
@@ -1083,6 +1157,7 @@ async function runHoverProbes(
export interface CaptureVomObservationOptions extends VomOptions {
conditionalSurfaceProbe?: boolean;
renderedSurfaceDiscovery?: boolean;
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
signal?: AbortSignal;
}
@@ -1131,7 +1206,20 @@ export async function captureVomObservation(
captured,
hoverProbes.surfaceProbes,
);
const rendered = renderVom(decoratedScene, {
const observedScene = options.renderedSurfaceDiscovery
? projectRenderedSurfaces(
decoratedScene,
normalizedDocuments,
clusterRenderedSurfaces(
discoverRenderedSurfaces(
normalizedDocuments,
captured.viewport,
captured.excludedBackendNodeIds,
),
),
)
: decoratedScene;
const rendered = renderVom(observedScene, {
maxDepth: options.maxDepth,
maxTokens: options.maxTokens,
redactValues: options.redactValues,
@@ -1186,6 +1274,7 @@ async function handleVomObservation(
maxTokens: params.max_tokens,
activeRegionPolicy: true,
conditionalSurfaceProbe: effectiveConditionalSurfaceProbe,
renderedSurfaceDiscovery: toolName === "observe",
hoverProbeBypassOverlay: deps.hoverProbeBypassOverlay,
signal,
});
@@ -1203,6 +1292,9 @@ 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;
}),
+17 -1
View File
@@ -2,8 +2,9 @@
// 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 } from "@/session-manager/ref-store";
import { normaliseRef, type RefCapability, type RefTargetKind } from "@/session-manager/ref-store";
import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors";
@@ -12,6 +13,9 @@ export interface SnapshotRefLookup {
refKey: string;
frameId?: string;
cdpSessionId?: string;
visibleRect?: Rect;
kind: RefTargetKind;
capabilities: RefCapability[];
}
function refEntryForTab(ctx: SessionContext, refKey: string, tabId: number) {
@@ -37,6 +41,9 @@ 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,
};
}
@@ -49,6 +56,7 @@ export function resolveSnapshotRef(
ctx: SessionContext,
ref: string,
tabId: number,
requiredCapability?: RefCapability,
): SnapshotRefLookup | RpcError {
const looked = lookupSnapshotRef(ctx, ref, tabId);
if (looked === null) {
@@ -58,5 +66,13 @@ 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;
}
@@ -0,0 +1,18 @@
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;
}
}
@@ -0,0 +1,52 @@
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,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { OVERLAY_HOST_MARKER_ATTR, OVERLAY_HOST_NAME } from "../../../lib/overlay-bridge";
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.
@@ -215,21 +216,29 @@ function makeCdp(snapshot: unknown) {
if (method === "Runtime.evaluate") {
return {
result: {
value: {
controls: [
deepSerializedValue: {
type: "array",
value: [
{
state: "filled",
sensitive: true,
defaultValue: "hunter2",
value: "hunter2",
placeholder: "secret",
type: "array",
value: [
{ type: "node", value: { backendNodeId: 13 } },
{
type: "string",
value: JSON.stringify({
state: "filled",
sensitive: true,
placeholder: "secret",
}),
},
],
},
],
childFrames: [],
},
},
};
}
if (method === "Runtime.releaseObjectGroup") return {};
throw new Error(`unexpected ${method}`);
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
};
@@ -273,8 +282,11 @@ describe("captureViewModel", () => {
expect(cdp.send).toHaveBeenCalledWith(
4,
"Runtime.evaluate",
expect.objectContaining({ returnByValue: true }),
expect.objectContaining({
serializationOptions: expect.objectContaining({ serialization: "deep" }),
}),
);
expect(cdp.send).toHaveBeenCalledWith(4, "Runtime.releaseObjectGroup", expect.anything());
expect(cdp.send).not.toHaveBeenCalledWith(4, "DOM.resolveNode", expect.anything());
expect(cdp.send).not.toHaveBeenCalledWith(4, "Runtime.callFunctionOn", expect.anything());
});
@@ -724,6 +736,44 @@ 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) => {
@@ -743,8 +793,10 @@ describe("captureViewModel", () => {
expect(div?.rect).toMatchObject({ y: 0, h: 600 });
});
it("normalizes device-pixel bounds by devicePixelRatio", async () => {
const S = ["html", "body", "div", "position", "fixed", "static", "pointer-events", "auto"];
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"];
const i = (s: string) => S.indexOf(s);
const snapshot = {
strings: S,
@@ -752,7 +804,7 @@ describe("captureViewModel", () => {
{
nodes: {
parentIndex: [-1, 0, 1],
nodeName: [i("html"), i("body"), i("div")],
nodeName: [i("html"), i("body"), i("canvas")],
backendNodeId: [10, 11, 12],
attributes: [[], [], []],
},
@@ -763,8 +815,8 @@ describe("captureViewModel", () => {
[i("fixed"), i("auto")],
],
bounds: [
[0, 0, 2000, 3200],
[0, 0, 2000, 1600],
[0, 0, 1000 * deviceScaleFactor, 1600 * deviceScaleFactor],
[0, 0, 1000 * deviceScaleFactor, 800 * deviceScaleFactor],
],
paintOrders: [0, 50],
},
@@ -778,14 +830,19 @@ describe("captureViewModel", () => {
if (method === "Page.getLayoutMetrics") {
return {
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
layoutViewport: { clientWidth: 2000, clientHeight: 1600 },
layoutViewport: {
clientWidth: 1000 * deviceScaleFactor,
clientHeight: 800 * deviceScaleFactor,
},
};
}
throw new Error(method);
}) 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).toEqual({
const canvas = nodes.find((node) => node.backendNodeId === 12);
expect(canvas?.tag).toBe("canvas");
expect(canvas?.rect).toEqual({
x: 0,
y: 0,
w: 1000,
@@ -961,7 +1018,7 @@ 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).toEqual({ x: 100, y: 300, w: 200, h: 40 });
expect(input?.rect).toBeNull();
expect(rootFrameId).toBe("main-frame");
expect(frameNodes?.get("child-frame")).toBe(subNodes);
expect(frameOwnerBackendNodeIds?.get("child-frame")).toBe(13);
@@ -1133,10 +1190,95 @@ 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).toEqual({ x: 15, y: 26, w: 100, h: 80 });
expect(nestedIframe?.rect).toBeNull();
expect(input?.ownerFrameBackendNodeId).toBe(23);
expect(input?.localRect).toEqual({ x: 1, y: 2, w: 40, h: 20 });
expect(input?.rect).toEqual({ x: 16, y: 28, w: 40, h: 20 });
expect(input?.rect).toBeNull();
});
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 () => {
@@ -0,0 +1,270 @@
import { describe, expect, it, vi } from "vitest";
import { type CdpRunner, cdpRunnerForTarget } from "../../shared";
import { captureViewModel } from "../capture";
interface Control {
id: number;
value: string;
type?: string;
}
function snapshot(groups: Control[][]) {
const strings: string[] = [];
const str = (value: string) => {
let index = strings.indexOf(value);
if (index < 0) index = strings.push(value) - 1;
return index;
};
const documents = groups.map((controls, groupIndex) => ({
frameId: `frame-${groupIndex}`,
nodes: {
parentIndex: [-1, 0, ...controls.map(() => 1)],
nodeName: [str("html"), str("body"), ...controls.map(() => str("input"))],
backendNodeId: [8000 + groupIndex * 2, 8001 + groupIndex * 2, ...controls.map((c) => c.id)],
attributes: [
[],
[],
...controls.map((c) => [
str("type"),
str(c.type ?? "text"),
str("placeholder"),
str("snapshot placeholder"),
]),
],
inputValue: {
index: controls.map((_, i) => i + 2),
value: controls.map((c) => str(c.value)),
},
contentDocumentIndex: { index: [] as number[], value: [] as number[] },
},
}));
const root = documents[0].nodes;
for (let i = 1; i < documents.length; i++) {
root.contentDocumentIndex.index.push(root.backendNodeId.length);
root.contentDocumentIndex.value.push(i);
root.parentIndex.push(1);
root.nodeName.push(str("iframe"));
root.backendNodeId.push(9000 + i);
root.attributes.push([]);
}
return { strings, documents };
}
function state(value: string) {
return {
value,
defaultValue: "",
placeholder: "live placeholder",
state: value ? "filled" : "empty",
sensitive: false,
};
}
function reply(entries: Array<[number, unknown]>) {
return {
result: {
deepSerializedValue: {
type: "array",
value: entries.map(([backendNodeId, fields]) => ({
type: "array",
value: [
{ type: "node", value: { backendNodeId } },
{ type: "string", value: JSON.stringify(fields) },
],
})),
},
},
};
}
function fakeCdp(groups: Control[][], evaluate: (params: Record<string, unknown>) => unknown) {
const snap = snapshot(groups);
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
if (method === "DOMSnapshot.captureSnapshot") return snap;
if (method === "Runtime.evaluate") return evaluate(params as Record<string, unknown>);
return {};
});
return { send, cdp: { send: send as CdpRunner["send"] } };
}
describe("form state identity", () => {
it("matches reordered controls by identity and preserves controls missing from the batch", async () => {
// The snapshot includes a shadow input. The runtime query skips it,
// encounters a new input, and sees the remaining inputs in a new order.
const { cdp } = fakeCdp(
[
[
{ id: 10, value: "" },
{ id: 11, value: "old email" },
{ id: 12, value: "old city" },
],
],
() =>
reply([
[99, state("new control")],
[12, state("上海")],
[11, state("alice@example.com")],
]),
);
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 10)).toMatchObject({
formValue: "",
formState: "empty",
formPlaceholder: "snapshot placeholder",
});
expect(captured.nodes.find((n) => n.backendNodeId === 11)).toMatchObject({
formValue: "alice@example.com",
});
expect(captured.nodes.find((n) => n.backendNodeId === 12)).toMatchObject({ formValue: "上海" });
expect(captured.nodes.some((n) => n.backendNodeId === 99)).toBe(false);
});
it("does not shift state between frames when one frame is absent from the runtime batch", async () => {
const { cdp } = fakeCdp(
[
[],
[{ id: 11, value: "inaccessible frame" }],
[{ id: 21, value: "old second" }],
[{ id: 31, value: "old third" }],
],
() =>
reply([
[31, state("third frame")],
[21, state("second frame")],
]),
);
const captured = await captureViewModel(cdp, 7);
const controls = [...captured.iframeNodes.values()].flat();
expect(controls.find((n) => n.backendNodeId === 11)?.formValue).toBe("inaccessible frame");
expect(controls.find((n) => n.backendNodeId === 21)?.formValue).toBe("second frame");
expect(controls.find((n) => n.backendNodeId === 31)?.formValue).toBe("third frame");
});
it("keeps identical backend ids in different OOPIF targets separate", async () => {
const sendToTarget = vi.fn(async (target, method) => {
if (method === "DOMSnapshot.captureSnapshot") return snapshot([[{ id: 11, value: "old" }]]);
if (method === "Runtime.evaluate") return reply([[11, state(target.sessionId)]]);
return {};
});
const send = vi.fn(async () => {
throw new Error("unexpected top-level target");
});
const cdp = { send, sendToTarget } as unknown as CdpRunner;
for (const sessionId of ["left-frame", "right-frame"]) {
const captured = await captureViewModel(cdpRunnerForTarget(cdp, { tabId: 7, sessionId }), 7);
expect(captured.nodes.find((n) => n.backendNodeId === 11)?.formValue).toBe(sessionId);
expect(sendToTarget).toHaveBeenCalledWith(
{ tabId: 7, sessionId },
"Runtime.releaseObjectGroup",
expect.anything(),
);
}
expect(send).not.toHaveBeenCalled();
});
it.each([
"unsupported",
"legacy",
"malformed",
"invalid-state",
])("preserves snapshot data when enrichment is %s", async (mode) => {
const { cdp, send } = fakeCdp([[{ id: 11, value: "snapshot value" }]], () => {
if (mode === "unsupported") throw new Error("serialization unsupported");
if (mode === "legacy") return { result: { value: { controls: [state("wrong value")] } } };
const result = reply([
[11, mode === "invalid-state" ? { value: "wrong value" } : state("wrong value")],
]);
if (mode === "malformed")
result.result.deepSerializedValue.value[0].value[1].value = "not json";
return result;
});
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 11)).toMatchObject({
formValue: "snapshot value",
formPlaceholder: "snapshot placeholder",
});
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
});
it("releases the batch's remote objects when capture is cancelled", async () => {
const controller = new AbortController();
const { cdp, send } = fakeCdp([[{ id: 11, value: "old" }]], () => {
controller.abort();
return reply([[11, state("new")]]);
});
await expect(captureViewModel(cdp, 7, { signal: controller.signal })).rejects.toMatchObject({
name: "AbortError",
});
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
});
it("skips entries without node identity while still applying valid entries", async () => {
const { cdp } = fakeCdp(
[
[
{ id: 11, value: "snapshot value" },
{ id: 12, value: "old value" },
],
],
() => {
const result = reply([
[11, state("wrong value")],
[12, state("live value")],
]);
const element = result.result.deepSerializedValue.value[0].value[0];
delete (element.value as { backendNodeId?: number }).backendNodeId;
return result;
},
);
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 11)?.formValue).toBe("snapshot value");
expect(captured.nodes.find((n) => n.backendNodeId === 12)?.formValue).toBe("live value");
});
it.each([
"password",
"text",
])("redacts a sensitive %s control after identity matching", async (type) => {
const { cdp } = fakeCdp([[{ id: 11, value: "secret", type }]], () =>
reply([[11, { ...state("secret"), sensitive: true }]]),
);
const captured = await captureViewModel(cdp, 7);
const control = captured.nodes.find((n) => n.backendNodeId === 11);
expect(control?.formState).toBe("filled");
expect(control?.formValue).toBeUndefined();
expect(control?.formDefaultValue).toBeUndefined();
expect(control?.attrs.value).toBeUndefined();
});
it("keeps one bounded batch without per-control CDP queries", async () => {
const controls = Array.from({ length: 251 }, (_, i) => ({ id: i + 1, value: "old" }));
const elements = controls.map((c) => ({
backendNodeId: c.id,
tagName: "INPUT",
type: "text",
value: `live ${c.id}`,
defaultValue: "",
placeholder: "",
}));
const { cdp, send } = fakeCdp([controls], (params) => {
const doc = {
querySelectorAll: (selector: string) =>
selector === "input,textarea,select" ? elements : [],
};
const entries = new Function("document", `return ${params.expression}`)(doc) as Array<
[{ backendNodeId: number }, string]
>;
expect(entries).toHaveLength(250);
return reply(entries.map(([element, json]) => [element.backendNodeId, JSON.parse(json)]));
});
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 250)?.formValue).toBe("live 250");
expect(captured.nodes.find((n) => n.backendNodeId === 251)?.formValue).toBe("old");
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(1);
expect(
send.mock.calls.filter(([, method]) => method === "Runtime.releaseObjectGroup"),
).toHaveLength(1);
expect(send).not.toHaveBeenCalledWith(7, "DOM.resolveNode", expect.anything());
expect(send).not.toHaveBeenCalledWith(7, "Runtime.callFunctionOn", expect.anything());
});
});
@@ -50,6 +50,105 @@ 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 () => {
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"]]),
rootFrameId: "main",
excludedBackendNodeIds: new Set(),
};
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"],
getFrameGraph: vi.fn(async () => ({
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "child",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 4 },
},
],
})),
};
await captureFrameData(cdp, 4, captured);
expect(captured.frameNodes?.get("child")?.[0]?.rect).toEqual({
x: 64,
y: 114,
w: 224,
h: 94,
});
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 () => {
const leftOwner = ownerNode(10, 50);
const rightOwner = ownerNode(20, 500);
@@ -133,6 +232,87 @@ 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 = {
@@ -0,0 +1,386 @@
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 });
});
});
+94 -38
View File
@@ -7,11 +7,19 @@
import type { Rect, Viewport } from "@browser-skill/vom";
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
import { childFrameProjection, type GeometryProjection, projectRectToViewport } from "../geometry";
import { type GeometryProjection, projectRectToViewport } from "../geometry";
import type { CdpRunner } from "../shared";
import { clearHover, ProbeBudget, waitForHover } from "./hover-perception";
const REQUESTED_STYLES = ["position", "pointer-events", "cursor", "visibility", "opacity"] as const;
const REQUESTED_STYLES = [
"position",
"pointer-events",
"cursor",
"visibility",
"opacity",
"overflow-x",
"overflow-y",
] as const;
const STYLE_COL = Object.fromEntries(
REQUESTED_STYLES.map((name, index) => [name, index]),
) as Record<(typeof REQUESTED_STYLES)[number], number>;
@@ -39,6 +47,10 @@ 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
@@ -190,9 +202,9 @@ interface CapturedFormState {
sensitive?: boolean;
}
interface CapturedFrameFormState {
controls: CapturedFormState[];
childFrames: CapturedFrameFormState[];
interface DeepSerializedValue {
type: string;
value?: unknown;
}
function formStateBatchExpression(maxControls: number): string {
@@ -215,43 +227,63 @@ function formStateBatchExpression(maxControls: number): string {
...(sensitive ? {} : { value: rawValue, defaultValue }),
};
};
const controls = [];
const collect = (doc) => {
const controls = [];
for (const el of Array.from(doc.querySelectorAll(controlSelector))) {
if (remaining <= 0) break;
controls.push(controlState(el));
// Deep serialization supplies the node's backend id. Keep the
// state as JSON so decoding needs no general-purpose V8 deserializer.
controls.push([el, JSON.stringify(controlState(el))]);
remaining -= 1;
}
const childFrames = [];
for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
if (remaining <= 0) break;
let childDoc = null;
try { childDoc = frame.contentDocument; } catch { childDoc = null; }
childFrames.push(childDoc && remaining > 0 ? collect(childDoc) : { controls: [], childFrames: [] });
if (childDoc) collect(childDoc);
}
return { controls, childFrames };
};
return collect(document);
collect(document);
return controls;
})()`;
}
function flattenFrameFormStates(
frame: CapturedFrameFormState | undefined,
out: CapturedFormState[][] = [],
): CapturedFormState[][] {
if (!frame) return out;
out.push(Array.isArray(frame.controls) ? frame.controls : []);
for (const child of Array.isArray(frame.childFrames) ? frame.childFrames : []) {
flattenFrameFormStates(child, out);
function formStatesByBackendId(result: RuntimeEvaluateReply): Map<number, CapturedFormState> {
const states = new Map<number, CapturedFormState>();
const serialized = result.result?.deepSerializedValue;
if (serialized?.type !== "array" || !Array.isArray(serialized.value)) return states;
for (const entry of serialized.value as DeepSerializedValue[]) {
if (entry?.type !== "array" || !Array.isArray(entry.value)) continue;
const [element, json] = entry.value as DeepSerializedValue[];
if (element?.type !== "node" || json?.type !== "string" || typeof json.value !== "string") {
continue;
}
const backendNodeId = (element.value as { backendNodeId?: number } | undefined)?.backendNodeId;
if (typeof backendNodeId !== "number" || !Number.isSafeInteger(backendNodeId)) continue;
try {
const state = JSON.parse(json.value) as CapturedFormState | null;
if (
!state ||
!["empty", "filled", "default"].includes(state.state ?? "") ||
typeof state.sensitive !== "boolean" ||
typeof state.placeholder !== "string" ||
(!state.sensitive &&
(typeof state.value !== "string" || typeof state.defaultValue !== "string"))
) {
continue;
}
states.set(backendNodeId, state);
} catch {
// A malformed entry must not overwrite the snapshot's own state.
}
}
return out;
return states;
}
function applyFormStates(nodes: CapturedNode[], states: CapturedFormState[]): void {
let index = 0;
function applyFormStates(nodes: CapturedNode[], states: Map<number, CapturedFormState>): void {
for (const node of nodes) {
if (!isFormControlTag(node.tag)) continue;
const state = states[index];
index += 1;
const state = states.get(node.backendNodeId);
if (!state) continue;
node.formState = state.state;
node.formPlaceholder = state.placeholder ?? "";
@@ -276,26 +308,37 @@ async function enrichFormControlStates(
nodes.some((node) => isFormControlTag(node.tag)),
);
if (!hasControls) return;
const objectGroup = `bsk-vom-forms-${crypto.randomUUID()}`;
try {
throwIfAborted(signal);
const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS),
returnByValue: true,
objectGroup,
serializationOptions: {
serialization: "deep",
maxDepth: 3,
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
},
});
throwIfAborted(signal);
const frameStates = flattenFrameFormStates(runtimeValue<CapturedFrameFormState>(result));
for (let i = 0; i < frameNodeGroups.length; i += 1) {
applyFormStates(frameNodeGroups[i], frameStates[i] ?? []);
// Backend ids are scoped to this capture's CDP target. OOPIF targets
// use their own captureViewModel call and never share this lookup.
const states = formStatesByBackendId(result);
for (const nodes of frameNodeGroups) {
applyFormStates(nodes, states);
}
} catch (error) {
if (isAbortError(error)) throw error;
// Best-effort enrichment. DOMSnapshot/AX data still carries the nodes.
} finally {
await cdp.send(tabId, "Runtime.releaseObjectGroup", { objectGroup }).catch(() => undefined);
}
}
interface RuntimeEvaluateReply {
result?: {
value?: unknown;
deepSerializedValue?: DeepSerializedValue;
};
}
@@ -818,6 +861,8 @@ 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 ?? []);
@@ -843,6 +888,7 @@ 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];
@@ -862,7 +908,11 @@ 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];
@@ -885,12 +935,19 @@ 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.
@@ -925,6 +982,9 @@ function parseDocumentNodes(
position,
pointerEvents,
cursor,
overflowX,
overflowY,
visuallySuppressed,
rendered,
textContent,
...(formValue !== undefined ? { formValue } : {}),
@@ -954,7 +1014,6 @@ function parseChildFrameDocuments(
strings: string[],
dpr: number,
parentDocIndex: number,
parentNodes: CapturedNode[],
parentContext: FrameContext,
visited = new Set<number>(),
): ParseFrameDocumentsResult {
@@ -969,19 +1028,17 @@ function parseChildFrameDocuments(
}
const parentBackendIds = parentDoc?.nodes?.backendNodeId ?? [];
const parentNodeByBackendId = new Map(parentNodes.map((node) => [node.backendNodeId, node]));
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;
const iframeNode = parentNodeByBackendId.get(iframeBackendId);
const projection =
parentContext.projection && iframeNode?.localRect
? childFrameProjection(parentContext.projection, iframeNode.localRect)
: null;
// 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 childContext: FrameContext = {
frameId: snapshotFrameId(childDoc, strings),
@@ -1005,7 +1062,6 @@ function parseChildFrameDocuments(
strings,
dpr,
childDocIndex,
parsed.nodes,
childContext,
nextVisited,
);
@@ -1083,7 +1139,7 @@ export async function captureViewModel(
const nodes = mainParsed.nodes;
const excludedBackendNodeIds = new Set(mainParsed.excludedBackendNodeIds);
const frameParsed = parseChildFrameDocuments(documents, strings, dpr, 0, nodes, topContext);
const frameParsed = parseChildFrameDocuments(documents, strings, dpr, 0, topContext);
const iframeNodes = frameParsed.iframeNodes;
for (const id of frameParsed.excludedBackendNodeIds) {
excludedBackendNodeIds.add(id);
+78 -29
View File
@@ -1,6 +1,5 @@
import type { CdpFrame, CdpFrameGraph } from "@/browser-driver/frame-graph";
import { resolveFrameProjection } from "../frame-geometry";
import { type GeometryProjection, projectRectToViewport } from "../geometry";
import { createFrameGeometryContext } from "../frame-geometry";
import { type CdpRunner, cdpRunnerForTarget, sendToCdpTarget } from "../shared";
import { type CapturedNode, type CapturedViewModel, captureViewModel } from "./capture";
import {
@@ -13,6 +12,8 @@ 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");
}
@@ -21,6 +22,22 @@ 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 {
@@ -31,21 +48,6 @@ async function discoverFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFra
}
}
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(
cdp: CdpRunner,
tabId: number,
@@ -63,16 +65,6 @@ async function captureMissingFrameDocuments(
const child = await captureViewModel(cdpRunnerForTarget(cdp, frame.target), tabId, {
signal,
});
let projection: GeometryProjection | null = null;
try {
projection = await resolveFrameProjection(cdp, graph, 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);
@@ -83,7 +75,7 @@ async function captureMissingFrameDocuments(
);
}
for (const [childFrameId, nodes] of childFrames) {
captured.frameNodes.set(childFrameId, transformFrameNodes(nodes, projection));
captured.frameNodes.set(childFrameId, nodes);
}
if (frame.ownerBackendNodeId !== undefined) {
captured.iframeNodes.set(
@@ -109,6 +101,60 @@ 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,
@@ -174,7 +220,10 @@ export async function captureFrameData<T extends FrameAxNode>(
): Promise<CapturedFrameDocument<T>[]> {
const graph = await discoverFrameGraph(cdp, tabId);
const frames = graph?.frames ?? [];
if (graph) await captureMissingFrameDocuments(cdp, tabId, graph, captured, signal);
if (graph) {
await captureMissingFrameDocuments(cdp, tabId, graph, captured, signal);
await normalizeFrameLocalRects(cdp, graph, captured, signal);
}
throwIfAborted(signal);
const batches = await captureAxTrees<T>(cdp, tabId, frames, captured, signal);
return buildFrameDocuments(graph, batches, captured);
@@ -0,0 +1,207 @@
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 } : {}),
};
}
@@ -0,0 +1,113 @@
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;
}
@@ -0,0 +1,8 @@
export { type ClusterRenderedSurfaceOptions, clusterRenderedSurfaces } from "./cluster";
export { discoverRenderedSurfaces } from "./discovery";
export { projectRenderedSurfaces } from "./project";
export type {
ClusteredRenderedSurfaces,
RenderedSurface,
RenderedSurfaceGroup,
} from "./types";
@@ -0,0 +1,108 @@
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 }
: {}),
}
: {}),
};
}
@@ -0,0 +1,25 @@
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;
}
+7
View File
@@ -23,14 +23,21 @@ export type ErrorCode =
export type RpcErrorReason =
| "agent_window_scope"
| "element_not_visible"
| "ref_capability_denied"
| "ref_not_found"
| "selector_not_found"
| "target_not_fillable"
| "fill_value_invalid"
| "fill_target_changed"
| "fill_focus_lost"
| "fill_value_mismatch"
| "fill_failed"
| "target_not_select"
| "option_not_found"
| "single_select_value_count"
| "tab_not_active"
| "restricted_tab_url"
| "cdp_extension_access_denied"
| "borrow_conflict"
| "screenshot_capture_failed"
| "file_input_probe_failed"
@@ -0,0 +1,110 @@
# 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.
@@ -0,0 +1,46 @@
<!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>
@@ -0,0 +1,28 @@
<!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>
@@ -0,0 +1,22 @@
<!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>
@@ -0,0 +1,118 @@
<!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>
@@ -0,0 +1,28 @@
<!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>
@@ -0,0 +1,68 @@
<!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>
@@ -0,0 +1,22 @@
<!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>
+14
View File
@@ -73,6 +73,16 @@ 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.
@@ -172,6 +182,10 @@ intended. Read `bsk upload --help` and `bsk download --help` for all flags and e
- Stale ref: observe again and retry the intended action once.
- Unknown tab or session: list current tabs/sessions; never guess identifiers.
- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful.
- Fill result unconfirmed (`fill_value_mismatch`): observe the field first; the page may have
formatted the value. Continue if the visible result satisfies the user's intent. Otherwise correct
the remaining difference; do not blindly repeat fill or immediately request human help. For other
fill errors, follow the returned hint and inspect current state before retrying.
- Unsupported command: continue with available capabilities; suggest updating only when the missing
command is necessary.
- Unrecoverable failure: report the blocker and stop the session in a finally-style path.
+131 -1
View File
@@ -40,13 +40,20 @@ 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";
pub const FILL_VALUE_INVALID: &str = "fill_value_invalid";
pub const FILL_TARGET_CHANGED: &str = "fill_target_changed";
pub const FILL_FOCUS_LOST: &str = "fill_focus_lost";
pub const FILL_VALUE_MISMATCH: &str = "fill_value_mismatch";
pub const FILL_FAILED: &str = "fill_failed";
pub const TARGET_NOT_SELECT: &str = "target_not_select";
pub const OPTION_NOT_FOUND: &str = "option_not_found";
pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count";
pub const TAB_NOT_ACTIVE: &str = "tab_not_active";
pub const CDP_EXTENSION_ACCESS_DENIED: &str = "cdp_extension_access_denied";
pub const BORROW_CONFLICT: &str = "borrow_conflict";
pub const SCREENSHOT_CAPTURE_FAILED: &str = "screenshot_capture_failed";
pub const FILE_INPUT_PROBE_FAILED: &str = "file_input_probe_failed";
@@ -206,6 +213,13 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
return base;
};
match (code, reason) {
(ErrorCode::CdpFailed, reason::CDP_EXTENSION_ACCESS_DENIED) => RenderInfo {
summary: "Chrome blocked CDP access to another extension's content in this tab",
hint: Some(
"a web page can contain a restricted extension frame; disable the conflicting extension and reload, or use `bsk navigate <url>` to leave this page; reconnecting BrowserSkill alone does not remove the restriction",
),
exit_code: base.exit_code,
},
(_, reason::TRANSFER_OUTCOME_UNKNOWN) => RenderInfo {
summary: "the file transfer outcome could not be confirmed",
hint: Some(
@@ -247,7 +261,49 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
(ErrorCode::InvalidParams, reason::TARGET_NOT_FILLABLE) => RenderInfo {
summary: "target element is not fillable",
hint: Some(
"choose an input, textarea, or contenteditable element from the latest snapshot",
"observe the page and choose an enabled, editable text input, textarea, or contenteditable; use the appropriate interaction for other control types",
),
exit_code: base.exit_code,
},
(ErrorCode::InvalidParams, reason::FILL_VALUE_INVALID) => RenderInfo {
summary: "the requested text does not fit the field constraints",
hint: Some(
"check the field's input type and maxlength; use a compatible value without silently truncating or changing the user's intended data",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_TARGET_CHANGED) => RenderInfo {
summary: "the fill target changed during the action",
hint: Some(
"observe the current page and field value; if the intended result is missing, use a fresh editable target before retrying",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_FOCUS_LOST) => RenderInfo {
summary: "the page moved focus away from the fill target",
hint: Some(
"observe the page for a dialog or rerender and resolve it before retrying; fill manages field focus and does not require bringing the browser to the foreground",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_VALUE_MISMATCH) => RenderInfo {
summary: "the fill result could not be confirmed",
hint: Some(
"observe the field before retrying; the page may have formatted the value. Continue if the visible result satisfies the user's intent; otherwise correct the remaining difference. Do not blindly repeat fill or immediately request human help",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_FAILED) => RenderInfo {
summary: "fill encountered a browser or page-script error",
hint: Some(
"observe the page before retrying because the field may already have changed; retry only if the intended result is missing and the target is still available",
),
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,
},
@@ -442,6 +498,56 @@ mod tests {
ErrorCode::NoBrowserConnected,
];
#[test]
fn fill_reasons_provide_recovery_without_changing_exit_codes() {
for (code, reason, hint_fragment) in [
(
ErrorCode::InvalidParams,
reason::TARGET_NOT_FILLABLE,
"enabled, editable",
),
(
ErrorCode::InvalidParams,
reason::FILL_VALUE_INVALID,
"maxlength",
),
(
ErrorCode::CdpFailed,
reason::FILL_TARGET_CHANGED,
"fresh editable target",
),
(
ErrorCode::CdpFailed,
reason::FILL_FOCUS_LOST,
"does not require bringing the browser",
),
(
ErrorCode::CdpFailed,
reason::FILL_VALUE_MISMATCH,
"Continue if the visible result satisfies",
),
(
ErrorCode::CdpFailed,
reason::FILL_FAILED,
"may already have changed",
),
] {
let data = serde_json::json!({ "reason": reason });
let info = info_for_error(code, Some(&data));
assert!(info.hint.unwrap().contains(hint_fragment), "{reason}");
assert_eq!(info.exit_code, info_for(code).exit_code);
}
let mismatch = serde_json::json!({ "reason": reason::FILL_VALUE_MISMATCH });
let info = info_for_error(ErrorCode::CdpFailed, Some(&mismatch));
assert_eq!(info.summary, "the fill result could not be confirmed");
assert!(info.hint.unwrap().contains("Do not blindly repeat fill"));
let unknown = serde_json::json!({ "reason": "future_fill_reason" });
assert_eq!(
info_for_error(ErrorCode::CdpFailed, Some(&unknown)),
info_for(ErrorCode::CdpFailed)
);
}
#[test]
fn element_not_visible_overrides_permission_denied_copy() {
let data = serde_json::json!({ "reason": reason::ELEMENT_NOT_VISIBLE });
@@ -454,6 +560,30 @@ mod tests {
assert!(!info.summary.contains("sandbox"));
}
#[test]
fn extension_access_denied_explains_the_frame_restriction_and_navigation_recovery() {
let data = serde_json::json!({ "reason": reason::CDP_EXTENSION_ACCESS_DENIED });
let info = info_for_error(ErrorCode::CdpFailed, Some(&data));
assert!(info.summary.contains("another extension"));
let hint = info.hint.unwrap();
assert!(hint.contains("restricted extension frame"));
assert!(hint.contains("bsk navigate <url>"));
assert!(hint.contains("disable the conflicting extension"));
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 });
+238 -76
View File
@@ -6,9 +6,7 @@ use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
use std::process::{Command, Stdio};
mod windows;
use anyhow::{Context, Result, bail};
use clap::Args;
@@ -363,7 +361,8 @@ fn install_candidate_with_client(
crate::daemon::start::run_stop().context("stop bsk daemon before update")?;
}
let action = replace_binary_for_update(&target, &binary, daemon_was_running)?;
let restart_args = daemon_was_running.then(StartArgs::default);
let action = replace_binary_for_update(&target, &binary, restart_args.as_ref())?;
if daemon_was_running && matches!(action, InstallAction::Replaced) {
crate::daemon::start::run_start(StartArgs::default())
@@ -394,8 +393,8 @@ pub(crate) fn download_candidate_binary(
/// Daemon-side install: download, verify, and replace the executable at
/// `target` (on Windows: stage the replacement next to it). Unlike the
/// CLI path this never stops or starts the daemon — the daemon drives
/// its own restart once the binary is replaced.
/// CLI path this never stops the daemon. On Windows the helper starts
/// its replacement after exit; elsewhere the daemon drives its own restart.
///
/// `target` must be captured *before* any replacement happens: on Linux
/// `std::env::current_exe` starts returning a ` (deleted)`-suffixed
@@ -403,10 +402,11 @@ pub(crate) fn download_candidate_binary(
pub(crate) fn self_install_candidate(
candidate: &UpdateCandidate,
target: &Path,
restart_args: &StartArgs,
) -> Result<InstallAction> {
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
let binary = download_candidate_binary(candidate, &client)?;
replace_binary_at_path(target, &binary)
replace_binary_for_update(target, &binary, Some(restart_args))
}
/// Outcome of one daemon auto-update step (see [`auto_update_step`]).
@@ -423,8 +423,8 @@ pub(crate) enum AutoUpdateOutcome {
/// The new binary replaced the old one; the daemon should now
/// restart into it.
Replaced { latest: String },
/// Windows staged the replacement next to the running binary; a
/// daemon/terminal restart finishes it.
/// Windows started a helper that will replace the executable and
/// restart the daemon once the current process exits.
Staged { latest: String },
}
@@ -668,6 +668,7 @@ fn render_report(format: Format, report: &UpdateReport) -> Result<()> {
}
if matches!(report.install_action, Some("staged")) {
println!("the detached update helper will apply the replacement after exit");
println!("if it fails, see the *.update-*.log file next to the executable");
}
}
Format::Json => {
@@ -688,22 +689,22 @@ pub fn extract_bsk_binary(archive_bytes: &[u8], kind: ArchiveKind) -> Result<Vec
}
pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> {
replace_binary_for_update(target, binary, false)
replace_binary_for_update(target, binary, None)
}
fn replace_binary_for_update(
target: &Path,
binary: &[u8],
restart_daemon: bool,
restart_args: Option<&StartArgs>,
) -> Result<InstallAction> {
#[cfg(windows)]
{
stage_windows_replacement(target, binary, restart_daemon)
stage_windows_replacement(target, binary, restart_args)
}
#[cfg(not(windows))]
{
let _ = restart_daemon;
let _ = restart_args;
replace_binary_atomically(target, binary)
}
}
@@ -749,34 +750,69 @@ fn replace_binary_atomically(target: &Path, binary: &[u8]) -> Result<InstallActi
fn stage_windows_replacement(
target: &Path,
binary: &[u8],
restart_daemon: bool,
restart_args: Option<&StartArgs>,
) -> Result<InstallAction> {
let paths = staged_replacement_paths(target, std::process::id())?;
std::fs::write(&paths.binary_path, binary)
.with_context(|| format!("write {}", paths.binary_path.display()))?;
std::fs::write(
&paths.script_path,
windows_replacement_script(target, &paths.binary_path, restart_daemon),
)
.with_context(|| format!("write {}", paths.script_path.display()))?;
// The helper owns replacement/restart after this process exits. Do not
// report Staged until it has actually begun executing the script.
let _child = spawn_windows_replacement(target, binary, restart_args, 120)?;
Ok(InstallAction::Staged)
}
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
let command = format!("\"{}\"", paths.script_path.display());
if let Err(err) = Command::new("cmd.exe")
.args(["/D", "/S", "/C"])
.arg(command)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)
.spawn()
{
#[cfg(windows)]
fn spawn_windows_replacement(
target: &Path,
binary: &[u8],
restart_args: Option<&StartArgs>,
attempts: u32,
) -> Result<windows::Helper> {
let paths = staged_replacement_paths(target, std::process::id())?;
let ready_path = paths.script_path.with_extension("ready");
let log_path = paths.script_path.with_extension("log");
let result = (|| {
// A previous failed attempt by this process must not acknowledge a
// new helper. All paths are private to this target and process id.
match std::fs::remove_file(&ready_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
std::fs::write(&paths.binary_path, binary)
.with_context(|| format!("write {}", paths.binary_path.display()))?;
std::fs::write(
&paths.script_path,
windows_replacement_script(restart_args, attempts),
)
.with_context(|| format!("write {}", paths.script_path.display()))?;
let mut child = windows::spawn(
&paths.script_path,
&paths.binary_path,
target,
&ready_path,
&log_path,
)
.context("launch detached Windows update helper")?;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
if std::fs::remove_file(&ready_path).is_ok() {
return Ok(child);
}
if let Some(status) = child.try_wait()? {
bail!("Windows update helper exited before becoming ready: {status}");
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
bail!("Windows update helper did not become ready within 5 seconds");
}
std::thread::sleep(Duration::from_millis(20));
}
})();
if result.is_err() {
let _ = std::fs::remove_file(&paths.binary_path);
let _ = std::fs::remove_file(&paths.script_path);
return Err(err).context("launch detached Windows update helper");
let _ = std::fs::remove_file(&ready_path);
}
Ok(InstallAction::Staged)
result.with_context(|| format!("Windows update helper failed; see {}", log_path.display()))
}
pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> {
@@ -793,25 +829,43 @@ pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplace
}
#[cfg(any(windows, test))]
fn windows_replacement_script(target: &Path, staged_binary: &Path, restart_daemon: bool) -> String {
let target = target.display().to_string().replace('%', "%%");
let staged_binary = staged_binary.display().to_string().replace('%', "%%");
let restart = if restart_daemon {
format!("start \"\" /B \"{target}\" daemon start >nul 2>nul\r\n")
} else {
String::new()
};
fn windows_replacement_script(restart_args: Option<&StartArgs>, attempts: u32) -> String {
// Only fixed switches and numeric values go into the ASCII script. Paths
// stay in Unicode environment variables, including %, ! and shell symbols.
let restart = restart_args.map_or_else(String::new, |args| {
format!(
"\"%BSK_UPDATE_TARGET%\" daemon start --port {} --session-idle {}ms --daemon-idle {}ms\r\nif errorlevel 1 goto failed_restart\r\n",
args.resolved_port(),
args.resolved_session_idle().as_millis(),
args.resolved_daemon_idle().as_millis(),
)
});
format!(
"@echo off\r\n\
setlocal\r\n\
setlocal DisableDelayedExpansion\r\n\
> \"%BSK_UPDATE_READY%\" echo ready\r\n\
if errorlevel 1 exit /b 1\r\n\
set /a attempts=0\r\n\
:retry\r\n\
move /Y \"{staged_binary}\" \"{target}\" >nul 2>nul\r\n\
if errorlevel 1 (\r\n\
ping -n 2 127.0.0.1 >nul\r\n\
goto retry\r\n\
)\r\n\
move /Y \"%BSK_UPDATE_SOURCE%\" \"%BSK_UPDATE_TARGET%\" >nul\r\n\
if not errorlevel 1 goto replaced\r\n\
set /a attempts+=1\r\n\
if %attempts% geq {attempts} goto failed_replace\r\n\
if not exist \"%BSK_UPDATE_SOURCE%\" goto failed_replace\r\n\
ping -n 2 127.0.0.1 >nul\r\n\
goto retry\r\n\
:failed_replace\r\n\
echo Failed to replace the executable after %attempts% attempts.\r\n\
goto failed\r\n\
:replaced\r\n\
{restart}\
del /F /Q \"%~f0\" >nul 2>nul\r\n"
del /F /Q \"%BSK_UPDATE_LOG%\" >nul 2>nul\r\n\
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 0)\r\n\
:failed_restart\r\n\
echo Replaced the executable but failed to restart the daemon.\r\n\
:failed\r\n\
del /F /Q \"%BSK_UPDATE_SOURCE%\" >nul 2>nul\r\n\
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 1)\r\n"
)
}
@@ -965,6 +1019,7 @@ mod tests {
assert_eq!(extracted, b"windows binary");
}
#[cfg(not(windows))]
#[test]
fn replaces_binary_at_path() {
let tmp = tempfile::TempDir::new().unwrap();
@@ -999,31 +1054,139 @@ mod tests {
}
#[test]
fn windows_replacement_script_retries_restarts_and_cleans_itself() {
let script = windows_replacement_script(
Path::new(r"C:\Program Files\bsk.exe"),
Path::new(r"C:\Program Files\bsk.exe.update-42"),
true,
fn windows_replacement_script_preserves_restart_config() {
let args = StartArgs {
port: Some(54321),
session_idle: Some(Duration::from_millis(1234)),
daemon_idle: Some(Duration::from_secs(75)),
..Default::default()
};
let script = windows_replacement_script(Some(&args), 120);
assert!(script.is_ascii());
assert!(
script
.contains("daemon start --port 54321 --session-idle 1234ms --daemon-idle 75000ms")
);
assert!(script.contains(":retry"));
assert!(script.contains("move /Y"));
assert!(script.contains("ping -n 2 127.0.0.1"));
assert!(!script.contains("timeout"));
assert!(script.contains(r#"start "" /B "C:\Program Files\bsk.exe" daemon start"#));
assert!(script.contains(r#"del /F /Q "%~f0""#));
assert!(!windows_replacement_script(None, 120).contains("daemon start"));
}
#[test]
fn windows_replacement_script_omits_restart_when_daemon_was_not_running() {
let script = windows_replacement_script(
Path::new(r"C:\bsk.exe"),
Path::new(r"C:\bsk.exe.update-42"),
false,
);
#[cfg(windows)]
fn wait_for_helper(mut child: windows::Helper) -> std::process::ExitStatus {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
if let Some(status) = child.try_wait().unwrap() {
return status;
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("Windows update helper did not exit");
}
std::thread::sleep(Duration::from_millis(20));
}
}
assert!(!script.contains("daemon start"));
assert!(script.contains(r#"del /F /Q "%~f0""#));
#[cfg(windows)]
#[test]
fn windows_helper_replaces_in_unicode_spaces_and_shell_symbol_paths() {
let tmp = tempfile::TempDir::new().unwrap();
for name in [
"ascii",
"with space",
"中文目录",
"literal %PATH% ! & (folder)",
] {
let dir = tmp.path().join(name);
std::fs::create_dir(&dir).unwrap();
let target = dir.join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
let child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
let status = wait_for_helper(child);
assert!(
status.success(),
"{name}: {:?}",
std::fs::read_to_string(paths.script_path.with_extension("log"))
);
assert_eq!(std::fs::read(&target).unwrap(), b"new binary", "{name}");
// Successful updates leave no helper, staged binary, ready file or log.
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "{name}");
}
}
#[cfg(windows)]
#[test]
fn windows_helper_waits_for_unlock_then_replaces() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
// Deny delete sharing, like a running Windows executable.
let lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(&target)
.unwrap();
let mut child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
std::thread::sleep(Duration::from_millis(150));
assert!(child.try_wait().unwrap().is_none());
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
drop(lock);
assert!(wait_for_helper(child).success());
assert_eq!(std::fs::read(&target).unwrap(), b"new binary");
}
#[cfg(windows)]
#[test]
fn windows_helper_bounds_retries_and_preserves_old_binary_on_failure() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
let _lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(&target)
.unwrap();
let child = spawn_windows_replacement(&target, b"new binary", None, 2).unwrap();
assert!(!wait_for_helper(child).success());
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
assert!(!paths.binary_path.exists());
assert!(!paths.script_path.exists());
let log_bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
let log = String::from_utf8_lossy(&log_bytes);
assert!(
log.contains("Failed to replace the executable after 2 attempts."),
"{log}"
);
}
#[cfg(windows)]
#[test]
fn windows_helper_reports_restart_failure_without_losing_replacement() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
// Not a valid PE executable: replacement succeeds but restart fails.
let child = spawn_windows_replacement(
&target,
b"invalid executable",
Some(&StartArgs::default()),
2,
)
.unwrap();
assert!(!wait_for_helper(child).success());
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
let bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
let log = String::from_utf8_lossy(&bytes);
assert!(
log.contains("Replaced the executable but failed to restart the daemon."),
"{log}"
);
assert_eq!(std::fs::read(&target).unwrap(), b"invalid executable");
assert!(!paths.script_path.exists());
assert!(!paths.binary_path.exists());
}
#[test]
@@ -1282,10 +1445,9 @@ mod tests {
}
#[test]
fn auto_update_step_staged_outcome_requests_no_restart() {
// The Windows shape: the replacement is staged next to the
// running binary, so the outcome must not ask for the immediate
// self-restart that `Replaced` triggers.
fn auto_update_step_reports_staged_handoff() {
// The helper, rather than this process, starts the new executable.
// The daemon must exit to let that staged handoff finish.
let candidate = test_candidate();
let outcome =
auto_update_step(Some(&candidate), true, 0, |_| Ok(InstallAction::Staged)).unwrap();
+203
View File
@@ -0,0 +1,203 @@
//! Launch the update helper with only its stdio handles inherited.
//!
//! std::process::Command on Windows also inherits other inheritable handles.
//! A daemon's listening socket must not survive in the helper: it would keep
//! the port occupied after the daemon exits and prevent its replacement starting.
use std::ffi::OsStr;
use std::fs::File;
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::os::windows::process::ExitStatusExt;
use std::path::Path;
use std::process::ExitStatus;
use windows_sys::Win32::Foundation::{
HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT,
};
use windows_sys::Win32::System::Threading::{
CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW,
DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess,
InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROCESS_INFORMATION,
STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute,
WaitForSingleObject,
};
pub(super) struct Helper(OwnedHandle);
impl Helper {
pub(super) fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
// SAFETY: the owned process handle remains valid for both calls.
match unsafe { WaitForSingleObject(self.0.as_raw_handle(), 0) } {
WAIT_OBJECT_0 => {
let mut code = 0;
if unsafe { GetExitCodeProcess(self.0.as_raw_handle(), &mut code) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(Some(ExitStatus::from_raw(code)))
}
WAIT_TIMEOUT => Ok(None),
_ => Err(io::Error::last_os_error()),
}
}
pub(super) fn kill(&mut self) -> io::Result<()> {
// SAFETY: this handle belongs to the helper we created.
if unsafe { TerminateProcess(self.0.as_raw_handle(), 1) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub(super) fn wait(&mut self) -> io::Result<()> {
// Only used after kill(); do not leave a live helper on startup failure.
if unsafe { WaitForSingleObject(self.0.as_raw_handle(), 5000) } != WAIT_OBJECT_0 {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"update helper did not exit",
));
}
Ok(())
}
}
// The opaque attribute list needs pointer-aligned storage and explicit teardown.
struct AttributeList(Vec<usize>);
impl Drop for AttributeList {
fn drop(&mut self) {
// SAFETY: this wrapper is constructed only after successful initialization.
unsafe { DeleteProcThreadAttributeList(self.0.as_mut_ptr().cast()) };
}
}
pub(super) fn spawn(
script: &Path,
source: &Path,
target: &Path,
ready: &Path,
log_path: &Path,
) -> io::Result<Helper> {
let root = std::env::var_os("SystemRoot")
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "SystemRoot is not set"))?;
let application = wide(Path::new(&root).join("System32/cmd.exe").as_os_str());
// /S /C strips one outer pair of quotes. Environment expansion keeps paths
// Unicode and avoids CRT escaping, batch-file encoding, and CALL expansion.
let mut command = wide(OsStr::new(
"cmd.exe /D /V:OFF /S /C \"\"%BSK_UPDATE_SCRIPT%\"\"",
));
let overrides = [
("BSK_UPDATE_SCRIPT", script.as_os_str()),
("BSK_UPDATE_SOURCE", source.as_os_str()),
("BSK_UPDATE_TARGET", target.as_os_str()),
("BSK_UPDATE_READY", ready.as_os_str()),
("BSK_UPDATE_LOG", log_path.as_os_str()),
];
let mut env: Vec<_> = std::env::vars_os()
.filter(|(key, _)| {
let key = key.to_string_lossy();
!overrides
.iter()
.any(|(name, _)| key.eq_ignore_ascii_case(name))
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMONIZED_ENV)
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMON_REPLACEMENT_WAIT_ENV)
})
.collect();
env.extend(
overrides
.into_iter()
.map(|(key, value)| (key.into(), value.to_owned())),
);
env.sort_by_key(|(key, _)| key.to_string_lossy().to_uppercase());
let mut environment = Vec::new();
for (key, value) in env {
environment.extend(key.encode_wide());
environment.push(b'=' as u16);
environment.extend(value.encode_wide());
environment.push(0);
}
environment.push(0);
let input = File::open("NUL")?;
let log = File::create(log_path)?;
let handles: [HANDLE; 2] = [input.as_raw_handle(), log.as_raw_handle()];
for &handle in &handles {
// SAFETY: these are dedicated helper stdio handles, held until spawn
// finishes. The explicit handle list excludes every other parent handle.
if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } == 0 {
return Err(io::Error::last_os_error());
}
}
let mut size = 0;
// SAFETY: the first call queries the allocation size, as required by Win32.
unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut size) };
if size == 0 {
return Err(io::Error::last_os_error());
}
let mut storage = vec![0usize; size.div_ceil(std::mem::size_of::<usize>())];
// SAFETY: storage has the requested size and pointer alignment.
if unsafe { InitializeProcThreadAttributeList(storage.as_mut_ptr().cast(), 1, 0, &mut size) }
== 0
{
return Err(io::Error::last_os_error());
}
let mut attributes = AttributeList(storage);
// SAFETY: the handle array and attribute storage outlive CreateProcessW.
if unsafe {
UpdateProcThreadAttribute(
attributes.0.as_mut_ptr().cast(),
0,
PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize,
handles.as_ptr().cast(),
std::mem::size_of_val(&handles),
std::ptr::null_mut(),
std::ptr::null(),
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: these Win32 structs permit zero initialization; required fields
// are populated below before the process is created.
let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
startup.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput = handles[0];
startup.StartupInfo.hStdOutput = handles[1];
startup.StartupInfo.hStdError = handles[1];
startup.lpAttributeList = attributes.0.as_mut_ptr().cast();
let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
// SAFETY: strings are NUL-terminated UTF-16, the environment is double-NUL
// terminated, and all buffers/handles remain alive for the duration of spawn.
if unsafe {
CreateProcessW(
application.as_ptr(),
command.as_mut_ptr(),
std::ptr::null(),
std::ptr::null(),
1,
CREATE_NO_WINDOW
| CREATE_NEW_PROCESS_GROUP
| CREATE_UNICODE_ENVIRONMENT
| EXTENDED_STARTUPINFO_PRESENT,
environment.as_ptr().cast(),
std::ptr::null(),
&startup.StartupInfo,
&mut process,
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: successful CreateProcessW transfers these two handles to us.
let _thread = unsafe { OwnedHandle::from_raw_handle(process.hThread) };
Ok(Helper(unsafe {
OwnedHandle::from_raw_handle(process.hProcess)
}))
}
fn wide(value: &OsStr) -> Vec<u16> {
value.encode_wide().chain(Some(0)).collect()
}
+134
View File
@@ -1399,6 +1399,28 @@ mod windows {
connected = pipe.connect() => {
match connected {
Ok(()) => {
// Keep the connected instance alive until its replacement
// exists. Otherwise a fast handler can close the last
// instance and make new clients fail with NotFound.
loop {
match ServerOptions::new()
.access_inbound(true)
.access_outbound(true)
.create(&listener.pipe_name)
{
Ok(next) => {
listener.first = Some(next);
break;
}
Err(err) => {
warn!(?err, "create next named-pipe instance failed");
tokio::select! {
_ = &mut shutdown => return,
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
}
}
}
}
on_open();
let handler = handler.clone();
let on_act = on_activity.clone();
@@ -1467,6 +1489,118 @@ mod windows {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::time::Duration;
use tokio::net::windows::named_pipe::ClientOptions;
use tokio::sync::oneshot;
fn isolated_listener() -> NamedPipeListener {
let pipe_name = format!(r"\\.\pipe\bsk-test-{}", uuid::Uuid::new_v4());
let first = ServerOptions::new()
.first_pipe_instance(true)
.create(&pipe_name)
.unwrap();
NamedPipeListener {
pipe_name,
first: Some(first),
}
}
#[tokio::test]
async fn next_instance_exists_before_connection_is_handed_off() {
let listener = isolated_listener();
let name = listener.pipe_name.clone();
let probe_name = name.clone();
let (probe_tx, probe_rx) = oneshot::channel();
let probe_tx = Mutex::new(Some(probe_tx));
let (stop_tx, stop_rx) = oneshot::channel();
let server = tokio::spawn(serve(
listener,
crate::daemon::ipc::default_ping_handler(),
move || {
if let Some(tx) = probe_tx.lock().unwrap().take() {
// This callback runs before the connection task can finish.
// A second client must already have an instance to open.
let result = ClientOptions::new().open(&probe_name).map(drop);
let _ = tx.send(result);
}
},
|| {},
|| {},
async {
let _ = stop_rx.await;
},
));
let client = ClientOptions::new().open(&name).unwrap();
let result = tokio::time::timeout(Duration::from_secs(5), probe_rx).await;
drop(client);
let _ = stop_tx.send(());
server.await.unwrap();
result
.expect("accept callback ran")
.unwrap()
.expect("next pipe instance is ready");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rapid_disconnects_and_concurrent_rpc_connections() {
let listener = isolated_listener();
let name = listener.pipe_name.clone();
let (stop_tx, stop_rx) = oneshot::channel();
let server = tokio::spawn(serve(
listener,
crate::daemon::ipc::default_ping_handler(),
|| {},
|| {},
|| {},
async {
let _ = stop_rx.await;
},
));
let exercise = async {
// Include clients that close without sending any request.
for _ in 0..64 {
drop(
crate::ipc_client::Client::connect_path(name.clone().into())
.await
.unwrap(),
);
}
let mut clients = tokio::task::JoinSet::new();
for _ in 0..8 {
let name = name.clone();
clients.spawn(async move {
for _ in 0..32 {
let mut client =
crate::ipc_client::Client::connect_path(name.clone().into())
.await
.unwrap();
let reply: bsk_protocol::PingResult = client
.call(
bsk_protocol::Method::SystemPing,
&serde_json::json!({}),
Duration::from_secs(5),
)
.await
.unwrap()
.unwrap();
assert!(reply.pong);
}
});
}
while let Some(result) = clients.join_next().await {
result.unwrap();
}
};
let result = tokio::time::timeout(Duration::from_secs(30), exercise).await;
let _ = stop_tx.send(());
server.await.unwrap();
result.expect("connection exercise completed");
}
}
}
#[cfg(windows)]
+44 -4
View File
@@ -90,17 +90,21 @@ pub fn pid_alive(pid: u32) -> bool {
#[cfg(windows)]
{
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject,
};
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid);
if handle.is_null() {
return false;
}
// An exited process can still be opened while another handle
// keeps its kernel object alive. Poll its termination signal;
// unlike checking STILL_ACTIVE, this also handles exit code 259.
let alive = WaitForSingleObject(handle, 0) == WAIT_TIMEOUT;
CloseHandle(handle);
true
alive
}
}
@@ -110,3 +114,39 @@ pub fn pid_alive(pid: u32) -> bool {
false
}
}
#[cfg(all(test, windows))]
mod tests {
use super::pid_alive;
use std::os::windows::process::CommandExt;
use std::process::Command;
#[test]
fn current_process_is_alive() {
assert!(pid_alive(std::process::id()));
}
#[test]
fn invalid_process_is_not_alive() {
assert!(!pid_alive(0));
assert!(!pid_alive(u32::MAX));
}
#[test]
fn exited_process_with_retained_handle_is_not_alive() {
for exit_code in [0, 259] {
let mut child = Command::new("cmd.exe")
.args(["/D", "/C", &format!("exit {exit_code}")])
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.spawn()
.expect("spawn short-lived process");
assert_eq!(child.wait().unwrap().code(), Some(exit_code));
// Keep Child (and its process handle) alive during the probe.
assert!(
!pid_alive(child.id()),
"exited process with code {exit_code}"
);
drop(child);
}
}
}
+14 -9
View File
@@ -232,8 +232,8 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state));
// Fired by the update check task after a successful auto-update:
// the replacement daemon has already been spawned, so this
// process should shut down and let it take over.
// the replacement daemon (or Windows update helper) is ready, so
// this process should shut down and let it take over.
let restart_notify = Arc::new(tokio::sync::Notify::new());
let update_check_task =
spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify));
@@ -539,8 +539,8 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task:
/// task spawns the replacement daemon (see
/// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process
/// shuts down and the new version takes over; on Windows the
/// replacement can only be staged, so it just logs that a restart is
/// needed.
/// helper waits for this process to exit, replaces the binary, and starts
/// the new daemon with the same configuration.
pub(crate) fn spawn_update_check_task(
state: Arc<DaemonState>,
restart: Arc<tokio::sync::Notify>,
@@ -605,7 +605,11 @@ pub(crate) fn spawn_update_check_task(
|candidate| {
let target =
exe_path.as_deref().context("current executable unknown")?;
update::self_install_candidate(candidate, target)
update::self_install_candidate(
candidate,
target,
&restart_start_args(&state.config),
)
},
)
})
@@ -635,10 +639,11 @@ pub(crate) fn spawn_update_check_task(
sessions,
"auto-update postponed: agent session(s) active; will retry on the next tick"
),
update::AutoUpdateOutcome::Staged { latest } => warn!(
%latest,
"auto-update staged the new binary but cannot replace the running daemon in place; restart the daemon (or terminal) to finish the upgrade"
),
update::AutoUpdateOutcome::Staged { latest } => {
info!(%latest, "update helper ready; exiting so it can replace and restart the daemon");
restart.notify_one();
return;
}
update::AutoUpdateOutcome::Replaced { latest } => {
info!(
current = env!("CARGO_PKG_VERSION"),
+343
View File
@@ -0,0 +1,343 @@
//! Exercise self-update with a real executable and a local release server.
#![cfg(windows)]
use std::fs;
use std::io::{Cursor, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use bsk::daemon::info::DaemonInfo;
use sha2::{Digest, Sha256};
const MARKER: &[u8] = b"windows-update-regression-fixture";
struct ReleaseServer {
url: String,
requests: Arc<AtomicUsize>,
stop: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
impl ReleaseServer {
fn new(binary: &[u8]) -> Self {
let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
archive
.start_file(
"bsk.exe",
zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored),
)
.unwrap();
archive.write_all(binary).unwrap();
let archive = archive.finish().unwrap().into_inner();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let manifest = serde_json::to_vec(&serde_json::json!({
"version": "999.0.0",
"assets": {"windows-x64": {
"url": format!("{url}/bsk.zip"),
"sha256": Sha256::digest(&archive).iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
}},
}))
.unwrap();
let stop = Arc::new(AtomicBool::new(false));
let worker_stop = Arc::clone(&stop);
let requests = Arc::new(AtomicUsize::new(0));
let worker_requests = Arc::clone(&requests);
let worker = thread::spawn(move || {
while !worker_stop.load(Ordering::SeqCst) {
let (mut stream, _) = match listener.accept() {
Ok(connection) => connection,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
continue;
}
Err(err) => panic!("accept release request: {err}"),
};
// Windows accept() inherits the listener's nonblocking mode.
// Keep accept polling for shutdown, but read/write each request
// in blocking mode with the bounded timeouts below.
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut request = Vec::new();
let mut chunk = [0; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let len = stream.read(&mut chunk).unwrap();
if len == 0 {
break;
}
request.extend_from_slice(&chunk[..len]);
}
let body = if request.starts_with(b"GET /bsk.zip ") {
worker_requests.fetch_add(1, Ordering::SeqCst);
&archive
} else {
&manifest
};
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.unwrap();
stream.write_all(body).unwrap();
}
});
Self {
url: format!("{url}/version.json"),
requests,
stop,
worker: Some(worker),
}
}
}
impl Drop for ReleaseServer {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
let _ = self.worker.take().unwrap().join();
}
}
#[test]
fn release_server_waits_for_delayed_and_fragmented_request_headers() {
let server = ReleaseServer::new(b"test binary");
let address = server
.url
.strip_prefix("http://")
.unwrap()
.strip_suffix("/version.json")
.unwrap();
let mut stream = TcpStream::connect(address).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
// Give accept() time to run before any bytes arrive, then split the
// headers across writes. Both reads must wait rather than return WouldBlock.
thread::sleep(Duration::from_millis(100));
stream.write_all(b"GET /version.json HTTP/1.1\r\n").unwrap();
thread::sleep(Duration::from_millis(100));
stream.write_all(b"Host: localhost\r\n\r\n").unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "{response}");
let (_, body) = response.split_once("\r\n\r\n").unwrap();
let manifest: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(manifest["version"], "999.0.0");
}
struct Fixture {
_tmp: tempfile::TempDir,
exe: PathBuf,
home: PathBuf,
binary: Vec<u8>,
server: ReleaseServer,
daemon: Option<Child>,
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("中文 space %PATH% ! & (update)");
fs::create_dir(&dir).unwrap();
let exe = dir.join("bsk.exe");
let home = tmp.path().join("home");
fs::create_dir(&home).unwrap();
fs::copy(env!("CARGO_BIN_EXE_bsk"), &exe).unwrap();
// A PE overlay distinguishes the replacement without requiring a
// second build or changing the executable's behavior/version.
let mut binary = fs::read(&exe).unwrap();
binary.extend_from_slice(MARKER);
let server = ReleaseServer::new(&binary);
Self {
_tmp: tmp,
exe,
home,
binary,
server,
daemon: None,
}
}
fn command(&self) -> Command {
let mut cmd = Command::new(&self.exe);
cmd.env("BSK_HOME", &self.home)
.env("BSK_UPDATE_MANIFEST_URL", &self.server.url)
.env("BSK_AUTO_UPDATE", "off")
.env("RUST_LOG", "info")
.env("NO_PROXY", "127.0.0.1,localhost")
.env_remove("BSK_DAEMONIZED")
.env_remove("BSK_DAEMON_REPLACES_PID")
.stdin(Stdio::null())
.creation_flags(0x0800_0000);
cmd
}
fn wait_for(&self, description: &str, mut check: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if check() {
return;
}
thread::sleep(Duration::from_millis(50));
}
let diagnostics: Vec<_> = fs::read_dir(self.exe.parent().unwrap())
.unwrap()
.flatten()
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "log"))
.map(|entry| {
(
entry.path(),
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
.into_owned(),
)
})
.collect();
let home_logs: Vec<_> = fs::read_dir(&self.home)
.unwrap()
.flatten()
.filter(|entry| entry.file_name().to_string_lossy().contains("log"))
.map(|entry| {
(
entry.path(),
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
.into_owned(),
)
})
.collect();
panic!(
"timed out waiting for {description}; helper logs: {diagnostics:?}; daemon logs: {home_logs:?}"
);
}
fn info(&self) -> Option<DaemonInfo> {
serde_json::from_slice(&fs::read(self.home.join("daemon.json")).ok()?).ok()
}
fn updated(&self) -> bool {
// A locked executable may briefly reject reads during replacement.
fs::read(&self.exe).is_ok_and(|binary| binary == self.binary)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
// Stop only the daemon belonging to this isolated BSK_HOME. Also reap
// the foreground process on assertion failures to avoid leaking locks.
let _ = self.command().args(["daemon", "stop"]).output();
if let Some(child) = self.daemon.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
#[test]
fn manual_update_replaces_the_running_executable_after_cli_exit() {
let fixture = Fixture::new();
let out = fixture
.command()
.args(["--json", "update", "--yes"])
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(report["status"], "staged");
fixture.wait_for("CLI replacement and helper cleanup", || {
fixture.updated() && fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
});
assert!(
fixture.info().is_none(),
"an update must not start a previously absent daemon"
);
assert_eq!(fixture.server.requests.load(Ordering::SeqCst), 1);
}
#[test]
fn automatic_update_exits_old_daemon_and_restarts_on_the_same_port() {
let mut fixture = Fixture::new();
let port = unused_port();
let log = fs::File::create(fixture.home.join("foreground.log")).unwrap();
let child = fixture
.command()
.env("BSK_AUTO_UPDATE", "on")
// Exercise the environment inherited by a normally detached daemon.
.env("BSK_DAEMONIZED", "1")
.args([
"daemon",
"start",
"--port",
&port.to_string(),
"--session-idle",
"1234ms",
"--daemon-idle",
"30s",
])
.stdout(Stdio::from(log.try_clone().unwrap()))
.stderr(Stdio::from(log))
.spawn()
.unwrap();
let old_pid = child.id();
fixture.daemon = Some(child);
fixture.wait_for("replacement daemon", || {
fixture.updated()
&& fixture
.info()
.is_some_and(|info| info.pid != old_pid && info.ws_port == port)
});
assert!(
fixture
.daemon
.as_mut()
.unwrap()
.try_wait()
.unwrap()
.is_some(),
"old daemon must exit"
);
fixture.wait_for("helper cleanup", || {
fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
});
let status = fixture
.command()
.args(["--json", "status"])
.output()
.unwrap();
assert!(
status.status.success(),
"{}",
String::from_utf8_lossy(&status.stderr)
);
assert_eq!(
fixture.server.requests.load(Ordering::SeqCst),
1,
"the replacement must not stage another update immediately"
);
}
fn unused_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
+27 -6
View File
@@ -112,7 +112,7 @@ shell's theme cannot bleed back in.
- **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 (refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on
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
@@ -129,11 +129,32 @@ shell's theme cannot bleed back in.
- **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.
- **Wire**: the host serves `GET /bsk-observation/state`, `GET /bsk-observation/events` (SSE),
`POST /bsk-observation/interrupt`, and `GET /bsk-observation/thumbnail/<attachmentId>` over
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.
- **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
@@ -97,6 +97,10 @@ Continue from returned sequence cursors instead of rereading the same buffer.
- Unknown tab: list tabs instead of guessing.
- Unknown session: list owned sessions or start one; never try foreign ids.
- Timeout: inspect current state before deciding whether one longer purposeful wait is useful.
- Fill result unconfirmed: observe the field first; the page may have formatted the value. Continue
if the visible result satisfies the user's intent. Otherwise correct the remaining difference;
do not blindly repeat fill or immediately request human help. For other fill errors, follow the
returned hint and inspect current state before retrying.
- Unrecoverable failure: report the blocker and stop the owned session.
Arbitrary page-script evaluation and interaction recording are intentionally unsupported. Do not
@@ -142,6 +142,8 @@
right: 6px;
width: 16px;
height: 16px;
padding: 0;
cursor: pointer;
border-radius: 50%;
display: inline-flex;
align-items: center;
@@ -48,7 +48,13 @@ import { createPortal } from "react-dom";
import type { SessionObservation } from "../observation";
import css from "./ObservationOverlay.module.css";
import type { ObservationClientStore } from "./observation-store";
import { focusOf, statusOf, useObservationView, usePip } from "./observation-view";
import {
focusOf,
statusOf,
useObservationView,
usePip,
useThumbnailObservation,
} from "./observation-view";
import { getSidebarMode, subscribeSidebarMode } from "./sidebar-mode";
// The pure view helpers live in observation-view (shared with the sidebar
@@ -341,6 +347,7 @@ export function OverlayBody(props: {
onHeaderPointerDown,
} = props;
const [interrupting, setInterrupting] = useState(false);
const viewRef = useThumbnailObservation(store, sessions.length > 0);
const thumbId = focus?.thumbnailAttachmentId;
useEffect(() => {
@@ -373,7 +380,12 @@ export function OverlayBody(props: {
const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle";
return (
<div className={cn(css.body, "bsk-obs")} data-state={state} data-in-pip={inPip || undefined}>
<div
ref={viewRef}
className={cn(css.body, "bsk-obs")}
data-state={state}
data-in-pip={inPip || undefined}
>
<div
className={css.header}
data-testid="obs-header"
@@ -421,9 +433,15 @@ export function OverlayBody(props: {
</div>
)}
{thumb?.status === "error" ? (
<span className={css.badge} aria-label="thumbnail failed">
<button
type="button"
className={css.badge}
aria-label="Retry thumbnail"
title="Frame unavailable — retry"
onClick={() => store.retryThumbnail(thumbId)}
>
<IconWarn size={12} />
</span>
</button>
) : null}
</div>
{sessions.length >= 2 ? (
@@ -1,10 +1,9 @@
/**
* Client-side data layer for the observation overlay: initial state fetch,
* SSE increment stream (with a state refetch on every (re)open — events that
* fired while the stream was down are otherwise lost forever), on-demand
* thumbnail blob loading through the session-authorized readAttachment path,
* Client-side data layer for the observation overlay: an ordered SSE snapshot
* and increment stream (each reconnect starts with a fresh snapshot), on-demand
* thumbnail blob loading through the host's ephemeral-frame route,
* and the interrupt call. The last ready blob per session is held until its
* replacement decodes so the overlay can swap frames in place. All I/O is
* replacement loads so the overlay can swap frames in place. All I/O is
* injected so tests never touch a network.
*/
@@ -12,8 +11,6 @@ import type { ObservationEvent, SessionObservation } from "../observation";
export interface EventSourceLike {
onmessage: ((event: { data: string }) => void) | null;
/** Fires on the initial connect AND on every automatic reconnect. */
onopen?: (() => void) | null;
close(): void;
}
@@ -50,10 +47,10 @@ export interface OverlaySnapshot {
readonly available: boolean;
}
const STATE_URL = "/bsk-observation/state";
const EVENTS_URL = "/bsk-observation/events";
const INTERRUPT_URL = "/bsk-observation/interrupt";
const STOP_URL = "/bsk-observation/stop";
const THUMBNAIL_RETRY_DELAYS_MS = [1000, 3000];
function revoke(url: string | undefined): void {
if (url !== undefined && typeof URL.revokeObjectURL === "function") {
@@ -66,8 +63,10 @@ export class ObservationClientStore {
private thumbs = new Map<string, ThumbnailState>();
/** sessionId → the attachment id currently advertised for it. */
private readonly thumbBySession = new Map<string, string>();
/** sessionId → last successfully decoded frame (held across the next load). */
/** sessionId → last successfully loaded frame (held across the next load). */
private readonly lastReady = new Map<string, { attachmentId: string; url: string }>();
private readonly retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly retryCounts = new Map<string, number>();
private listeners = new Set<() => void>();
private events: EventSourceLike | undefined;
private snapshot: OverlaySnapshot = {
@@ -81,6 +80,7 @@ export class ObservationClientStore {
private started = false;
/** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */
private consumers = 0;
private thumbnailConsumers = 0;
constructor(private readonly deps: ObservationClientDeps) {}
@@ -133,37 +133,16 @@ export class ObservationClientStore {
return undefined;
}
/** (Re)pull the full state: initial load and every SSE (re)open. */
private refreshState(): void {
void this.deps
.fetchFn(STATE_URL)
.then(async (res) => {
if (!res.ok) return;
const body = (await res.json()) as {
sessions?: SessionObservation[];
available?: boolean;
};
const sessions = body.sessions ?? [];
this.sessions = new Map(sessions.map((s) => [s.sessionId, s]));
// Prune thumbnails of sessions that vanished while we were away.
const alive = new Set(sessions.map((s) => s.sessionId));
for (const sessionId of [...this.thumbBySession.keys()]) {
if (!alive.has(sessionId)) this.dropThumb(sessionId);
}
for (const s of sessions) this.trackThumb(s.sessionId, s.thumbnailAttachmentId);
if (typeof body.available === "boolean") this.available = body.available;
this.publish();
})
.catch(() => {});
}
/** Initial fetch + SSE subscription. Idempotent. */
start(): void {
if (this.started) return;
this.started = true;
this.refreshState();
const events = this.deps.eventSourceFactory(EVENTS_URL);
private connectEvents(): void {
const previous = this.events;
this.events = undefined;
previous?.close();
const events = this.deps.eventSourceFactory(
`${EVENTS_URL}?thumbnails=${this.thumbnailConsumers > 0 ? "1" : "0"}`,
);
this.events = events;
events.onmessage = (message) => {
if (!this.started || this.events !== events) return;
let event: ObservationEvent;
try {
event = JSON.parse(message.data) as ObservationEvent;
@@ -172,23 +151,24 @@ export class ObservationClientStore {
}
this.apply(event);
};
events.onopen = () => {
// Reconnects lose every event fired during the outage — resync.
if (this.events === events) this.refreshState();
};
this.events = events;
}
/** Every initial connection and reconnect starts with the server's snapshot. */
start(): void {
if (this.started) return;
this.started = true;
this.connectEvents();
this.publish();
}
stop(): void {
this.events?.close();
const previous = this.events;
this.events = undefined;
this.started = false;
for (const thumb of this.thumbs.values()) revoke(thumb.url);
this.thumbs.clear();
this.thumbBySession.clear();
this.lastReady.clear();
previous?.close();
this.clearThumbnails();
this.sessions.clear();
this.available = true;
this.publish();
}
@@ -209,6 +189,33 @@ export class ObservationClientStore {
if (this.consumers === 0) this.stop();
}
/** Only visible image surfaces request screenshots; metadata watchers do not. */
watchThumbnails(): () => void {
this.thumbnailConsumers += 1;
if (this.thumbnailConsumers === 1 && this.started) this.connectEvents();
let released = false;
return () => {
if (released) return;
released = true;
this.thumbnailConsumers -= 1;
if (this.thumbnailConsumers === 0 && this.started) this.connectEvents();
};
}
private forgetThumbnail(attachmentId: string): void {
clearTimeout(this.retryTimers.get(attachmentId));
this.retryTimers.delete(attachmentId);
this.retryCounts.delete(attachmentId);
revoke(this.thumbs.get(attachmentId)?.url);
this.thumbs.delete(attachmentId);
}
private clearThumbnails(): void {
for (const attachmentId of this.thumbs.keys()) this.forgetThumbnail(attachmentId);
this.thumbBySession.clear();
this.lastReady.clear();
}
/** Forget one session's tracked + held frames, revoking their blob URLs. */
private dropThumb(sessionId: string): void {
const attachmentId = this.thumbBySession.get(sessionId);
@@ -216,39 +223,45 @@ export class ObservationClientStore {
this.thumbBySession.delete(sessionId);
this.lastReady.delete(sessionId);
if (attachmentId !== undefined) {
revoke(this.thumbs.get(attachmentId)?.url);
this.thumbs.delete(attachmentId);
this.forgetThumbnail(attachmentId);
}
if (held !== undefined && held.attachmentId !== attachmentId) {
revoke(held.url);
this.thumbs.delete(held.attachmentId);
this.forgetThumbnail(held.attachmentId);
}
}
/**
* Track the frame a session currently advertises. The last *ready* blob is
* kept until the replacement decodes — dropping it on the upsert is what
* kept until the replacement loads — dropping it on the upsert is what
* made the overlay flash a placeholder between breaths.
*/
private trackThumb(sessionId: string, attachmentId: string | undefined): void {
if (attachmentId === undefined) {
this.dropThumb(sessionId);
return;
}
const previous = this.thumbBySession.get(sessionId);
if (previous === attachmentId) return;
if (attachmentId !== undefined) this.thumbBySession.set(sessionId, attachmentId);
else this.thumbBySession.delete(sessionId);
this.thumbBySession.set(sessionId, attachmentId);
const heldId = this.lastReady.get(sessionId)?.attachmentId;
if (previous !== undefined && previous !== heldId) {
revoke(this.thumbs.get(previous)?.url);
this.thumbs.delete(previous);
this.forgetThumbnail(previous);
}
}
private apply(event: ObservationEvent): void {
if (event.type === "reset") {
if (event.type === "snapshot") {
this.sessions = new Map(event.sessions.map((session) => [session.sessionId, session]));
for (const sessionId of this.thumbBySession.keys()) {
if (!this.sessions.has(sessionId)) this.dropThumb(sessionId);
}
for (const session of event.sessions) {
this.trackThumb(session.sessionId, session.thumbnailAttachmentId);
}
this.available = event.available;
} else if (event.type === "reset") {
this.sessions.clear();
for (const thumb of this.thumbs.values()) revoke(thumb.url);
this.thumbs.clear();
this.thumbBySession.clear();
this.lastReady.clear();
this.clearThumbnails();
} else if (event.type === "remove" && event.session !== undefined) {
this.sessions.delete(event.session.sessionId);
this.dropThumb(event.session.sessionId);
@@ -259,44 +272,77 @@ export class ObservationClientStore {
this.available = event.available;
}
this.publish();
if (event.type === "snapshot") {
for (const session of event.sessions) this.retryThumbnail(session.thumbnailAttachmentId);
}
}
/**
* Ensure a thumbnail load is in flight for one attachment reference. New
* frames replace the old URL only after they decode; failures keep the last
* frames replace the old URL only after they load; failures keep the last
* good frame (the caller renders `status: 'error'` as a small badge over
* the old image).
*/
ensureThumbnail(attachmentId: string | undefined): void {
if (attachmentId === undefined || this.thumbs.has(attachmentId)) return;
this.thumbs.set(attachmentId, { status: "loading" });
if (
!this.started ||
attachmentId === undefined ||
this.sessionOf(attachmentId) === undefined ||
this.thumbs.has(attachmentId)
)
return;
const loading: ThumbnailState = { status: "loading" };
this.thumbs.set(attachmentId, loading);
this.publish();
this.deps.loadImage(attachmentId).then(
(url) => {
if (!this.thumbs.has(attachmentId)) {
if (this.thumbs.get(attachmentId) !== loading) {
// Replaced or removed while loading: never resurrect (or leak) it.
revoke(url);
return;
}
this.retryCounts.delete(attachmentId);
this.thumbs.set(attachmentId, { status: "ready", url });
const sessionId = this.sessionOf(attachmentId);
if (sessionId !== undefined) {
const previous = this.lastReady.get(sessionId);
if (previous !== undefined && previous.attachmentId !== attachmentId) {
revoke(previous.url);
this.thumbs.delete(previous.attachmentId);
this.forgetThumbnail(previous.attachmentId);
}
this.lastReady.set(sessionId, { attachmentId, url });
}
this.publish();
},
() => {
this.thumbs.set(attachmentId, { status: "error" });
if (this.thumbs.get(attachmentId) !== loading) return;
const failed: ThumbnailState = { status: "error" };
this.thumbs.set(attachmentId, failed);
const attempts = this.retryCounts.get(attachmentId) ?? 0;
const delay = THUMBNAIL_RETRY_DELAYS_MS[attempts];
if (delay !== undefined) {
this.retryCounts.set(attachmentId, attempts + 1);
this.retryTimers.set(
attachmentId,
setTimeout(() => {
this.retryTimers.delete(attachmentId);
if (this.thumbs.get(attachmentId) !== failed) return;
this.thumbs.delete(attachmentId);
this.ensureThumbnail(attachmentId);
}, delay),
);
}
this.publish();
},
);
}
/** Explicit retry (also used on a fresh connection after an outage). */
retryThumbnail(attachmentId: string | undefined): void {
if (attachmentId === undefined || this.thumbs.get(attachmentId)?.status !== "error") return;
this.forgetThumbnail(attachmentId);
this.ensureThumbnail(attachmentId);
}
/** Interrupt the current or named session's in-flight call. */
async interrupt(sessionId?: string): Promise<boolean> {
try {
@@ -6,7 +6,7 @@
* without duplicating hooks.
*/
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { SessionObservation } from "../observation";
import type { ObservationClientStore, OverlaySnapshot } from "./observation-store";
@@ -114,6 +114,49 @@ export function useObservationView(
};
}
/** A mounted metadata watcher is not necessarily a visible screenshot viewer. */
export function useThumbnailObservation(store: ObservationClientStore, enabled: boolean) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (!enabled || element === null) return;
// A portal uses its PiP document's visibility, independently of the main tab.
const doc = element.ownerDocument;
const Observer = doc.defaultView?.IntersectionObserver;
let active = true;
let intersecting = Observer === undefined;
let release: (() => void) | undefined;
const update = () => {
if (!active) return;
const visible = intersecting && doc.visibilityState !== "hidden";
if (visible && release === undefined) release = store.watchThumbnails();
else if (!visible && release !== undefined) {
release();
release = undefined;
}
};
const observer =
Observer === undefined
? undefined
: new Observer((entries) => {
intersecting = entries.some(
(entry) => entry.target === element && entry.isIntersecting,
);
update();
});
observer?.observe(element);
doc.addEventListener("visibilitychange", update);
update();
return () => {
active = false;
observer?.disconnect();
doc.removeEventListener("visibilitychange", update);
release?.();
};
}, [store, enabled]);
return ref;
}
export interface PipHandle {
/** The open PiP window, or null while the content renders in its carrier. */
readonly pipWindow: Window | null;
@@ -5,12 +5,19 @@
* the event allowlist lives in dsh-api-remotes), so the plugin serves its
* observation channel through the documented `webServer` route seam instead:
*
* GET /bsk-observation/state → { sessions, available }
* GET /bsk-observation/events → SSE stream of ObservationEvent
* GET /bsk-observation/state → one-time { sessions, available } read
* GET /bsk-observation/events → SSE snapshot, then ObservationEvent increments
* (on every connection, including reconnects)
* ?thumbnails=0: state only; =1: request screenshots
* Omitted parameter defaults to screenshots.
* POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean}
* POST /bsk-observation/stop → body {sessionId} → {stopped: boolean}
* GET /bsk-observation/thumbnail/<attachmentId> → image bytes
*
* Live clients initialize/resynchronize from the SSE snapshot; a separate
* /state read has no ordering guarantee relative to the stream and does not
* request screenshots. Closing an SSE connection releases its screenshot demand.
*
* Routes exist only when a webServer service is mounted (web composition);
* headless deployments skip registration entirely.
*/
@@ -99,6 +106,7 @@ export function registerObservationRoutes(
if (webServer === undefined) {
return () => {};
}
const streams = new Set<() => void>();
const disposers = [
webServer.register({
kind: "exact",
@@ -129,14 +137,42 @@ export function registerObservationRoutes(
"cache-control": "no-cache",
connection: "keep-alive",
});
const unsubscribe = observation.subscribe((event) => {
res.write(`data: ${JSON.stringify(event)}\n\n`);
});
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), SSE_HEARTBEAT_MS);
res.on("close", () => {
const thumbnails =
new URL(req.url ?? "/", "http://localhost").searchParams.get("thumbnails") !== "0";
let unsubscribe: (() => void) | undefined;
let heartbeat: ReturnType<typeof setInterval> | undefined;
let closed = false;
const close = () => {
if (closed) return;
closed = true;
clearInterval(heartbeat);
unsubscribe();
});
unsubscribe?.();
streams.delete(close);
res.end();
};
const write = (data: string) => {
if (closed) return;
try {
res.write(data);
} catch {
close();
}
};
streams.add(close);
res.on("close", close);
res.on("error", close);
try {
unsubscribe = observation.subscribe(
(event) => {
write(`data: ${JSON.stringify(event)}\n\n`);
},
{ thumbnails },
);
if (closed) unsubscribe();
else heartbeat = setInterval(() => write(": heartbeat\n\n"), SSE_HEARTBEAT_MS);
} catch {
close();
}
},
}),
webServer.register({
@@ -228,6 +264,7 @@ export function registerObservationRoutes(
}),
];
return () => {
for (const close of streams) close();
for (const dispose of disposers) dispose();
};
}
@@ -49,8 +49,9 @@ export interface SessionObservation {
dshSessionIds?: string[];
}
/** Incremental event carried to subscribers (SSE on the wire). */
/** Initial snapshot or incremental change carried to subscribers (SSE on the wire). */
export type ObservationEvent =
| { type: "snapshot"; sessions: SessionObservation[]; available: boolean }
| { type: "upsert" | "remove" | "reset"; session?: SessionObservation }
| { type: "availability"; available: boolean };
@@ -89,6 +90,7 @@ export class ObservationService {
private readonly scratchNamespace = randomUUID();
private readonly observations = new Map<string, SessionObservation>();
private readonly listeners = new Set<(event: ObservationEvent) => void>();
private thumbnailViewers = 0;
private readonly captureTimers = new Map<string, unknown>();
private readonly captureInFlight = new Set<string>();
/** Captures intentionally cancelled to make way for foreground work. */
@@ -125,7 +127,7 @@ export class ObservationService {
return this.deps.scheduler ?? DEFAULT_SCHEDULER;
}
/** All current entries (client initial/resync snapshot). */
/** One-time state read; use subscribe for an ordered snapshot and subsequent changes. */
getState(): SessionObservation[] {
return [...this.observations.values()].map((entry) => ({ ...entry }));
}
@@ -141,10 +143,42 @@ export class ObservationService {
this.emit({ type: "availability", available });
}
/** Subscribe to incremental changes; returns an unsubscribe function. */
subscribe(listener: (event: ObservationEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
/**
* Subscribe to an ordered initial snapshot and subsequent changes. On an
* active service, listener receives the snapshot synchronously before this
* method returns. Subscribing after disposal is a no-op.
*
* `thumbnails` defaults to true for compatibility: this subscription counts
* as a screenshot viewer for the service's owned sessions. Pass false for
* state-only observation. The first viewer starts capture scheduling.
*
* @returns An idempotent unsubscribe function releasing this subscription's
* screenshot demand. When the last viewer leaves, scheduled/queued captures
* are cancelled/skipped; an already running capture may finish.
*/
subscribe(
listener: (event: ObservationEvent) => void,
{ thumbnails = true }: { thumbnails?: boolean } = {},
): () => void {
if (this.disposed) return () => {};
// Each subscription owns its lease, even if a caller reuses a callback.
const receive = (event: ObservationEvent) => listener(event);
this.listeners.add(receive);
try {
listener({ type: "snapshot", sessions: this.getState(), available: this.available });
} catch (error) {
this.listeners.delete(receive);
throw error;
}
if (thumbnails && ++this.thumbnailViewers === 1) {
for (const sessionId of this.observations.keys()) this.scheduleCapture(sessionId, 0);
}
return () => {
if (!this.listeners.delete(receive)) return;
if (thumbnails && --this.thumbnailViewers === 0) {
for (const sessionId of this.captureTimers.keys()) this.cancelCapture(sessionId);
}
};
}
private emit(event: ObservationEvent): void {
@@ -355,6 +389,7 @@ export class ObservationService {
this.observations.clear();
this.emit({ type: "reset" });
this.listeners.clear();
this.thumbnailViewers = 0;
}
// ------------------------------------------------------------------
@@ -369,12 +404,21 @@ export class ObservationService {
}
}
private canCapture(sessionId: string): boolean {
const entry = this.observations.get(sessionId);
return (
this.deps.options.enabled &&
!this.disposed &&
this.thumbnailViewers > 0 &&
!this.foregroundDepth.has(sessionId) &&
entry !== undefined &&
entry.dead !== true
);
}
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
private scheduleCapture(sessionId: string, delayMs?: number): void {
if (!this.deps.options.enabled || this.disposed) return;
if (this.foregroundDepth.has(sessionId)) return;
const entry = this.observations.get(sessionId);
if (entry === undefined || entry.dead === true) return;
if (!this.canCapture(sessionId)) return;
this.cancelCapture(sessionId);
const now = this.scheduler.now();
const lastSeen = this.lastActivity.get(sessionId) ?? 0;
@@ -399,9 +443,7 @@ export class ObservationService {
* Failures keep the previous frame and back off silently.
*/
private async capture(sessionId: string): Promise<void> {
if (this.disposed) return;
const current = this.observations.get(sessionId);
if (current === undefined || current.dead === true) return;
if (!this.canCapture(sessionId)) return;
if (this.captureInFlight.has(sessionId)) return;
this.captureInFlight.add(sessionId);
// Stable within this service, isolated from captures owned by other instances.
@@ -410,8 +452,8 @@ export class ObservationService {
let writtenPath = outPath;
try {
const result = await this.deps.queue.run(sessionId, () => {
// A foreground lease may have arrived while this capture was queued.
if (this.foregroundDepth.has(sessionId)) return Promise.resolve(undefined);
// The last viewer may have left, or foreground work arrived, while queued.
if (!this.canCapture(sessionId)) return Promise.resolve(undefined);
return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
timeoutMs: 15_000,
tag: `observation:${sessionId}`,
@@ -445,6 +487,7 @@ export class ObservationService {
this.setAvailable(true);
this.publishFrame(sessionId, entry, data);
} catch {
if (this.disposed || !this.observations.has(sessionId)) return;
// Foreground preemption is healthy scheduling, not browser/daemon
// unavailability; do not let normal tool traffic trip capture backoff.
if (!this.capturePreempted.delete(sessionId)) {
+12 -3
View File
@@ -489,7 +489,9 @@ 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. Read-only: never submits input.";
"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.";
register(
defineTool({
name,
@@ -636,7 +638,9 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
session: SESSION_PARAM,
noClear: {
type: "boolean",
description: "Skip the default wipe-the-field-first pass (append instead).",
description:
"Append to the end of the existing value instead of clearing it. " +
"The tool moves the caret automatically; no prior click or selection is needed.",
},
},
output: {
@@ -752,12 +756,17 @@ 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: "Snapshot ref (@e3) from the last snapshot/observe; crops to that element.",
description:
"Ref (@e3) from the last snapshot/observe; crops to that DOM element or visual surface.",
},
},
output: {
@@ -19,10 +19,12 @@ interface Harness {
emitRaw: (event: unknown) => void;
fetches: { url: string; init?: { body?: string } }[];
loadImage: ReturnType<typeof vi.fn>;
eventUrls: string[];
}
function makeHarness(initial: SessionObservation[]): Harness {
const fetches: Harness["fetches"] = [];
const eventUrls: string[] = [];
let es: EventSourceLike | undefined;
let current = initial;
const loadImage = vi.fn(async (id: string) => `blob:${id}`);
@@ -34,9 +36,16 @@ function makeHarness(initial: SessionObservation[]): Harness {
}
return { ok: true, json: async () => ({ interrupted: true }) };
},
eventSourceFactory: () => {
es = { onmessage: null, close: vi.fn() };
return es;
eventSourceFactory: (url) => {
eventUrls.push(url);
const connection = { onmessage: null, close: vi.fn() } as EventSourceLike;
es = connection;
queueMicrotask(() =>
connection.onmessage?.({
data: JSON.stringify({ type: "snapshot", sessions: current, available: true }),
}),
);
return connection;
},
loadImage,
});
@@ -58,10 +67,120 @@ function makeHarness(initial: SessionObservation[]): Harness {
},
fetches,
loadImage,
eventUrls,
};
}
afterEach(cleanup);
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("visible thumbnail demand", () => {
function intersections() {
const observers: Array<{
notify: (visible: boolean) => void;
disconnect: ReturnType<typeof vi.fn>;
}> = [];
vi.stubGlobal(
"IntersectionObserver",
class {
private target?: Element;
disconnect = vi.fn();
constructor(private callback: IntersectionObserverCallback) {
observers.push(this);
}
observe(target: Element) {
this.target = target;
}
notify(visible: boolean) {
this.callback(
[{ target: this.target, isIntersecting: visible } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
},
);
return observers;
}
it("pauses screenshots on collapse while keeping the metadata feed", async () => {
const observers = intersections();
const h = makeHarness([BUSY]);
const { unmount } = render(<ObservationOverlay store={h.store} />);
await screen.findByTestId("obs-card");
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
act(() => observers[0].notify(true));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
fireEvent.click(screen.getByRole("button", { name: "Collapse" }));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
expect(h.es().close).not.toHaveBeenCalled();
expect(observers[0].disconnect).toHaveBeenCalledOnce();
// A callback queued before disconnect must not reclaim screenshot demand.
act(() => observers[0].notify(true));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
fireEvent.click(screen.getByRole("button", { name: /Expand browser observation/ }));
act(() => observers[1].notify(true));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
unmount();
expect(h.es().close).toHaveBeenCalledOnce();
});
it("ignores an initial visibility notification delivered after collapse", async () => {
const observers = intersections();
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
await screen.findByTestId("obs-card");
fireEvent.click(screen.getByRole("button", { name: "Collapse" }));
act(() => observers[0].notify(true));
expect(h.eventUrls).toEqual(["/bsk-observation/events?thumbnails=0"]);
});
it("pauses for a hidden document or hidden panel and resumes when visible", async () => {
const observers = intersections();
let visibility: DocumentVisibilityState = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility);
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
await screen.findByTestId("obs-card");
act(() => observers[0].notify(true));
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
act(() => {
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(h.eventUrls.at(-1)).toContain("thumbnails=0");
act(() => {
visibility = "visible";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
act(() => observers[0].notify(false));
expect(h.eventUrls.at(-1)).toContain("thumbnails=0");
});
it("keeps a visible PiP watching when the main document is hidden", async () => {
// The detached PiP test document has no window/IntersectionObserver.
vi.stubGlobal("IntersectionObserver", undefined);
let visibility: DocumentVisibilityState = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility);
const pipDoc = document.implementation.createHTMLDocument("pip");
Object.defineProperty(pipDoc, "visibilityState", { value: "visible" });
(window as unknown as Record<string, unknown>).documentPictureInPicture = {
requestWindow: async () => ({ document: pipDoc, addEventListener: () => {}, close: vi.fn() }),
};
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
fireEvent.click(await screen.findByRole("button", { name: /Pop out/ }));
await waitFor(() => expect(pipDoc.body.textContent).toContain("s1"));
act(() => {
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
});
});
describe("ObservationOverlay", () => {
beforeEach(() => {
@@ -92,6 +211,16 @@ describe("ObservationOverlay", () => {
await screen.findByRole("img", { name: "session s1 view" });
});
it("offers a retry after a thumbnail fails without requiring a new frame id", async () => {
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-9" }]);
h.loadImage.mockRejectedValueOnce(new Error("temporary failure"));
render(<ObservationOverlay store={h.store} />);
fireEvent.click(await screen.findByRole("button", { name: "Retry thumbnail" }));
await screen.findByRole("img", { name: "session s1 view" });
expect(h.loadImage).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("button", { name: "Retry thumbnail" })).toBeNull();
});
it("keeps the current frame on stage while the next thumbnail loads", async () => {
let resolveNext: ((url: string) => void) | undefined;
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-1" }]);
@@ -53,8 +53,14 @@ function makeHarness(initial: SessionObservation[]): Harness {
return { ok: true, json: async () => ({ interrupted: true }) };
},
eventSourceFactory: () => {
es = { onmessage: null, close: vi.fn() };
return es;
const connection = { onmessage: null, close: vi.fn() } as EventSourceLike;
es = connection;
queueMicrotask(() =>
connection.onmessage?.({
data: JSON.stringify({ type: "snapshot", sessions: current, available: true }),
}),
);
return connection;
},
loadImage: async (id: string) => `blob:${id}`,
});
@@ -1,12 +1,19 @@
// ObservationClientStore: initial fetch + SSE application, thumbnail loading
// ObservationClientStore: ordered SSE snapshots/increments, thumbnail loading
// lifecycle, interrupt wire shape. All I/O faked.
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
import type { ObservationEvent, SessionObservation } from "../../src/observation";
const OBS_IDLE: SessionObservation = { sessionId: "s1", action: "idle", since: 1000 };
const OBS_BUSY: SessionObservation = { sessionId: "s1", action: "clicking", since: 2000 };
const stores: ObservationClientStore[] = [];
afterEach(() => {
for (const store of stores.splice(0)) store.stop();
vi.restoreAllMocks();
vi.useRealTimers();
});
function harness(state: SessionObservation[] = []) {
const fetches: {
@@ -14,6 +21,7 @@ function harness(state: SessionObservation[] = []) {
init?: { method?: string; body?: string; headers?: Record<string, string> };
}[] = [];
const esInstances: EventSourceLike[] = [];
const eventUrls: string[] = [];
const fetchFn = vi.fn(
async (
url: string,
@@ -29,11 +37,14 @@ function harness(state: SessionObservation[] = []) {
const eventSourceFactory = (url: string) => {
const es: EventSourceLike = { onmessage: null, close: vi.fn() };
esInstances.push(es);
eventUrls.push(url);
queueMicrotask(() => emit(es, { type: "snapshot", sessions: state, available: true }));
return es;
};
const loadImage = vi.fn(async (id: string) => `blob:url-${id}`);
const store = new ObservationClientStore({ fetchFn, eventSourceFactory, loadImage });
return { store, fetches, esInstances, loadImage };
stores.push(store);
return { store, fetches, esInstances, eventUrls, loadImage };
}
function emit(es: EventSourceLike, event: ObservationEvent): void {
@@ -73,7 +84,9 @@ describe("ObservationClientStore", () => {
});
it("loads each thumbnail once and reports ready", async () => {
const { store, loadImage } = harness();
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "att-1" }]);
store.start();
await Promise.resolve();
store.ensureThumbnail("att-1");
store.ensureThumbnail("att-1");
expect(loadImage).toHaveBeenCalledTimes(1);
@@ -86,7 +99,9 @@ describe("ObservationClientStore", () => {
});
it("marks failed thumbnail loads as error", async () => {
const { store, loadImage } = harness();
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "att-bad" }]);
store.start();
await Promise.resolve();
loadImage.mockRejectedValueOnce(new Error("nope"));
store.ensureThumbnail("att-bad");
await vi.waitFor(() => expect(store.getSnapshot().thumbnails["att-bad"].status).toBe("error"));
@@ -124,9 +139,7 @@ describe("ObservationClientStore", () => {
describe("thumbnail blob URL lifecycle", () => {
function withRevokeSpy() {
const revoke = vi.fn();
(URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revoke;
return revoke;
return vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
}
it("holds the last ready frame until the replacement decodes", async () => {
@@ -231,16 +244,216 @@ describe("thumbnail blob URL lifecycle", () => {
});
});
describe("SSE (re)open resync", () => {
it("refetches the full state when the stream (re)opens", async () => {
describe("SSE snapshot ordering", () => {
it("ignores events from a stopped connection after a restart", async () => {
const { store, esInstances } = harness([OBS_IDLE]);
store.start();
await Promise.resolve();
const old = esInstances[0];
store.stop();
emit(old, { type: "upsert", session: OBS_BUSY });
expect(store.getSnapshot().sessions).toEqual([]);
store.start();
await Promise.resolve();
emit(esInstances[1], { type: "upsert", session: OBS_BUSY });
emit(old, { type: "snapshot", sessions: [], available: false });
emit(old, { type: "remove", session: OBS_BUSY });
expect(store.getSnapshot()).toMatchObject({ sessions: [OBS_BUSY], available: true });
});
it("does not apply a queued initial snapshot after stop", async () => {
const { store } = harness([OBS_BUSY]);
store.start();
store.stop();
await Promise.resolve();
expect(store.getSnapshot()).toMatchObject({ sessions: [], thumbnails: {}, subscribed: false });
});
it("replaces state on reconnect before applying subsequent events, without a competing fetch", async () => {
const { store, fetches, esInstances } = harness([OBS_IDLE]);
store.start();
await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1));
const before = fetches.filter((f) => f.url === "/bsk-observation/state").length;
esInstances[0].onopen?.();
await vi.waitFor(() =>
expect(fetches.filter((f) => f.url === "/bsk-observation/state").length).toBe(before + 1),
);
emit(esInstances[0], { type: "snapshot", sessions: [], available: false });
expect(store.getSnapshot()).toMatchObject({ sessions: [], available: false });
emit(esInstances[0], { type: "upsert", session: OBS_BUSY });
await Promise.resolve();
expect(store.getSnapshot().sessions).toEqual([OBS_BUSY]);
expect(fetches).toEqual([]);
store.stop();
});
});
describe("thumbnail recovery and stale loads", () => {
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
it("retries a transient failure without needing a different frame id", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
store.start();
await Promise.resolve();
loadImage.mockRejectedValueOnce(new Error("temporary"));
store.ensureThumbnail("a1");
await Promise.resolve();
expect(store.getSnapshot().thumbnails.a1.status).toBe("error");
await vi.advanceTimersByTimeAsync(1000);
expect(loadImage).toHaveBeenCalledTimes(2);
expect(store.getSnapshot().displayFrames.s1).toEqual({ status: "ready", url: "blob:url-a1" });
expect(vi.getTimerCount()).toBe(0);
});
it("bounds automatic retries and allows an explicit recovery", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
store.start();
await Promise.resolve();
loadImage.mockRejectedValue(new Error("offline"));
store.ensureThumbnail("a1");
await vi.advanceTimersByTimeAsync(60_000);
expect(loadImage).toHaveBeenCalledTimes(3);
expect(vi.getTimerCount()).toBe(0);
store.ensureThumbnail("a1");
expect(loadImage).toHaveBeenCalledTimes(3);
loadImage.mockResolvedValue("blob:recovered");
store.retryThumbnail("a1");
await Promise.resolve();
expect(store.getSnapshot().displayFrames.s1.url).toBe("blob:recovered");
});
it("cancels backoff when a reconnect retries the current frame", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const session = { ...OBS_IDLE, thumbnailAttachmentId: "a1" };
const { store, esInstances, loadImage } = harness([session]);
store.start();
await Promise.resolve();
loadImage.mockRejectedValueOnce(new Error("offline"));
store.ensureThumbnail("a1");
await Promise.resolve();
emit(esInstances[0], { type: "snapshot", sessions: [session], available: true });
await Promise.resolve();
expect(store.getSnapshot().thumbnails.a1.status).toBe("ready");
await vi.advanceTimersByTimeAsync(60_000);
expect(loadImage).toHaveBeenCalledTimes(2);
});
it.each([
"remove",
"reset",
"stop",
"missing-thumbnail",
"snapshot-removal",
])("cleans frames and cancels retries on %s", async (action) => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const { store, esInstances, loadImage } = harness([
{ ...OBS_IDLE, thumbnailAttachmentId: "a1" },
]);
store.start();
await Promise.resolve();
store.ensureThumbnail("a1");
await Promise.resolve();
emit(esInstances[0], { type: "upsert", session: { ...OBS_IDLE, thumbnailAttachmentId: "a2" } });
loadImage.mockRejectedValueOnce(new Error("no frame"));
store.ensureThumbnail("a2");
await Promise.resolve();
expect(store.getSnapshot().displayFrames.s1).toEqual({ status: "error", url: "blob:url-a1" });
if (action === "stop") store.stop();
else if (action === "remove") emit(esInstances[0], { type: "remove", session: OBS_IDLE });
else if (action === "reset") emit(esInstances[0], { type: "reset" });
else if (action === "missing-thumbnail")
emit(esInstances[0], { type: "upsert", session: OBS_IDLE });
else emit(esInstances[0], { type: "snapshot", sessions: [], available: true });
expect(store.getSnapshot().thumbnails).toEqual({});
expect(store.getSnapshot().displayFrames).toEqual({});
expect(revoke).toHaveBeenCalledExactlyOnceWith("blob:url-a1");
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(60_000);
expect(loadImage).toHaveBeenCalledTimes(2);
});
it.each([
"resolve",
"reject",
])("ignores a stale load's %s even when the same id is loaded again", async (outcome) => {
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const { store, esInstances, loadImage } = harness([
{ ...OBS_IDLE, thumbnailAttachmentId: "a1" },
]);
const old = deferred<string>();
loadImage.mockReturnValueOnce(old.promise);
store.start();
await Promise.resolve();
store.ensureThumbnail("a1");
emit(esInstances[0], { type: "upsert", session: { ...OBS_IDLE, thumbnailAttachmentId: "a2" } });
emit(esInstances[0], { type: "upsert", session: { ...OBS_IDLE, thumbnailAttachmentId: "a1" } });
store.ensureThumbnail("a1");
await Promise.resolve();
if (outcome === "resolve") old.resolve("blob:stale");
else old.reject(new Error("stale error"));
await Promise.resolve();
expect(store.getSnapshot().displayFrames.s1).toEqual({ status: "ready", url: "blob:url-a1" });
expect(revoke).not.toHaveBeenCalledWith("blob:url-a1");
if (outcome === "resolve") expect(revoke).toHaveBeenCalledWith("blob:stale");
});
it.each(["resolve", "reject"])("ignores an in-flight load's %s after stop", async (outcome) => {
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
const pending = deferred<string>();
loadImage.mockReturnValueOnce(pending.promise);
store.start();
await Promise.resolve();
store.ensureThumbnail("a1");
store.stop();
if (outcome === "resolve") pending.resolve("blob:late");
else pending.reject(new Error("late error"));
await Promise.resolve();
expect(store.getSnapshot()).toMatchObject({ sessions: [], thumbnails: {}, displayFrames: {} });
if (outcome === "resolve") expect(revoke).toHaveBeenCalledWith("blob:late");
});
});
describe("thumbnail viewer leases", () => {
it("keeps metadata subscribed while the last screenshot viewer leaves", async () => {
const { store, eventUrls, esInstances } = harness([OBS_IDLE]);
store.acquire();
await Promise.resolve();
expect(eventUrls).toEqual(["/bsk-observation/events?thumbnails=0"]);
const first = store.watchThumbnails();
expect(eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
const second = store.watchThumbnails();
expect(eventUrls).toHaveLength(2);
first();
first();
expect(eventUrls).toHaveLength(2);
second();
expect(eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
expect(store.getSnapshot().subscribed).toBe(true);
store.release();
expect(esInstances.at(-1)?.close).toHaveBeenCalledOnce();
expect(store.getSnapshot().subscribed).toBe(false);
});
it("ignores delayed snapshots from a replaced stream without clearing the displayed state", async () => {
const { store, esInstances } = harness([OBS_IDLE]);
store.start();
await Promise.resolve();
emit(esInstances[0], { type: "upsert", session: OBS_BUSY });
const release = store.watchThumbnails();
expect(store.getSnapshot().sessions).toEqual([OBS_BUSY]);
await Promise.resolve();
emit(esInstances[1], { type: "upsert", session: OBS_BUSY });
emit(esInstances[0], { type: "snapshot", sessions: [], available: false });
expect(store.getSnapshot()).toMatchObject({ sessions: [OBS_BUSY], available: true });
store.stop();
release();
expect(esInstances).toHaveLength(2);
});
});
@@ -6,7 +6,7 @@ import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import { tmpdir } from "node:os";
import { basename, dirname } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
actionForLabel,
type ObservationEvent,
@@ -138,6 +138,8 @@ function setup(opts: {
runner?: FakeRunner;
registry?: SessionRegistry;
scheduler?: ReturnType<typeof fakeScheduler>;
thumbnails?: boolean;
queue?: KeyedExecutor;
}) {
const registry = opts.registry ?? new SessionRegistry(5);
const runner = opts.runner ?? fakeRunner();
@@ -146,13 +148,18 @@ function setup(opts: {
ctx: fakeCtx(),
runner,
registry,
queue: new KeyedExecutor(),
queue: opts.queue ?? new KeyedExecutor(),
options: OPTIONS,
scheduler,
});
const events: ObservationEvent[] = [];
service.subscribe((event) => events.push(event));
return { service, registry, runner, scheduler, events };
const unsubscribe = service.subscribe(
(event) => {
if (event.type !== "snapshot") events.push(event);
},
{ thumbnails: opts.thumbnails ?? true },
);
return { service, registry, runner, scheduler, events, unsubscribe };
}
/** Drive the registry through a real start so the session is owned. */
@@ -224,6 +231,111 @@ describe("state machine", () => {
});
describe("thumbnail cadence", () => {
it("keeps metadata current without screenshots and resumes when a viewer arrives", async () => {
const { service, scheduler, runner, events } = setup({ thumbnails: false });
service.addSession("s1");
service.beginAction("s1", "clicking");
service.endAction("s1");
const releaseForeground = service.acquireForeground("s1");
releaseForeground();
expect(service.getState()[0].action).toBe("idle");
expect(events).toHaveLength(3);
expect(scheduler.pending()).toEqual([]);
expect(runner.calls).toEqual([]);
const snapshots: ObservationEvent[] = [];
const leave = service.subscribe((event) => snapshots.push(event));
expect(snapshots[0]).toEqual({
type: "snapshot",
sessions: service.getState(),
available: true,
});
expect(scheduler.pending()).toEqual([0]);
scheduler.runNext();
await waitFor(() => scheduler.pending().length === 1);
expect(runner.calls).toHaveLength(1);
leave();
leave();
expect(scheduler.pending()).toEqual([]);
service.endAction("s1");
expect(scheduler.pending()).toEqual([]);
const resume = service.subscribe(() => {});
expect(scheduler.pending()).toEqual([0]);
resume();
service.dispose();
});
it("does not capture in a headless service with no subscribers", () => {
const { service, scheduler, unsubscribe } = setup({});
unsubscribe();
service.addSession("s1");
service.endAction("s1");
expect(scheduler.pending()).toEqual([]);
expect(service.getState()).toHaveLength(1);
service.dispose();
});
it("keeps capturing until the last viewer leaves, including reused callbacks", () => {
const { service, scheduler } = setup({ thumbnails: false });
service.addSession("s1");
const listener = () => {};
const first = service.subscribe(listener);
const second = service.subscribe(listener);
expect(scheduler.pending()).toEqual([0]);
first();
first();
expect(scheduler.pending()).toEqual([0]);
second();
expect(scheduler.pending()).toEqual([]);
service.dispose();
});
it("skips a queued screenshot if its last viewer leaves before execution", async () => {
const queue = new KeyedExecutor();
let unblock!: () => void;
const gate = new Promise<void>((resolve) => {
unblock = resolve;
});
const busy = queue.run("s1", () => gate);
const { service, scheduler, runner, unsubscribe } = setup({ queue });
service.addSession("s1");
scheduler.runNext();
unsubscribe();
unblock();
await busy;
await queue.run("s1", async () => {});
expect(runner.calls).toEqual([]);
expect(scheduler.pending()).toEqual([]);
service.dispose();
});
it("lets an already running capture finish without restarting the loop after the last viewer leaves", async () => {
const runner = fakeRunner();
const run = runner.run.bind(runner);
let finish!: () => void;
let started = false;
runner.run = async (args, options) => {
started = true;
await new Promise<void>((resolve) => {
finish = resolve;
});
return run(args, options);
};
const { service, scheduler, unsubscribe } = setup({ runner });
service.addSession("s1");
scheduler.runNext();
await waitFor(() => started);
unsubscribe();
finish();
await waitFor(() => service.getState()[0]?.thumbnailAttachmentId !== undefined);
const args = runner.calls[0].args;
await waitFor(() => !existsSync(args[args.indexOf("--out") + 1]));
expect(scheduler.pending()).toEqual([]);
const leave = service.subscribe(() => {});
expect(scheduler.pending()).toEqual([0]);
leave();
service.dispose();
});
it("gives foreground work priority and resumes with one fresh capture", async () => {
const scheduler = fakeScheduler();
let finishCapture: ((result: BskRunResult) => void) | undefined;
@@ -540,6 +652,12 @@ describe("HTTP/SSE interface", () => {
const eventsRes = fakeRes();
await eventsRoute?.handler(fakeReq(), eventsRes.res as never);
service.beginAction("s1", "clicking");
const streamed = eventsRes.chunks.map((chunk) => JSON.parse(chunk.slice(6)));
expect(streamed.map((event) => event.type)).toEqual(["snapshot", "upsert"]);
expect(streamed[0]).toMatchObject({
sessions: [{ sessionId: "s1", action: "idle" }],
available: true,
});
expect(eventsRes.res.body()).toContain('"action":"clicking"');
eventsRes.res.close();
@@ -566,6 +684,47 @@ describe("HTTP/SSE interface", () => {
service.dispose();
});
it("state-only SSE does not capture, and route disposal releases every active viewer", async () => {
const { service, scheduler } = setup({ thumbnails: false });
service.addSession("s1");
const { routes, webServer } = routeHarness();
const dispose = registerObservationRoutes({ get: () => webServer } as never, service);
const route = routes.get("/bsk-observation/events")!;
const metadata = fakeRes();
await route.handler(fakeReq({ url: "/bsk-observation/events?thumbnails=0" }), metadata.res);
expect(scheduler.pending()).toEqual([]);
const viewing = fakeRes();
await route.handler(fakeReq({ url: "/bsk-observation/events?thumbnails=1" }), viewing.res);
expect(scheduler.pending()).toEqual([0]);
const endMetadata = vi.spyOn(metadata.res, "end");
const endViewing = vi.spyOn(viewing.res, "end");
dispose();
expect(endMetadata).toHaveBeenCalledOnce();
expect(endViewing).toHaveBeenCalledOnce();
expect(scheduler.pending()).toEqual([]);
viewing.res.close();
metadata.res.close();
expect(endViewing).toHaveBeenCalledOnce();
service.dispose();
});
it("releases a viewer when writing its initial snapshot fails", async () => {
const { service, scheduler } = setup({ thumbnails: false });
service.addSession("s1");
const { routes, webServer } = routeHarness();
const dispose = registerObservationRoutes({ get: () => webServer } as never, service);
const response = fakeRes();
vi.spyOn(response.res, "write").mockImplementation(() => {
throw new Error("closed socket");
});
const end = vi.spyOn(response.res, "end");
await routes.get("/bsk-observation/events")!.handler(fakeReq(), response.res);
expect(end).toHaveBeenCalledOnce();
expect(scheduler.pending()).toEqual([]);
dispose();
service.dispose();
});
it("refuses a stop without a sessionId", async () => {
const registry = new SessionRegistry(5);
own(registry, "s1");
@@ -124,6 +124,28 @@ describe("parseBskJson", () => {
expect(() => parseBskJson({ ...base, code: 1, stderr: "boom" }, "x")).toThrow(/boom/);
});
it("surfaces fill recovery guidance to the model without retrying", async () => {
const hint =
"observe the field before retrying; the page may have formatted the value. Continue if the visible result satisfies the user's intent";
const reply = {
...base,
code: 3,
stdout: JSON.stringify({
code: "cdp_failed",
message: "fill could not verify the expected value",
data: { reason: "fill_value_mismatch" },
hint,
}),
};
let calls = 0;
const result = await runWithSessionBusyRetry(async () => {
calls++;
return reply;
});
expect(calls).toBe(1);
expect(() => parseBskJson(result, "fill")).toThrow(hint);
});
it("reports killed-by-interrupt children (null exit code) as interrupted", () => {
expect(() => parseBskJson({ ...base, code: null }, "navigate")).toThrow(/interrupted/);
});
+153
View File
@@ -29,6 +29,159 @@ 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([
@@ -0,0 +1,35 @@
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,6 +15,13 @@ export type {
VomNode,
VomOptions,
VomRef,
VomRefCapability,
VomResult,
VomScene,
VomVisualSurface,
} from "./types";
export {
compareVisualSurfacePriority,
selectHighestPriorityVisualSurfaces,
type VisualSurfacePriorityInput,
} from "./visual-surface-priority";
+123 -15
View File
@@ -9,7 +9,9 @@ import type {
VomOptions,
VomResult,
VomScene,
VomVisualSurface,
} from "./types";
import { selectHighestPriorityVisualSurfaces } from "./visual-surface-priority";
const SKIP_ROLES = new Set(["generic", "none", "presentation", "inlinetextbox"]);
@@ -87,6 +89,8 @@ 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"]);
@@ -793,34 +797,117 @@ 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}]`;
const headerTokens = estimateTokens(header);
if (state.tokens + headerTokens > state.maxTokens) {
if (!tryAppendLine(state, header)) {
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}`;
const nextTokens = state.tokens + estimateTokens(line);
if (nextTokens > state.maxTokens) {
if (!tryAppendLine(state, line)) {
state.truncated = true;
state.stopped = true;
return;
}
state.lines.push(line);
state.tokens = nextTokens;
}
}
@@ -855,15 +942,11 @@ 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);
const nextTokens = state.tokens + estimateTokens(line);
if (nextTokens > state.maxTokens) {
if (!tryAppendLine(state, line)) {
state.truncated = true;
state.stopped = true;
break;
}
state.lines.push(line);
state.tokens = nextTokens;
if (ref) {
const name = cleaned(node.name);
state.refs.push({
@@ -873,6 +956,8 @@ 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;
@@ -894,6 +979,9 @@ function renderNodes(
initialLines: string[],
surfaces: CondSurface[] = [],
activeScopeBlocks: ActiveScopeBlock[] = [],
visualSurfaces: VomVisualSurface[] = [],
visualSurfacesTruncated = false,
omittedVisualSurfaceCount?: number,
): RenderState {
const children = buildChildren(nodes);
const state: RenderState = {
@@ -913,6 +1001,9 @@ 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);
@@ -1124,8 +1215,21 @@ function renderDoubleLayer(scene: VomScene, layer: BlockingLayer, options: VomOp
"@layers 2 focus=L1",
`L1 ${layer.kind} cover=${Math.round(layer.coverage * 100)}%`,
];
const state = renderNodes(visibleNodes, options, header, scene.surfaces, scene.activeScopeBlocks);
state.lines.push(renderPageOcclusionLine(hiddenCount));
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);
return {
text: state.lines.join("\n"),
@@ -1152,7 +1256,11 @@ 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,6 +61,12 @@ 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 {
@@ -94,6 +100,22 @@ 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 {
@@ -0,0 +1,80 @@
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);
}
+14
View File
@@ -73,6 +73,16 @@ 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.
@@ -172,6 +182,10 @@ intended. Read `bsk upload --help` and `bsk download --help` for all flags and e
- Stale ref: observe again and retry the intended action once.
- Unknown tab or session: list current tabs/sessions; never guess identifiers.
- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful.
- Fill result unconfirmed (`fill_value_mismatch`): observe the field first; the page may have
formatted the value. Continue if the visible result satisfies the user's intent. Otherwise correct
the remaining difference; do not blindly repeat fill or immediately request human help. For other
fill errors, follow the returned hint and inspect current state before retrying.
- Unsupported command: continue with available capabilities; suggest updating only when the missing
command is necessary.
- Unrecoverable failure: report the blocker and stop the session in a finally-style path.