Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions packages/extension/src/lib/actionability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,26 @@ export type ProbeResult = { state: "hit" } | { state: "none" } | { state: "misse
* still receives the press, so the click would "land" and do nothing — and a
* one-shot capture listener is armed recording whether the next pointerdown
* reaches the element; read it back with `readProbe`.
*
* With `node`, the target is the Jev node cache entry (`reins do` observed it
* by id); `selector` then only names it in messages.
*/
export async function actionPoint(
selector: string,
timeoutMs: number,
forClick: boolean,
node: number | null = null,
): Promise<ActionPoint> {
// A Jev node id points at the exact element reins do observed; otherwise
// the CSS selector is re-queried (frameworks may swap nodes while we wait).
const find = (): Element | null => {
if (node === null) return document.querySelector(selector);
const cache = (
window as unknown as Record<symbol, { nodes?: Map<number, Element> } | undefined>
)[Symbol.for("reins.jev")];
const el = cache?.nodes?.get(node);
return el?.isConnected ? el : null;
};
const describe = (n: Element | null): string => {
if (!n) return "nothing (the point is outside the viewport)";
let s = n.tagName.toLowerCase();
Expand Down Expand Up @@ -84,7 +98,7 @@ export async function actionPoint(
let reason = "";
for (let first = true; ; first = false) {
// Re-query every attempt: frameworks may swap the node while we wait.
const el = document.querySelector(selector);
const el = find();
if (!el) {
if (first) return { error: "notfound" };
reason = "element was removed from the page";
Expand Down Expand Up @@ -119,7 +133,7 @@ export async function actionPoint(
const at = e.composedPath()[0];
// Frameworks re-mount nodes on hover; the element the selector
// finds now counts as the target too.
const now = document.querySelector(selector);
const now = find();
const path = e.composedPath();
state.result =
path.includes(el) || (now !== null && path.includes(now))
Expand Down
22 changes: 22 additions & 0 deletions packages/extension/src/lib/cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ vi.mock("./monitor.js", () => ({ isMonitored: () => false }));

import {
__resetDebugSessions,
actionablePoint,
cdpClick,
cdpOpenTab,
cdpType,
Expand Down Expand Up @@ -310,6 +311,27 @@ describe("cdpClick", () => {
});
});

describe("actionablePoint for a Jev node", () => {
it("passes the node id and the short timeout into the page check", async () => {
const sendCommand = vi.fn(async () => ({ result: { value: { x: 5, y: 6 } } }));
vi.stubGlobal("chrome", {
debugger: {
attach: vi.fn(),
detach: vi.fn(),
onDetach: { addListener: () => {} },
sendCommand,
},
});
await expect(
actionablePoint(7, "node 12", "click", true, { node: 12, timeoutMs: 500 }),
).resolves.toEqual({ x: 5, y: 6 });
const expr = (
sendCommand.mock.calls[0] as unknown as [unknown, string, { expression: string }]
)[2].expression;
expect(expr).toMatch(/\("node 12", 500, true, 12\)$/);
});
});

describe("cdpOpenTab", () => {
function stubOpenChrome(opts: { id?: number; attachError?: string } = {}) {
const order: string[] = [];
Expand Down
97 changes: 57 additions & 40 deletions packages/extension/src/lib/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,17 +340,22 @@ export async function ensureVisible(tabId: number): Promise<void> {
);
}

/** Resolve a point where trusted pointer input will land on the element. */
/**
* Resolve a point where trusted pointer input will land on the element.
* `opts.node` targets a Jev node id instead of the selector (which then only
* names the element in errors); `opts.timeoutMs` overrides the default wait.
*/
export async function actionablePoint(
tabId: number,
css: string,
action: string,
forClick: boolean,
opts: { node?: number; timeoutMs?: number } = {},
): Promise<{ x: number; y: number }> {
const { result } = await send<{
result: { value: { x: number; y: number } | { error: string } };
}>(tabId, "Runtime.evaluate", {
expression: `(${actionPoint})(${JSON.stringify(css)}, ${ACTION_TIMEOUT_MS}, ${forClick})`,
expression: `(${actionPoint})(${JSON.stringify(css)}, ${opts.timeoutMs ?? ACTION_TIMEOUT_MS}, ${forClick}, ${opts.node ?? null})`,
returnByValue: true,
awaitPromise: true,
});
Expand All @@ -362,50 +367,62 @@ export async function actionablePoint(
return point;
}

/**
* Press at (x, y) and confirm the press reached the element `actionablePoint`
* armed the probe on. Only a press seen landing elsewhere fails; `what` names
* the target in that error.
*
* `button` and `clickCount` default here because the CLI omits them unless
* flagged, and nothing applies the schema defaults on the way in. They must be
* explicit: CDP's own defaults (button "none", clickCount 0) move the pointer
* but never press.
*/
export async function pressAt(
tabId: number,
x: number,
y: number,
what: string,
button: "left" | "right" | "middle" = "left",
clickCount = 1,
): Promise<void> {
// CDP synthesizes a real click only when the pressed-button bitmask is set
// (button alone isn't enough — the target never sees a `click`). Move the
// pointer first so hit-testing lands on the element under (x, y).
const buttonBit = button === "right" ? 2 : button === "middle" ? 4 : 1;
const base = { x, y, button, clickCount };
await send(tabId, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y, buttons: 0 });
await send(tabId, "Input.dispatchMouseEvent", {
type: "mousePressed",
...base,
buttons: buttonBit,
});
await send(tabId, "Input.dispatchMouseEvent", { type: "mouseReleased", ...base, buttons: 0 });
// Confirm the press reached the element — never report a click that missed.
// A throw or a vanished probe means the click navigated: that's success.
const probe = await send<{ result: { value: ProbeResult | null } }>(tabId, "Runtime.evaluate", {
expression: `(${readProbe})()`,
returnByValue: true,
}).then(
(r) => r.result.value,
() => null,
);
// Only a press seen landing elsewhere is a failure. Seeing nothing proves
// nothing — a page listener may have stopped the event first — and
// failing a click that landed makes the agent click twice.
if (probe?.state === "missed") {
throw new Error(
`click on ${what} landed on ${probe.by} instead — the page changed under the pointer. Re-snapshot and retry.`,
);
}
}

export async function cdpClick(params: ClickParams): Promise<{ ok: true }> {
const tabId = await resolveTabId(params.tabId);
const css = selectorFor(params.ref, params.selector);
return drivePage(tabId, async () => {
await ensureVisible(tabId);
const { x, y } = await actionablePoint(tabId, css, "click", true);
// The CLI omits button/count unless flagged, and nothing applies the schema
// defaults on the way in. They must be explicit: CDP's own defaults
// (button "none", clickCount 0) move the pointer but never press.
const button = params.button ?? "left";
const clickCount = params.clickCount ?? 1;
// CDP synthesizes a real click only when the pressed-button bitmask is set
// (button alone isn't enough — the target never sees a `click`). Move the
// pointer first so hit-testing lands on the element under (x, y).
const buttonBit = button === "right" ? 2 : button === "middle" ? 4 : 1;
const base = { x, y, button, clickCount };
await send(tabId, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y, buttons: 0 });
await send(tabId, "Input.dispatchMouseEvent", {
type: "mousePressed",
...base,
buttons: buttonBit,
});
await send(tabId, "Input.dispatchMouseEvent", {
type: "mouseReleased",
...base,
buttons: 0,
});
// Confirm the press reached the element — never report a click that missed.
// A throw or a vanished probe means the click navigated: that's success.
const probe = await send<{ result: { value: ProbeResult | null } }>(tabId, "Runtime.evaluate", {
expression: `(${readProbe})()`,
returnByValue: true,
}).then(
(r) => r.result.value,
() => null,
);
// Only a press seen landing elsewhere is a failure. Seeing nothing proves
// nothing — a page listener may have stopped the event first — and
// failing a click that landed makes the agent click twice.
if (probe?.state === "missed") {
throw new Error(
`click on ${css} landed on ${probe.by} instead — the page changed under the pointer. Re-snapshot and retry.`,
);
}
await pressAt(tabId, x, y, css, params.button ?? "left", params.clickCount ?? 1);
return { ok: true };
});
}
Expand Down
23 changes: 23 additions & 0 deletions packages/extension/src/lib/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ vi.mock("./cdp.js", () => ({
cdpWaitFor: vi.fn(async () => ({ ok: true })),
}));

vi.mock("./jev.js", () => ({
jevObserve: vi.fn(async () => ({
url: "https://x.com/",
title: "",
text: "",
visible: true,
actions: [],
})),
jevAct: vi.fn(async () => ({ ok: true })),
}));

vi.mock("./reload.js", () => ({
reloadExtension: vi.fn(async () => ({ reloading: true, version: "9.9.9" })),
}));
Expand Down Expand Up @@ -54,6 +65,7 @@ vi.mock("./policy.js", async (importOriginal) => {

import { cdpClick } from "./cdp.js";
import { dispatchMethod, dispatchWithMeta } from "./dispatch.js";
import { jevAct } from "./jev.js";
import { ensureAllowed, PolicyDenied, policy, tightenPolicy } from "./policy.js";

/** chrome stub with enough tabs API for the gate (tabs.get → host). */
Expand Down Expand Up @@ -163,6 +175,17 @@ describe("dispatchMethod routing (CDP)", () => {
stubTabs();
expect(await dispatchMethod("wait_for", { selector: "#btn" })).toEqual({ ok: true });
});
it("routes jev_observe to the handler", async () => {
stubTabs();
expect(await dispatchMethod("jev_observe", {})).toMatchObject({ url: "https://x.com/" });
});
it("routes jev_act to the handler with the tab pinned", async () => {
stubTabs();
const out = await dispatchWithMeta("jev_act", { op: "click", node: 3 });
expect(out.result).toEqual({ ok: true });
expect(jevAct).toHaveBeenCalledWith({ op: "click", node: 3, tabId: 1 });
expect(out.meta).toMatchObject({ tabId: 1, host: "x.com" });
});
});

describe("dispatchMethod routing (chrome.tabs)", () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/extension/src/lib/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
cdpWaitFor,
resolveTabId,
} from "./cdp.js";
import { jevAct, jevObserve } from "./jev.js";
import { readConsole, readNetwork } from "./monitor.js";
import {
cdpRaw,
Expand Down Expand Up @@ -196,6 +197,10 @@ async function runHandler(method: GatedMethod, gated: Record<string, unknown>):
return handleDialog(gated as Parameters<typeof handleDialog>[0]);
case "cdp":
return cdpRaw(gated as Parameters<typeof cdpRaw>[0]);
case "jev_observe":
return jevObserve(gated as Parameters<typeof jevObserve>[0]);
case "jev_act":
return jevAct(gated as Parameters<typeof jevAct>[0]);
default:
throw new Error(`unknown method: ${method}`);
}
Expand Down
Loading
Loading