Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/core/lib/v3/handlers/extractHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export class ExtractHandler {
page,
selector,
ignoreSelectors,
selectAll,
timeout,
model,
} = params;
Expand All @@ -135,6 +136,7 @@ export class ExtractHandler {
experimental: this.experimental,
focusSelector: focusSelector || undefined,
ignoreSelectors,
selectAll,
});
ensureTimeRemaining();

Expand All @@ -157,6 +159,7 @@ export class ExtractHandler {
experimental: this.experimental,
focusSelector: focusSelector,
ignoreSelectors,
selectAll,
});

v3Logger({
Expand Down
118 changes: 61 additions & 57 deletions packages/core/lib/v3/handlers/observeHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class ObserveHandler {
timeout,
selector,
ignoreSelectors,
selectAll,
model,
variables,
} = params;
Expand Down Expand Up @@ -104,6 +105,7 @@ export class ObserveHandler {
experimental: this.experimental,
focusSelector: focusSelector || undefined,
ignoreSelectors,
selectAll,
});

const combinedTree = snapshot.combinedTree;
Expand Down Expand Up @@ -149,39 +151,52 @@ export class ObserveHandler {
// Map elementIds -> selectors via combinedXpathMap
const elementsWithSelectors = (
await Promise.all(
observationResponse.elements.map(async (element) => {
const { elementId, ...rest } = element; // rest may or may not have method/arguments
if (typeof elementId === "string" && elementId.includes("-")) {
const lookUpIndex = elementId as EncodedId;
const xpath = combinedXpathMap[lookUpIndex];
const trimmedXpath = trimTrailingTextNode(xpath);
if (!trimmedXpath) return undefined;

// For dragAndDrop, convert element ID in arguments to xpath (target element)
let resolvedArgs = rest.arguments;
if (
rest.method === "dragAndDrop" &&
Array.isArray(rest.arguments) &&
rest.arguments.length > 0
) {
const targetArg = rest.arguments[0];
// Check if argument looks like an element ID (e.g., "1-67")
observationResponse.elements.map(
async (element: (typeof observationResponse.elements)[number]) => {
const { elementId, ...rest } = element; // rest may or may not have method/arguments
if (typeof elementId === "string" && elementId.includes("-")) {
const lookUpIndex = elementId as EncodedId;
const xpath = combinedXpathMap[lookUpIndex];
const trimmedXpath = trimTrailingTextNode(xpath);
if (!trimmedXpath) return undefined;

// For dragAndDrop, convert element ID in arguments to xpath (target element)
let resolvedArgs = rest.arguments;
if (
typeof targetArg === "string" &&
/^\d+-\d+$/.test(targetArg)
rest.method === "dragAndDrop" &&
Array.isArray(rest.arguments) &&
rest.arguments.length > 0
) {
const argXpath = combinedXpathMap[targetArg as EncodedId];
const trimmedArgXpath = trimTrailingTextNode(argXpath);
if (trimmedArgXpath) {
resolvedArgs = [
`xpath=${trimmedArgXpath}`,
...rest.arguments.slice(1),
];
const targetArg = rest.arguments[0];
// Check if argument looks like an element ID (e.g., "1-67")
if (
typeof targetArg === "string" &&
/^\d+-\d+$/.test(targetArg)
) {
const argXpath = combinedXpathMap[targetArg as EncodedId];
const trimmedArgXpath = trimTrailingTextNode(argXpath);
if (trimmedArgXpath) {
resolvedArgs = [
`xpath=${trimmedArgXpath}`,
...rest.arguments.slice(1),
];
} else {
// Target element lookup failed, filter out this action
v3Logger({
category: "observation",
message: "dragAndDrop target element lookup failed",
level: 0,
auxiliary: {
targetElementId: { value: targetArg, type: "string" },
sourceElementId: { value: elementId, type: "string" },
},
});
return undefined;
}
} else {
// Target element lookup failed, filter out this action
v3Logger({
category: "observation",
message: "dragAndDrop target element lookup failed",
message: "dragAndDrop target element invalid ID format",
level: 0,
auxiliary: {
targetElementId: { value: targetArg, type: "string" },
Expand All @@ -190,39 +205,28 @@ export class ObserveHandler {
});
return undefined;
}
} else {
v3Logger({
category: "observation",
message: "dragAndDrop target element invalid ID format",
level: 0,
auxiliary: {
targetElementId: { value: targetArg, type: "string" },
sourceElementId: { value: elementId, type: "string" },
},
});
return undefined;
}
}

return {
...rest,
arguments: resolvedArgs,
selector: `xpath=${trimmedXpath}`,
} as {
description: string;
method?: string;
arguments?: string[];
selector: string;
};
}
// shadow-root fallback:
return {
...rest,
arguments: resolvedArgs,
selector: `xpath=${trimmedXpath}`,
} as {
description: string;
method?: string;
arguments?: string[];
selector: string;
description: "an element inside a shadow DOM",
method: "not-supported",
arguments: [],
selector: "not-supported",
};
}
// shadow-root fallback:
return {
description: "an element inside a shadow DOM",
method: "not-supported",
arguments: [],
selector: "not-supported",
};
}),
},
),
)
).filter(<T>(e: T | undefined): e is T => e !== undefined);

Expand Down
2 changes: 2 additions & 0 deletions packages/core/lib/v3/types/private/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ExtractHandlerParams<T extends StagehandZodSchema> {
timeout?: number;
selector?: string;
ignoreSelectors?: string[];
selectAll?: boolean;
page: Page;
}

Expand All @@ -28,6 +29,7 @@ export interface ObserveHandlerParams {
timeout?: number;
selector?: string;
ignoreSelectors?: string[];
selectAll?: boolean;
page: Page;
}

Expand Down
3 changes: 3 additions & 0 deletions packages/core/lib/v3/types/private/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export type SnapshotOptions = {
* Supports XPath (prefixed with `xpath=` or starting with `/`) and CSS with iframe hops via `>>`.
*/
focusSelector?: string;
/** When true, scope to every element matched by `focusSelector` instead of only the first match. */
selectAll?: boolean;
/**
* Exclude matching elements and their descendants from the captured snapshot.
* Each selector may be XPath (prefixed with `xpath=` or starting with `/`) or CSS.
Expand Down Expand Up @@ -112,6 +114,7 @@ export type A11yNode = {
export type A11yOptions = {
focusSelector?: string;
isIgnoredBackendNode?: (backendNodeId: number) => boolean;
selectAll?: boolean;
experimental: boolean;
tagNameMap: Record<string, string>;
scrollableMap: Record<string, boolean>;
Expand Down
10 changes: 10 additions & 0 deletions packages/core/lib/v3/types/public/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,11 @@ export const ExtractOptionsSchema = z
"Selectors for elements and subtrees that should be excluded from extraction",
example: ["nav", ".cookie-banner", "#sidebar-ads"],
}),
selectAll: z.boolean().optional().meta({
description:
"When true, apply selector scoping to all matching elements instead of only the first match",
example: true,
}),
})
.optional()
.meta({ id: "ExtractOptions" });
Expand Down Expand Up @@ -597,6 +602,11 @@ export const ObserveOptionsSchema = z
"Selectors for elements and subtrees that should be excluded from observation",
example: ["nav", ".cookie-banner", "#sidebar-ads"],
}),
selectAll: z.boolean().optional().meta({
description:
"When true, apply selector scoping to all matching elements instead of only the first match",
example: true,
}),
})
.optional()
.meta({ id: "ObserveOptions" });
Expand Down
2 changes: 2 additions & 0 deletions packages/core/lib/v3/types/public/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface ExtractOptions {
timeout?: number;
selector?: string;
ignoreSelectors?: string[];
selectAll?: boolean;
page?: PlaywrightPage | PuppeteerPage | PatchrightPage | Page;
/**
* Override the instance-level serverCache setting for this request.
Expand All @@ -78,6 +79,7 @@ export interface ObserveOptions {
timeout?: number;
selector?: string;
ignoreSelectors?: string[];
selectAll?: boolean;
page?: PlaywrightPage | PuppeteerPage | PatchrightPage | Page;
/**
* Override the instance-level serverCache setting for this request.
Expand Down
95 changes: 80 additions & 15 deletions packages/core/lib/v3/understudy/a11y/snapshot/a11yTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type {
} from "../../../types/private/snapshot.js";
import {
resolveObjectIdForCss,
resolveObjectIdsForCss,
resolveObjectIdsForXPath,
resolveObjectIdForXPath,
} from "./focusSelectors.js";
import { formatTreeLine, normaliseSpaces } from "./treeFormatUtils.js";
Expand Down Expand Up @@ -48,34 +50,97 @@ export async function a11yForFrame(
if (!sel) return nodes;
try {
const looksLikeXPath = /^xpath=/i.test(sel) || sel.startsWith("/");
const objectId = looksLikeXPath
? await resolveObjectIdForXPath(session, sel, frameId)
: await resolveObjectIdForCss(session, sel, frameId);
if (!objectId) return nodes;
const desc = await session.send<{ node?: { backendNodeId?: number } }>(
"DOM.describeNode",
{ objectId },
let objectIds: string[] = [];

if (opts.selectAll) {
if (looksLikeXPath) {
objectIds = await resolveObjectIdsForXPath(session, sel, frameId);
} else {
objectIds = await resolveObjectIdsForCss(session, sel, frameId);
}
} else {
if (looksLikeXPath) {
const objId = await resolveObjectIdForXPath(session, sel, frameId);
objectIds = objId ? [objId] : [];
} else {
const objId = await resolveObjectIdForCss(session, sel, frameId);
objectIds = objId ? [objId] : [];
}
}

if (objectIds.length === 0) return nodes;

const targetBackendNodeIds = new Set<number>();

for (const objectId of objectIds) {
try {
const desc = await session.send<{
node?: { backendNodeId?: number };
}>("DOM.describeNode", { objectId });

const backendNodeId = desc.node?.backendNodeId;
if (typeof backendNodeId === "number") {
targetBackendNodeIds.add(backendNodeId);
}
} catch {
// Keep any successfully resolved matches instead of falling back to an
// unscoped tree because one candidate object went stale.
} finally {
await session
.send("Runtime.releaseObject", { objectId })
.catch(() => {});
}
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

if (targetBackendNodeIds.size === 0) return nodes;

const nodeById = new Map(nodes.map((node) => [node.nodeId, node]));

const matchedTargets = nodes.filter(
(node) =>
typeof node.backendDOMNodeId === "number" &&
targetBackendNodeIds.has(node.backendDOMNodeId),
);
const be = desc.node?.backendNodeId;
if (typeof be !== "number") return nodes;
const target = nodes.find((n) => n.backendDOMNodeId === be);
if (!target) return nodes;

if (matchedTargets.length === 0) return nodes;

const topLevelTargets = matchedTargets.filter((target) => {
let parentId = target.parentId;
while (parentId) {
const parent = nodeById.get(parentId);
if (!parent) break;
if (
typeof parent.backendDOMNodeId === "number" &&
targetBackendNodeIds.has(parent.backendDOMNodeId)
) {
return false;
}
parentId = parent.parentId;
}
return true;
});

scopeApplied = true;
const keep = new Set<string>([target.nodeId]);
const queue: Protocol.Accessibility.AXNode[] = [target];
const keep = new Set(topLevelTargets.map((target) => target.nodeId));
const queue = [...topLevelTargets];

while (queue.length) {
const cur = queue.shift()!;
for (const id of cur.childIds ?? []) {
if (keep.has(id)) continue;
keep.add(id);
const child = nodes.find((n) => n.nodeId === id);
const child = nodeById.get(id);
if (child) queue.push(child);
}
}

const topLevelTargetIds = new Set(
topLevelTargets.map((target) => target.nodeId),
);
return nodes
.filter((n) => keep.has(n.nodeId))
.map((n) =>
n.nodeId === target.nodeId ? { ...n, parentId: undefined } : n,
topLevelTargetIds.has(n.nodeId) ? { ...n, parentId: undefined } : n,
);
} catch {
return nodes;
Expand Down
1 change: 1 addition & 0 deletions packages/core/lib/v3/understudy/a11y/snapshot/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export async function tryScopedSnapshot(
ownerSessionIndexForFrame(page, targetFrameId, sessionToIndex),
exclusionIntervalsByFrame,
),
selectAll: options?.selectAll,
tagNameMap,
experimental: options?.experimental ?? false,
scrollableMap,
Expand Down
Loading
Loading