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
6 changes: 6 additions & 0 deletions .changeset/step-commands-shadow-dom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@karnstack/reins": patch
"@reins/extension": patch
---

Step commands see web components and custom controls. `reins snapshot` now lists elements inside open shadow roots (a site's search button, a `Sort by` menu built as a web component) and names them the way `reins do` does (through `aria-labelledby`, labels, shadow roots and slots), and `--ref` reaches them in `click`, `type`, `fill`, `hover`, `select` and the rest. `--selector` stays light-DOM CSS. Each snapshot now re-issues its refs, so a ref no longer lands on an element hidden since the previous snapshot (the "element has zero size" failures on wizards and tab panels). A click on a control with no box of its own (a native radio or checkbox shrunk to 0×0 under a styled card, or a `display: contents` wrapper) presses its visible label, custom radio/switch it sits in, or first rendered child instead.
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,16 @@ jobs:
steps:
- uses: actions/checkout@v7.0.0
- uses: jdx/mise-action@v4.2.0
# Same Chrome as ci.yml, so the extension's real-browser suite runs the
# same way here as it did on the PR.
- uses: browser-actions/setup-chrome@v2.2.0
id: chrome
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
env:
REINS_TEST_CHROME: ${{ steps.chrome.outputs.chrome-path }}
- run: pnpm build

- name: Version or publish (changesets)
Expand Down
2 changes: 1 addition & 1 deletion docs/benchmarks/2026-09-reins-do.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ claude.ai connectors, so their definitions were in the context too).
**What the step-by-step arm measures.** It is Claude plus reins' step
commands, and some of its failures are the step commands': `reins snapshot`
does not read shadow roots (mdn, openlibrary) and reported "zero size" on
controls in fx-wizard and fx-settings. A better step toolkit, or a Claude
controls in fx-wizard and fx-settings. Both were fixed after this benchmark (0.6.1: the snapshot reads open shadow roots, and stale refs from an earlier snapshot no longer point at hidden elements). The fx-wizard page also opened with its modal already showing, for both arms, because a CSS rule overrode `hidden`; that fixture bug was fixed afterwards too. A better step toolkit, or a Claude
allowed to navigate by URL or use other tabs, would score higher and differ
in speed and cost.

Expand Down
1 change: 1 addition & 0 deletions packages/cli/scripts/bench/fixtures/wizard.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
button.secondary { background: #fff; color: #2f3e6e; }
button:disabled { opacity: .5; cursor: not-allowed; }
.backdrop { position: fixed; inset: 0; background: rgba(20, 24, 40, .55); display: flex; align-items: center; justify-content: center; }
.backdrop[hidden] { display: none; }
.modal { background: #fff; border-radius: 10px; width: 520px; max-width: calc(100% - 32px); box-shadow: 0 20px 60px rgba(0,0,0,.3); }
.modal header { background: #fff; color: #1a1a1a; border-bottom: 1px solid #dfe3e8; border-radius: 10px 10px 0 0; display: flex; justify-content: space-between; align-items: center; }
.modal header button { all: unset; cursor: pointer; font-size: 20px; color: #666; padding: 0 4px; }
Expand Down
70 changes: 62 additions & 8 deletions packages/extension/src/lib/actionability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,16 @@ export type ProbeResult = (
* 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.
* by id); with `locate`, whatever that function returns (a ref looked up
* through shadow roots). `selector` then only names it in messages.
*
* A target with no box of its own is pressed where it shows: a native radio
* or checkbox shrunk to 0×0 (or display:none) under a styled card is pressed
* through its visible <label> (which forwards the click to it) or the custom
* radio/switch/checkbox/option it sits in; a `display: contents` wrapper
* through its first rendered child. Every check — disabled, covered, moving —
* then applies to the element actually pressed, and "disabled" to the
* original target as well.
*
* A click target inside an `<a href target=_blank>` (or any link whose
* effective target, including a `<base target>`, names no frame of this page)
Expand All @@ -54,17 +63,61 @@ export async function actionPoint(
timeoutMs: number,
forClick: boolean,
node: number | null = null,
locate: (() => Element | null) | 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).
// the target is re-queried (frameworks may swap nodes while we wait).
const find = (): Element | null => {
if (node === null) return document.querySelector(selector);
if (node === null) {
const el = locate ? locate() : document.querySelector(selector);
return el?.isConnected ? el : null;
}
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;
};
// The parent as rendered (the flat tree, the path events take): slotted
// content's parent is its slot, a shadow root's is its host.
const up = (n: Node): Node | null =>
(n instanceof Element && n.assignedSlot) ||
n.parentNode ||
(n instanceof ShadowRoot ? n.host : null);
const sized = (e: Element) => {
const r = e.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
// The first box a display:contents element renders, looking through
// nested display:contents children, its shadow root and slots.
const firstBox = (e: Element): Element | null => {
const kids = e.shadowRoot
? [...e.shadowRoot.children]
: e instanceof HTMLSlotElement
? e.assignedElements({ flatten: true })
: [...e.children];
for (const k of kids) {
if (sized(k)) return k;
if (getComputedStyle(k).display === "contents") {
const inner = firstBox(k);
if (inner) return inner;
}
}
return null;
};
const CUSTOM =
'[role="radio"],[role="switch"],[role="checkbox"],[role="option"],[role="menuitemradio"],[role="menuitemcheckbox"]';
// Where to press for `t`: itself when it has a box, else what shows for it.
const standIn = (t: Element): Element => {
if (sized(t)) return t;
if (getComputedStyle(t).display === "contents") return firstBox(t) ?? t;
for (const l of (t as HTMLInputElement).labels ?? []) if (sized(l)) return l;
for (let p = up(t); p; p = up(p)) {
if (p instanceof Element && p.matches(CUSTOM)) return sized(p) ? p : t;
}
return t;
};
const disabled = (e: Element) => e.matches(":disabled") || !!e.closest('[aria-disabled="true"]');
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 @@ -158,8 +211,9 @@ 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 = find();
if (!el) {
const found = find();
const el = found && standIn(found);
if (!found || !el) {
if (first) return { error: "notfound" };
reason = "element was removed from the page";
} else {
Expand All @@ -170,7 +224,7 @@ export async function actionPoint(
const { r, still } = await settle(el);
if (r.width === 0 || r.height === 0) {
reason = "element has zero size (hidden?)";
} else if (forClick && (el.matches(":disabled") || el.closest('[aria-disabled="true"]'))) {
} else if (forClick && (disabled(el) || disabled(found))) {
reason = "element is disabled";
} else {
const x = r.x + r.width / 2;
Expand All @@ -182,7 +236,7 @@ export async function actionPoint(
hit = inner;
}
let n: Node | null = hit;
while (n && n !== el) n = n.parentNode ?? (n instanceof ShadowRoot ? n.host : null);
while (n && n !== el) n = up(n);
// Slotted content: when the top-most box at the point is light-DOM
// content a host slots into `el` (a web component's button whose
// caption arrives through <slot>), hit-testing retargets it to the
Expand Down Expand Up @@ -214,7 +268,7 @@ export async function actionPoint(
const now = find();
const path = e.composedPath();
state.result =
path.includes(el) || (now !== null && path.includes(now))
path.includes(el) || (now !== null && path.includes(standIn(now)))
? { state: "hit" }
: { state: "missed", by: describe(at instanceof Element ? at : null) };
};
Expand Down
41 changes: 40 additions & 1 deletion packages/extension/src/lib/cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
cdpOpenTab,
cdpType,
initDebugSessionListeners,
targetExpr,
withDebugger,
} from "./cdp.js";

Expand Down Expand Up @@ -383,7 +384,45 @@ describe("actionablePoint for a Jev node", () => {
const expr = (
sendCommand.mock.calls[0] as unknown as [unknown, string, { expression: string }]
)[2].expression;
expect(expr).toMatch(/\("node 12", 500, true, 12\)$/);
expect(expr).toMatch(/\("node 12", 500, true, 12, null\)$/);
});

it("passes a locator for a ref, which looks through open shadow roots", 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 actionablePoint(7, '[data-reins-ref="e3"]', "click", true, {
locate: targetExpr("e3"),
});
const expr = (
sendCommand.mock.calls[0] as unknown as [unknown, string, { expression: string }]
)[2].expression;
expect(expr).toContain(", null, () => (function findRef(ref) {");
expect(expr).toMatch(/\)\("e3"\)\)$/);
});
});

describe("targetExpr", () => {
it("a selector stays plain light-DOM CSS", () => {
expect(targetExpr("e1", "#go")).toBe('document.querySelector("#go")');
});

it("a ref is found by walking open shadow roots", () => {
const expr = targetExpr("e1");
expect(expr).toMatch(/^\(function findRef\(ref\) \{/);
expect(expr).toContain("shadowRoot");
expect(expr).toContain("data-reins-ref");
expect(expr).toMatch(/\("e1"\)$/);
});

it("needs one or the other", () => {
expect(() => targetExpr()).toThrow("requires a ref or selector");
});
});

Expand Down
67 changes: 33 additions & 34 deletions packages/extension/src/lib/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type {
import { actionPoint, type ProbeResult, readProbe } from "./actionability.js";
import { autofillGuard } from "./autofill-guard.js";
import { isMonitored } from "./monitor.js";
import { findRef, pageDom } from "./page-dom.js";
import { type StepSnapshot, stepSnapshot } from "./snapshot.js";

const PROTOCOL = "1.3";

Expand Down Expand Up @@ -293,45 +295,37 @@ export async function cdpNavigate(params: NavigateParams): Promise<{ url: string
});
}

/** Tag interactive/labelled elements with data-reins-ref and return a compact tree + refs. */
const SNAPSHOT_EXPR = `(() => {
const refs = [];
let n = 0;
const sel = "a,button,input,textarea,select,[role],h1,h2,h3,[contenteditable=true]";
for (const el of document.querySelectorAll(sel)) {
if (!(el instanceof HTMLElement) || el.offsetParent === null) continue;
const ref = "e" + (++n);
el.setAttribute("data-reins-ref", ref);
const role = el.getAttribute("role") || el.tagName.toLowerCase();
const name = (el.getAttribute("aria-label") || el.textContent || el.getAttribute("placeholder") || "").trim().slice(0, 80);
refs.push({ ref, role, name });
}
const text = refs.map(r => r.ref + ": " + r.role + " " + JSON.stringify(r.name)).join("\\n");
return { content: text, refs };
})()`;

export async function cdpSnapshot(
params: SnapshotParams,
): Promise<{ content: string; refs: Array<{ ref: string; role?: string; name?: string }> }> {
export async function cdpSnapshot(params: SnapshotParams): Promise<StepSnapshot> {
const tabId = await resolveTabId(params.tabId);
return withDebugger(tabId, async () => {
const { result } = await send<{
result: {
value: { content: string; refs: Array<{ ref: string; role?: string; name?: string }> };
};
}>(tabId, "Runtime.evaluate", { expression: SNAPSHOT_EXPR, returnByValue: true });
const { result } = await send<{ result: { value: StepSnapshot } }>(tabId, "Runtime.evaluate", {
expression: `(${stepSnapshot})(${pageDom})`,
returnByValue: true,
});
const value = result.value;
const content = params.maxChars ? value.content.slice(0, params.maxChars) : value.content;
return { content, refs: value.refs };
});
}

/** How a command's target is named in its messages. */
export function selectorFor(ref?: string, selector?: string): string {
if (selector) return selector;
if (ref) return `[data-reins-ref="${ref}"]`;
throw new Error("requires a ref or selector");
}

/**
* A page expression evaluating to the command's target, or null. A selector
* is plain CSS on the light DOM (`document.querySelector`); a ref is found
* through open shadow roots, where `reins snapshot` tags elements too.
*/
export function targetExpr(ref?: string, selector?: string): string {
if (selector) return `document.querySelector(${JSON.stringify(selector)})`;
if (ref) return `(${findRef})(${JSON.stringify(ref)})`;
throw new Error("requires a ref or selector");
}

/** How long click/hover wait for their target to become actionable. */
const ACTION_TIMEOUT_MS = 5000;

Expand Down Expand Up @@ -361,20 +355,22 @@ export async function ensureVisible(tabId: number): Promise<void> {

/**
* 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.
* `opts.node` targets a Jev node id, and `opts.locate` a page expression
* (`targetExpr`), 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 } = {},
opts: { node?: number; locate?: string; timeoutMs?: number } = {},
): Promise<{ x: number; y: number }> {
const locate = opts.locate === undefined ? "null" : `() => ${opts.locate}`;
const { result } = await send<{
result: { value: { x: number; y: number } | { error: string } };
}>(tabId, "Runtime.evaluate", {
expression: `(${actionPoint})(${JSON.stringify(css)}, ${opts.timeoutMs ?? ACTION_TIMEOUT_MS}, ${forClick}, ${opts.node ?? null})`,
expression: `(${actionPoint})(${JSON.stringify(css)}, ${opts.timeoutMs ?? ACTION_TIMEOUT_MS}, ${forClick}, ${opts.node ?? null}, ${locate})`,
returnByValue: true,
awaitPromise: true,
});
Expand Down Expand Up @@ -468,7 +464,9 @@ export async function cdpClick(params: ClickParams): Promise<ClickResult> {
const clickCount = params.clickCount ?? 1;
return drivePage(tabId, async () => {
await ensureVisible(tabId);
const { x, y } = await actionablePoint(tabId, css, "click", true);
const { x, y } = await actionablePoint(tabId, css, "click", true, {
locate: targetExpr(params.ref, params.selector),
});
const { newTabUrl } = await pressAt(tabId, x, y, css, button, clickCount);
if (newTabUrl === undefined) return { ok: true };
// The user sees the new tab as after a normal click, without Chrome
Expand All @@ -484,7 +482,7 @@ export async function cdpType(params: TypeParams): Promise<{ ok: true }> {
return drivePage(tabId, async () => {
await ensureVisible(tabId);
const { result } = await send<{ result: { value: boolean } }>(tabId, "Runtime.evaluate", {
expression: `(() => { const el = document.querySelector(${JSON.stringify(css)}); if (!el) return false; el.focus(); return true; })()`,
expression: `(() => { const el = ${targetExpr(params.ref, params.selector)}; if (!el) return false; el.focus(); return true; })()`,
returnByValue: true,
});
if (!result.value) throw new Error(`element not found: ${css}`);
Expand Down Expand Up @@ -546,12 +544,13 @@ export async function cdpWaitFor(params: WaitForParams): Promise<{ ok: true }> {
const state = params.state ?? "visible";
const timeoutMs = params.timeoutMs ?? 5000;

const target = targetExpr(params.ref, params.selector);
let checkExpr: string;
if (state === "present") {
checkExpr = `!!document.querySelector(${JSON.stringify(css)})`;
checkExpr = `!!${target}`;
} else if (state === "visible") {
checkExpr = `(() => {
const el = document.querySelector(${JSON.stringify(css)});
const el = ${target};
if (!el) return false;
const r = el.getBoundingClientRect();
const s = getComputedStyle(el);
Expand All @@ -560,7 +559,7 @@ export async function cdpWaitFor(params: WaitForParams): Promise<{ ok: true }> {
} else {
// hidden: element missing OR not visible
checkExpr = `(() => {
const el = document.querySelector(${JSON.stringify(css)});
const el = ${target};
if (!el) return true;
const r = el.getBoundingClientRect();
const s = getComputedStyle(el);
Expand Down
Loading
Loading