8000 ax-grep/src/browser.ts at refs/tags/v0.1.2 · hmmhmmhm/ax-grep · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
755 lines (659 loc) · 25.2 KB

browser.ts

File metadata and controls

755 lines (659 loc) · 25.2 KB
for (const option of Array.from(element.options)) {
node.children.push({
id: nextId(context),
tag: "option",
role: "option",
name: normalizeText(option.textContent ?? "", context.options.maxTextLength),
value: option.value,
state: { selected: option.selected, disabled: option.disabled },
interactive: false,
focusable: false,
selector: getCssPath(option),
xpath: getXPath(option),
children: [],
});
}
}
}
function isCustomElement(element: Element): boolean {
return element.tagName.includes("-");
}
function appendShadowChildren(element: Element, node: SemanticNode, context: WalkContext): void {
const shadowRoot = element.shadowRoot;
if (!shadowRoot) return;
for (const child of Array.from(shadowRoot.children)) {
const semanticChild = walkElement(child, context);
if (semanticChild) node.children.push(semanticChild);
}
}
function appendFrameChildren(element: Element, node: SemanticNode, context: WalkContext): void {
if (!(element instanceof HTMLIFrameElement)) return;
try {
const frameDocument = element.contentDocument;
if (!frameDocument?.body) {
node.children.push(unavailableNode(context, "iframe", "iframe document unavailable"));
return;
}
const previousDocument = context.rootDocument;
context.rootDocument = frameDocument;
const child = walkElement(frameDocument.body, context);
context.rootDocument = previousDocument;
if (child) node.children.push(child);
} catch {
node.children.push(unavailableNode(context, "iframe", "cross-origin iframe"));
}
}
function unavailableNode(context: WalkContext, tag: string, reason: string): SemanticNode {
return {
id: nextId(context),
tag,
role: null,
name: "",
interactive: false,
focusable: false,
unavailableReason: reason,
children: [],
};
}
function containerNode(context: WalkContext, tag: string, children: SemanticNode[]): SemanticNode {
return {
id: nextId(context),
tag,
role: null,
name: "",
interactive: false,
focusable: false,
children,
};
}
function getValue(element: Element): string {
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) {
return element.value;
}
return normalizeText(element.getAttribute("aria-valuetext") ?? element.getAttribute("aria-valuenow") ?? "", 80);
}
function getDirectText(element: Element, maxLength: number): string {
return normalizeText(
Array.from(element.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent ?? "")
.join(" "),
maxLength,
);
}
function getVisibleText(element: Element, maxLength: number): string {
const parts: string[] = [];
function visit(node: Node): void {
if (node.nodeType === Node.TEXT_NODE) {
parts.push(node.textContent ?? "");
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const childElement = node as Element;
if (isHidden(childElement)) return;
for (const child of Array.from(childElement.childNodes)) visit(child);
}
visit(element);
return normalizeText(parts.join(" "), maxLength);
}
function getAttributes(element: Element): Record<string, string> {
const attributes: Record<string, string> = {};
for (const attribute of Array.from(element.attributes)) {
if (
attribute.name === "id" ||
attribute.name === "href" ||
attribute.name === "type" ||
attribute.name === "role" ||
attribute.name === "alt" ||
attribute.name === "title" ||
attribute.name.startsWith("aria-") ||
attribute.name.startsWith("data-")
) {
attributes[attribute.name] = attribute.value;
}
}
return attributes;
}
function getBounds(element: Element) {
const rect = element.getBoundingClientRect();
return {
x: round(rect.x),
y: round(rect.y),
width: round(rect.width),
height: round(rect.height),
};
}
function getCssPath(element: Element): string {
if (element.id) return `#${cssEscape(element.id)}`;
const segments: string[] = [];
let current: Element | null = element;
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
const elementAtLevel: Element = current;
const tag = elementAtLevel.tagName.toLowerCase();
const parent: Element | null = elementAtLevel.parentElement;
if (!parent) {
segments.unshift(tag);
break;
}
const siblings = Array.from(parent.children).filter((child) => child.tagName === elementAtLevel.tagName);
const index = siblings.indexOf(elementAtLevel) + 1;
segments.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${index})` : tag);
current = parent;
}
return segments.join(" > ");
}
function getXPath(element: Element): string {
const segments: string[] = [];
let current: Element | null = element;
while (current && current.nodeType === Node.ELEMENT_NODE) {
const elementAtLevel: Element = current;
const tag = elementAtLevel.tagName.toLowerCase();
const parent: Element | null = elementAtLevel.parentElement;
if (!parent) {
segments.unshift(`/${tag}[1]`);
break;
}
const sameTag = Array.from(parent.children).filter((child) => child.tagName === elementAtLevel.tagName);
segments.unshift(`/${tag}[${sameTag.indexOf(elementAtLevel) + 1}]`);
current = parent;
}
return segments.join("");
}
function textFromIds(ids: string, rootDocument: Document): string {
return normalizeText(
ids
.split(/\s+/)
.map((id) => {
const element = rootDocument.getElementById(id);
return element ? getVisibleText(element, 240) : "";
})
.filter(Boolean)
.join(" "),
240,
);
}
function normalizeText(value: string, maxLength: number): string {
const normalized = value.replace(/\s+/g, " ").trim();
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1)}…` : normalized;
}
function firstToken(value: string | null): string | null {
return value?.trim().split(/\s+/)[0] || null;
}
function hasExplicitNameSource(element: Element): boolean {
return Boolean(
element.getAttribute("aria-label") ||
element.getAttribute("aria-labelledby") ||
element.getAttribute("title"),
);
}
function hasEmptyAlt(element: Element): boolean {
return element.hasAttribute("alt") && element.getAttribute("alt") === "";
}
function isButtonLikeInput(input: HTMLInputElement): boolean {
return ["button", "image", "reset", "submit"].includes((input.getAttribute("type") || "").toLowerCase());
}
function inputFallbackName(input: HTMLInputElement): string {
const type = (input.getAttribute("type") || "").toLowerCase();
if (type === "submit") return "Submit";
if (type === "reset") return "Reset";
return "";
}
function ariaBoolean(value: string | null): boolean | undefined {
if (value === "true") return true;
if (value === "false") return false;
return undefined;
}
function ariaBooleanOrMixed(value: string | null): boolean | "mixed" | undefined {
if (value === "mixed") return "mixed";
return ariaBoolean(value);
}
function ariaNumber(value: string | null): number | undefined {
if (value === null || value.trim() === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function formatState(state: SemanticNodeState | undefined): string {
if (!state) return "";
const entries = Object.entries(state).filter(([, value]) => value !== undefined);
return entries.length > 0
? ` [${entries.map(([key, value]) => `${key}=${String(value)}`).join(" ")}]`
: "";
}
function nextId(context: WalkContext): string {
const id = `n${context.nextId}`;
context.nextId += 1;
return id;
}
function round(value: number): number {
return Math.round(value * 100) / 100;
}
function cssEscape(value: string): string {
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(value);
}
return value.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`);
}
0