mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-18 23:45:49 +00:00
Adds the explicit browser:screenshot action that writes JPEG/PNG files for vision_load, extends agent-callable Browser input actions, and documents the explicit vision workflow. Adds the browser-forms on-demand skill and regression coverage for dispatch, runtime screenshot files, ref point resolution, upload path normalization, prompt discoverability, and label-wrapped form controls surfaced by the chat-driven E2E.
3963 lines
119 KiB
JavaScript
3963 lines
119 KiB
JavaScript
(() => {
|
|
const GLOBAL_KEY = "__spaceBrowserPageContent__";
|
|
const DOM_HELPER_KEY = "__spaceBrowserDomHelper__";
|
|
const VERSION = "11";
|
|
const BLOCK_TAGS = new Set([
|
|
"ADDRESS",
|
|
"ARTICLE",
|
|
"ASIDE",
|
|
"BLOCKQUOTE",
|
|
"BODY",
|
|
"DETAILS",
|
|
"DIV",
|
|
"DL",
|
|
"FIELDSET",
|
|
"FIGCAPTION",
|
|
"FIGURE",
|
|
"FOOTER",
|
|
"FORM",
|
|
"H1",
|
|
"H2",
|
|
"H3",
|
|
"H4",
|
|
"H5",
|
|
"H6",
|
|
"HEADER",
|
|
"HR",
|
|
"HTML",
|
|
"LI",
|
|
"MAIN",
|
|
"NAV",
|
|
"OL",
|
|
"P",
|
|
"PRE",
|
|
"SECTION",
|
|
"TABLE",
|
|
"TBODY",
|
|
"TD",
|
|
"TFOOT",
|
|
"TH",
|
|
"THEAD",
|
|
"TR",
|
|
"UL"
|
|
]);
|
|
const SKIP_TAGS = new Set([
|
|
"HEAD",
|
|
"LINK",
|
|
"META",
|
|
"NOSCRIPT",
|
|
"SCRIPT",
|
|
"STYLE",
|
|
"TEMPLATE"
|
|
]);
|
|
const INTERACTIVE_ROLES = new Set([
|
|
"button",
|
|
"checkbox",
|
|
"combobox",
|
|
"link",
|
|
"menuitem",
|
|
"menuitemcheckbox",
|
|
"menuitemradio",
|
|
"option",
|
|
"radio",
|
|
"searchbox",
|
|
"slider",
|
|
"spinbutton",
|
|
"switch",
|
|
"tab",
|
|
"textbox"
|
|
]);
|
|
const INTERACTIVE_EVENT_NAMES = new Set([
|
|
"auxclick",
|
|
"change",
|
|
"click",
|
|
"contextmenu",
|
|
"dblclick",
|
|
"input",
|
|
"keydown",
|
|
"keypress",
|
|
"keyup",
|
|
"mousedown",
|
|
"mouseup",
|
|
"pointerdown",
|
|
"pointerup",
|
|
"submit",
|
|
"touchend",
|
|
"touchstart"
|
|
]);
|
|
const INTERACTIVE_EVENT_PROPERTIES = [...INTERACTIVE_EVENT_NAMES]
|
|
.map((eventName) => `on${eventName}`);
|
|
|
|
if (globalThis[GLOBAL_KEY]?.version === VERSION) {
|
|
return;
|
|
}
|
|
|
|
const state = {
|
|
backend: "live",
|
|
captureId: 0,
|
|
capturedAt: 0,
|
|
captureOptions: {
|
|
includeLabelQuotes: false,
|
|
includeLinkUrls: false,
|
|
includeSemanticTags: true,
|
|
includeStateTags: true,
|
|
includeListIndentation: true,
|
|
includeListMarkers: false
|
|
},
|
|
entries: new Map()
|
|
};
|
|
|
|
function isElementNode(value) {
|
|
return Boolean(value && value.nodeType === 1);
|
|
}
|
|
|
|
function isTextNode(value) {
|
|
return Boolean(value && value.nodeType === 3);
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
return String(value ?? "")
|
|
.replace(/\s+/gu, " ")
|
|
.trim();
|
|
}
|
|
|
|
function looksLikeSerializedHtmlText(value) {
|
|
const normalizedValue = normalizeText(value);
|
|
if (!normalizedValue || !normalizedValue.includes("<") || !normalizedValue.includes(">")) {
|
|
return false;
|
|
}
|
|
|
|
if (/<!(?:doctype|--)\b/iu.test(normalizedValue)) {
|
|
return true;
|
|
}
|
|
|
|
if (/<\/?(?:style|script)\b[\s\S]*?>/iu.test(normalizedValue)) {
|
|
return true;
|
|
}
|
|
|
|
const tagMatches = normalizedValue.match(/<\/?[a-z][^>]*>/giu) || [];
|
|
return tagMatches.length >= 3 && normalizedValue.length >= 80;
|
|
}
|
|
|
|
function looksLikeBrowserHelperMarkupText(value) {
|
|
const normalizedValue = normalizeText(value);
|
|
if (!normalizedValue) {
|
|
return false;
|
|
}
|
|
|
|
return /space-browser-(?:frame-document|shadow-root)/iu.test(normalizedValue)
|
|
|| /data-space-browser-(?:frame|node|status|frame-url|frame-title|frame-src)/iu.test(normalizedValue);
|
|
}
|
|
|
|
function looksLikeMinifiedScriptText(value) {
|
|
const normalizedValue = normalizeText(value);
|
|
if (!normalizedValue || normalizedValue.length < 400) {
|
|
return false;
|
|
}
|
|
|
|
const jsSignals = [
|
|
/\bfunction\b/u,
|
|
/\breturn\b/u,
|
|
/\bvar\b/u,
|
|
/\bnew\b/u,
|
|
/\bcase\b/u,
|
|
/\bswitch\b/u,
|
|
/\bwhile\b/u,
|
|
/\bfor\b/u,
|
|
/\b(?:localStorage|postMessage|document\.|window\.|parent\.)/u,
|
|
/\bthis\./u,
|
|
/(?:&&|\|\||>>>|!==|===)/u
|
|
].reduce((count, pattern) => count + (pattern.test(normalizedValue) ? 1 : 0), 0);
|
|
|
|
if (jsSignals < 4) {
|
|
return false;
|
|
}
|
|
|
|
const punctuationCount = (normalizedValue.match(/[{}[\]();=<>\\]/gu) || []).length;
|
|
return punctuationCount / normalizedValue.length >= 0.12;
|
|
}
|
|
|
|
function shouldDropReadableText(value) {
|
|
const normalizedValue = normalizeText(value);
|
|
if (!normalizedValue) {
|
|
return true;
|
|
}
|
|
|
|
return looksLikeBrowserHelperMarkupText(normalizedValue)
|
|
|| looksLikeSerializedHtmlText(normalizedValue)
|
|
|| looksLikeMinifiedScriptText(normalizedValue);
|
|
}
|
|
|
|
function normalizeAttributeText(value) {
|
|
return normalizeText(value).slice(0, 160);
|
|
}
|
|
|
|
function escapeMarkdownText(value) {
|
|
return String(value ?? "").replace(/([\\`*_{}\[\]()#+\-!|>])/gu, "\\$1");
|
|
}
|
|
|
|
function quoteText(value) {
|
|
return JSON.stringify(String(value ?? ""));
|
|
}
|
|
|
|
function truncateText(value, maxLength = 120) {
|
|
const normalizedValue = normalizeText(value);
|
|
if (normalizedValue.length <= maxLength) {
|
|
return normalizedValue;
|
|
}
|
|
|
|
return `${normalizedValue.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`;
|
|
}
|
|
|
|
function delayMs(timeoutMs) {
|
|
return new Promise((resolve) => {
|
|
globalThis.setTimeout(resolve, Math.max(0, Number(timeoutMs) || 0));
|
|
});
|
|
}
|
|
|
|
function parseCssColor(value) {
|
|
const normalizedValue = normalizeText(value);
|
|
if (!normalizedValue || normalizedValue === "transparent") {
|
|
return null;
|
|
}
|
|
|
|
const rgbMatch = normalizedValue.match(/^rgba?\(([^)]+)\)$/iu);
|
|
if (rgbMatch) {
|
|
const parts = rgbMatch[1]
|
|
.split(",")
|
|
.map((part) => Number.parseFloat(String(part || "").trim()))
|
|
.filter((part) => Number.isFinite(part));
|
|
if (parts.length >= 3) {
|
|
return {
|
|
r: Math.max(0, Math.min(255, parts[0])),
|
|
g: Math.max(0, Math.min(255, parts[1])),
|
|
b: Math.max(0, Math.min(255, parts[2])),
|
|
a: parts.length >= 4 ? Math.max(0, Math.min(1, parts[3])) : 1
|
|
};
|
|
}
|
|
}
|
|
|
|
const hexMatch = normalizedValue.match(/^#([\da-f]{3,8})$/iu);
|
|
if (!hexMatch) {
|
|
return null;
|
|
}
|
|
|
|
const hex = hexMatch[1];
|
|
if (hex.length === 3 || hex.length === 4) {
|
|
const [r, g, b, a = "f"] = hex.split("");
|
|
return {
|
|
r: Number.parseInt(`${r}${r}`, 16),
|
|
g: Number.parseInt(`${g}${g}`, 16),
|
|
b: Number.parseInt(`${b}${b}`, 16),
|
|
a: Number.parseInt(`${a}${a}`, 16) / 255
|
|
};
|
|
}
|
|
|
|
if (hex.length === 6 || hex.length === 8) {
|
|
return {
|
|
r: Number.parseInt(hex.slice(0, 2), 16),
|
|
g: Number.parseInt(hex.slice(2, 4), 16),
|
|
b: Number.parseInt(hex.slice(4, 6), 16),
|
|
a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function rgbToHsl(color) {
|
|
if (!color) {
|
|
return null;
|
|
}
|
|
|
|
const r = color.r / 255;
|
|
const g = color.g / 255;
|
|
const b = color.b / 255;
|
|
const max = Math.max(r, g, b);
|
|
const min = Math.min(r, g, b);
|
|
const delta = max - min;
|
|
const lightness = (max + min) / 2;
|
|
let hue = 0;
|
|
let saturation = 0;
|
|
|
|
if (delta > 0) {
|
|
saturation = delta / (1 - Math.abs(2 * lightness - 1));
|
|
if (max === r) {
|
|
hue = 60 * (((g - b) / delta) % 6);
|
|
} else if (max === g) {
|
|
hue = 60 * (((b - r) / delta) + 2);
|
|
} else {
|
|
hue = 60 * (((r - g) / delta) + 4);
|
|
}
|
|
}
|
|
|
|
if (hue < 0) {
|
|
hue += 360;
|
|
}
|
|
|
|
return {
|
|
hue,
|
|
lightness,
|
|
saturation
|
|
};
|
|
}
|
|
|
|
function isTrustedHtmlRequirementError(error) {
|
|
return /TrustedHTML/iu.test(String(error?.message || error || ""));
|
|
}
|
|
|
|
function joinBlocks(blocks) {
|
|
return blocks
|
|
.map((block) => String(block || "").trim())
|
|
.filter(Boolean)
|
|
.join("\n\n")
|
|
.trim();
|
|
}
|
|
|
|
function cleanReadableMarkdown(value) {
|
|
const lines = String(value || "")
|
|
.replace(/<style\\?>[\s\S]*?<\/style\\?>/giu, "")
|
|
.replace(/<script\\?>[\s\S]*?<\/script\\?>/giu, "")
|
|
.replace(/<space\\-browser\\-(?:frame\\-document|shadow\\-root)\b[\s\S]*?<\/space\\-browser\\-(?:frame\\-document|shadow\\-root)>/giu, "")
|
|
.split("\n");
|
|
|
|
const filteredLines = [];
|
|
let insideCodeFence = false;
|
|
|
|
lines.forEach((line) => {
|
|
const trimmedLine = String(line || "").trim();
|
|
if (trimmedLine.startsWith("```")) {
|
|
insideCodeFence = !insideCodeFence;
|
|
filteredLines.push(line);
|
|
return;
|
|
}
|
|
|
|
if (!trimmedLine || insideCodeFence) {
|
|
filteredLines.push(line);
|
|
return;
|
|
}
|
|
|
|
if (shouldDropReadableText(trimmedLine)) {
|
|
return;
|
|
}
|
|
|
|
filteredLines.push(line);
|
|
});
|
|
|
|
return filteredLines
|
|
.join("\n")
|
|
.replace(/\n{3,}/gu, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
function joinInlineParts(parts) {
|
|
return String(parts
|
|
.map((part) => String(part || "").trim())
|
|
.filter(Boolean)
|
|
.join(" "))
|
|
.replace(/\s+([,.;!?])/gu, "$1")
|
|
.replace(/([([{\u201c])\s+/gu, "$1")
|
|
.replace(/\s+([\])}\u201d])/gu, "$1")
|
|
.replace(/\s*\n\s*/gu, "\n")
|
|
.replace(/[ \t]+\n/gu, "\n")
|
|
.replace(/\n{3,}/gu, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
function indentBlock(text, level = 1) {
|
|
const prefix = " ".repeat(Math.max(0, level));
|
|
return String(text || "")
|
|
.split("\n")
|
|
.map((line) => `${prefix}${line}`)
|
|
.join("\n");
|
|
}
|
|
|
|
function createNamedError(name, message, details = {}) {
|
|
const error = new Error(message);
|
|
error.name = name;
|
|
Object.assign(error, details);
|
|
return error;
|
|
}
|
|
|
|
function coerceSelectorList(payload) {
|
|
if (typeof payload === "string") {
|
|
return [payload];
|
|
}
|
|
|
|
if (Array.isArray(payload?.selectors)) {
|
|
return payload.selectors;
|
|
}
|
|
|
|
if (typeof payload?.selectors === "string") {
|
|
return [payload.selectors];
|
|
}
|
|
|
|
if (Array.isArray(payload?.selector)) {
|
|
return payload.selector;
|
|
}
|
|
|
|
if (typeof payload?.selector === "string") {
|
|
return [payload.selector];
|
|
}
|
|
|
|
if (Array.isArray(payload)) {
|
|
return payload;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function normalizeSelectorList(payload) {
|
|
return coerceSelectorList(payload)
|
|
.map((selector) => String(selector || "").trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function normalizeIncludeLinkUrls(payload) {
|
|
return payload?.includeLinkUrls === true;
|
|
}
|
|
|
|
function normalizeIncludeLabelQuotes(payload) {
|
|
return payload?.includeLabelQuotes === true;
|
|
}
|
|
|
|
function normalizeIncludeListIndentation(payload) {
|
|
return payload?.includeListIndentation !== false;
|
|
}
|
|
|
|
function normalizeIncludeListMarkers(payload) {
|
|
return payload?.includeListMarkers === true;
|
|
}
|
|
|
|
function normalizeIncludeStateTags(payload) {
|
|
return payload?.includeStateTags !== false;
|
|
}
|
|
|
|
function normalizeIncludeSemanticTags(payload) {
|
|
return payload?.includeSemanticTags !== false;
|
|
}
|
|
|
|
function formatSummaryValue(value, options = {}) {
|
|
const normalizedValue = normalizeText(value);
|
|
if (!normalizedValue) {
|
|
return "";
|
|
}
|
|
|
|
if (options.includeLabelQuotes === true) {
|
|
return quoteText(normalizedValue);
|
|
}
|
|
|
|
return escapeMarkdownText(normalizedValue);
|
|
}
|
|
|
|
function normalizeFrameChain(value) {
|
|
const rawFrameChain = Array.isArray(value)
|
|
? value
|
|
: typeof value === "string"
|
|
? value.split(">")
|
|
: [];
|
|
|
|
return rawFrameChain
|
|
.map((entry) => String(entry || "").trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function getDomHelper() {
|
|
const helper = globalThis[DOM_HELPER_KEY];
|
|
if (
|
|
helper
|
|
&& typeof helper.captureDocument === "function"
|
|
&& typeof helper.detailNode === "function"
|
|
&& typeof helper.clickNode === "function"
|
|
&& typeof helper.typeNode === "function"
|
|
&& typeof helper.submitNode === "function"
|
|
&& typeof helper.typeSubmitNode === "function"
|
|
&& typeof helper.scrollNode === "function"
|
|
) {
|
|
return helper;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function requireDomHelper(actionLabel) {
|
|
const helper = getDomHelper();
|
|
if (helper) {
|
|
return helper;
|
|
}
|
|
|
|
throw createNamedError(
|
|
"BrowserPageContentHelperUnavailableError",
|
|
`Browser page content cannot ${actionLabel} without the desktop DOM helper.`,
|
|
{
|
|
code: "browser_page_content_dom_helper_unavailable",
|
|
details: {
|
|
action: String(actionLabel || "resolve")
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
function normalizeReferenceId(value) {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return String(Math.trunc(value));
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
return value.trim();
|
|
}
|
|
|
|
if (value && typeof value === "object") {
|
|
return normalizeReferenceId(value.referenceId ?? value.ref ?? value.id);
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function getTagName(element) {
|
|
return String(element?.tagName || "").toUpperCase();
|
|
}
|
|
|
|
function expandSlotNodes(node) {
|
|
if (!isElementNode(node) || getTagName(node) !== "SLOT" || typeof node.assignedNodes !== "function") {
|
|
return [node];
|
|
}
|
|
|
|
try {
|
|
const assignedNodes = [...(node.assignedNodes({ flatten: true }) || [])].filter(Boolean);
|
|
if (assignedNodes.length) {
|
|
return assignedNodes.flatMap((assignedNode) => expandSlotNodes(assignedNode));
|
|
}
|
|
} catch {
|
|
// Fall through to the slot's fallback children.
|
|
}
|
|
|
|
return [...(node.childNodes || [])].flatMap((childNode) => expandSlotNodes(childNode));
|
|
}
|
|
|
|
function getReadableChildNodes(element) {
|
|
const shadowRoot = element?.shadowRoot;
|
|
if (shadowRoot) {
|
|
const shadowNodes = [...(shadowRoot.childNodes || [])].flatMap((childNode) => expandSlotNodes(childNode));
|
|
if (shadowNodes.length) {
|
|
return shadowNodes;
|
|
}
|
|
}
|
|
|
|
return [...(element?.childNodes || [])].flatMap((childNode) => expandSlotNodes(childNode));
|
|
}
|
|
|
|
function getReadableElementChildren(element) {
|
|
return getReadableChildNodes(element).filter((childNode) => isElementNode(childNode));
|
|
}
|
|
|
|
function getReadableNodeText(node) {
|
|
if (isTextNode(node)) {
|
|
return node.textContent || "";
|
|
}
|
|
|
|
if (!isElementNode(node) || isHiddenElement(node)) {
|
|
return "";
|
|
}
|
|
|
|
return getReadableChildNodes(node)
|
|
.map((childNode) => getReadableNodeText(childNode))
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
|
|
function querySelectorAllDeep(selector, root = globalThis.document) {
|
|
const results = [];
|
|
const seen = new Set();
|
|
|
|
const addResult = (element) => {
|
|
if (element && !seen.has(element)) {
|
|
seen.add(element);
|
|
results.push(element);
|
|
}
|
|
};
|
|
|
|
const visitRoot = (scope) => {
|
|
if (!scope || typeof scope.querySelectorAll !== "function") {
|
|
return;
|
|
}
|
|
|
|
[...(scope.querySelectorAll(selector) || [])].forEach(addResult);
|
|
[...(scope.querySelectorAll("*") || [])].forEach((element) => {
|
|
if (element.shadowRoot) {
|
|
visitRoot(element.shadowRoot);
|
|
}
|
|
});
|
|
};
|
|
|
|
visitRoot(root);
|
|
return results;
|
|
}
|
|
|
|
function getAttributeNamesSafe(element) {
|
|
try {
|
|
if (typeof element?.getAttributeNames === "function") {
|
|
return element.getAttributeNames();
|
|
}
|
|
|
|
return [...(element?.attributes || [])]
|
|
.map((attribute) => String(attribute?.name || "").trim())
|
|
.filter(Boolean);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function normalizeInteractiveEventName(value) {
|
|
return String(value || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.split(/[.:]/u, 1)[0];
|
|
}
|
|
|
|
function isInteractiveEventName(value) {
|
|
return INTERACTIVE_EVENT_NAMES.has(normalizeInteractiveEventName(value));
|
|
}
|
|
|
|
function isInteractiveEventAttributeName(attributeName) {
|
|
const normalizedName = String(attributeName || "").trim().toLowerCase();
|
|
if (!normalizedName) {
|
|
return false;
|
|
}
|
|
|
|
if (normalizedName.startsWith("@")) {
|
|
return isInteractiveEventName(normalizedName.slice(1));
|
|
}
|
|
|
|
if (normalizedName.startsWith("x-on:") || normalizedName.startsWith("v-on:")) {
|
|
return isInteractiveEventName(normalizedName.slice(5));
|
|
}
|
|
|
|
if (normalizedName.startsWith("ng-")) {
|
|
return isInteractiveEventName(normalizedName.slice(3));
|
|
}
|
|
|
|
if (normalizedName.startsWith("on") && normalizedName.length > 2) {
|
|
return isInteractiveEventName(normalizedName.slice(2));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function hasHelperManagedNodeReference(element) {
|
|
return Boolean(normalizeAttributeText(element?.getAttribute?.("data-space-browser-node-id")));
|
|
}
|
|
|
|
function hasInteractiveEventHandlerAttribute(element) {
|
|
return getAttributeNamesSafe(element).some((attributeName) => {
|
|
return isInteractiveEventAttributeName(attributeName);
|
|
});
|
|
}
|
|
|
|
function hasInteractiveEventHandlerProperty(element) {
|
|
return INTERACTIVE_EVENT_PROPERTIES.some((propertyName) => {
|
|
return typeof element?.[propertyName] === "function";
|
|
});
|
|
}
|
|
|
|
function hasInteractiveEventHandler(element) {
|
|
return hasInteractiveEventHandlerAttribute(element) || hasInteractiveEventHandlerProperty(element);
|
|
}
|
|
|
|
function isStyleDeclarationHidden(styleValue) {
|
|
const normalizedStyleValue = String(styleValue || "")
|
|
.toLowerCase()
|
|
.replace(/\s+/gu, "");
|
|
|
|
if (!normalizedStyleValue) {
|
|
return false;
|
|
}
|
|
|
|
return /(?:^|;)display:none(?:;|$)/u.test(normalizedStyleValue)
|
|
|| /(?:^|;)visibility:hidden(?:;|$)/u.test(normalizedStyleValue)
|
|
|| /(?:^|;)visibility:collapse(?:;|$)/u.test(normalizedStyleValue)
|
|
|| /(?:^|;)content-visibility:hidden(?:;|$)/u.test(normalizedStyleValue)
|
|
|| /(?:^|;)opacity:0(?:\.0+)?(?:;|$)/u.test(normalizedStyleValue);
|
|
}
|
|
|
|
function isComputedStyleHidden(computedStyle) {
|
|
if (!computedStyle) {
|
|
return false;
|
|
}
|
|
|
|
const display = normalizeText(computedStyle.display).toLowerCase();
|
|
const visibility = normalizeText(computedStyle.visibility).toLowerCase();
|
|
const contentVisibility = normalizeText(computedStyle.contentVisibility).toLowerCase();
|
|
const opacity = Number(computedStyle.opacity || 1);
|
|
|
|
return display === "none"
|
|
|| visibility === "hidden"
|
|
|| visibility === "collapse"
|
|
|| contentVisibility === "hidden"
|
|
|| opacity <= 0;
|
|
}
|
|
|
|
function isEffectivelyHiddenByAncestor(element) {
|
|
let current = element;
|
|
|
|
while (isElementNode(current)) {
|
|
if (current.hidden || current.getAttribute?.("aria-hidden") === "true") {
|
|
return true;
|
|
}
|
|
|
|
if (isStyleDeclarationHidden(current.getAttribute?.("style"))) {
|
|
return true;
|
|
}
|
|
|
|
if (isComputedStyleHidden(getComputedStyleSafe(current))) {
|
|
return true;
|
|
}
|
|
|
|
current = current.parentElement;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isHiddenElement(element) {
|
|
if (!isElementNode(element)) {
|
|
return true;
|
|
}
|
|
|
|
const tagName = getTagName(element);
|
|
if (SKIP_TAGS.has(tagName)) {
|
|
return true;
|
|
}
|
|
|
|
if (element.hidden || element.getAttribute?.("aria-hidden") === "true") {
|
|
return true;
|
|
}
|
|
|
|
if (tagName === "INPUT" && String(element.getAttribute?.("type") || "").toLowerCase() === "hidden") {
|
|
return true;
|
|
}
|
|
|
|
if (isStyleDeclarationHidden(element.getAttribute?.("style"))) {
|
|
return true;
|
|
}
|
|
|
|
const computedStyle = getComputedStyleSafe(element);
|
|
if (isComputedStyleHidden(computedStyle)) {
|
|
return true;
|
|
}
|
|
|
|
return isEffectivelyHiddenByAncestor(element.parentElement);
|
|
}
|
|
|
|
function isBlockElement(element) {
|
|
return BLOCK_TAGS.has(getTagName(element));
|
|
}
|
|
|
|
function isInteractiveElement(element) {
|
|
if (!isElementNode(element) || isHiddenElement(element)) {
|
|
return false;
|
|
}
|
|
|
|
if (hasHelperManagedNodeReference(element)) {
|
|
return true;
|
|
}
|
|
|
|
const tagName = getTagName(element);
|
|
if (tagName === "A" && element.hasAttribute?.("href")) {
|
|
return true;
|
|
}
|
|
|
|
if (tagName === "BUTTON" || tagName === "INPUT" || tagName === "SELECT" || tagName === "TEXTAREA" || tagName === "SUMMARY") {
|
|
return true;
|
|
}
|
|
|
|
if (String(element.getAttribute?.("contenteditable") || "").toLowerCase() === "true") {
|
|
return true;
|
|
}
|
|
|
|
const role = String(element.getAttribute?.("role") || "").trim().toLowerCase();
|
|
return INTERACTIVE_ROLES.has(role) || hasInteractiveEventHandler(element);
|
|
}
|
|
|
|
function isFileInputElement(element) {
|
|
return getTagName(element) === "INPUT"
|
|
&& String(element.getAttribute?.("type") || element.type || "").toLowerCase() === "file";
|
|
}
|
|
|
|
function getAssociatedLabelFileInput(labelElement) {
|
|
if (getTagName(labelElement) !== "LABEL") {
|
|
return null;
|
|
}
|
|
|
|
if (isFileInputElement(labelElement.control)) {
|
|
return labelElement.control;
|
|
}
|
|
|
|
const descendantInput = labelElement.querySelector?.("input[type='file']");
|
|
if (isFileInputElement(descendantInput)) {
|
|
return descendantInput;
|
|
}
|
|
|
|
const forId = normalizeAttributeText(labelElement.getAttribute?.("for"));
|
|
if (!forId) {
|
|
return null;
|
|
}
|
|
|
|
return isFileInputElement(labelElement.ownerDocument?.getElementById?.(forId))
|
|
? labelElement.ownerDocument.getElementById(forId)
|
|
: null;
|
|
}
|
|
|
|
function isFileInputLabel(element) {
|
|
if (getTagName(element) !== "LABEL" || isHiddenElement(element)) {
|
|
return false;
|
|
}
|
|
|
|
const input = getAssociatedLabelFileInput(element);
|
|
return Boolean(input && isHiddenElement(input));
|
|
}
|
|
|
|
function getComputedStyleSafe(element) {
|
|
try {
|
|
return globalThis.getComputedStyle?.(element) || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getElementRectSafe(element) {
|
|
try {
|
|
const rect = element?.getBoundingClientRect?.();
|
|
if (!rect) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
height: Number(rect.height) || 0,
|
|
width: Number(rect.width) || 0,
|
|
x: Number(rect.x) || 0,
|
|
y: Number(rect.y) || 0
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readSerializedTagList(element, attributeName) {
|
|
const rawValue = normalizeText(element?.getAttribute?.(attributeName));
|
|
if (!rawValue) {
|
|
return [];
|
|
}
|
|
|
|
return rawValue
|
|
.split(/\s+/u)
|
|
.map((part) => normalizeText(part))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function detectSemanticTone(element, computedStyle, metadata = {}) {
|
|
const opacity = Number(computedStyle?.opacity || 1);
|
|
const backgroundColor = parseCssColor(computedStyle?.backgroundColor || "");
|
|
const borderColor = parseCssColor(computedStyle?.borderTopColor || "");
|
|
const foregroundColor = parseCssColor(computedStyle?.color || "");
|
|
const isButtonLike = ["BUTTON", "INPUT", "SUMMARY"].includes(getTagName(element))
|
|
|| ["button", "tab", "menuitem"].includes(String(element?.getAttribute?.("role") || "").trim().toLowerCase());
|
|
|
|
if (metadata.disabled || metadata.blocked || opacity <= 0.58) {
|
|
return "muted";
|
|
}
|
|
|
|
const preferredColor = [backgroundColor, borderColor, foregroundColor]
|
|
.filter((color) => color && color.a > 0.15)
|
|
.map((color) => ({
|
|
color,
|
|
hsl: rgbToHsl(color)
|
|
}))
|
|
.find((entry) => entry.hsl && entry.hsl.saturation >= 0.2);
|
|
|
|
if (!preferredColor) {
|
|
return "";
|
|
}
|
|
|
|
const {
|
|
hue,
|
|
lightness,
|
|
saturation
|
|
} = preferredColor.hsl;
|
|
if (saturation < 0.2) {
|
|
return "";
|
|
}
|
|
|
|
if ((hue >= 345 || hue < 20) && lightness >= 0.18 && lightness <= 0.82) {
|
|
return "error";
|
|
}
|
|
|
|
if (hue >= 20 && hue < 65 && lightness >= 0.2 && lightness <= 0.9) {
|
|
return "warning";
|
|
}
|
|
|
|
if (hue >= 65 && hue < 170 && lightness >= 0.16 && lightness <= 0.84) {
|
|
return "success";
|
|
}
|
|
|
|
if (hue >= 170 && hue < 280 && lightness >= 0.14 && lightness <= 0.82) {
|
|
if (isButtonLike && backgroundColor?.a > 0.2) {
|
|
return "primary";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function collectElementStateMetadata(element, options = {}) {
|
|
if (!isElementNode(element)) {
|
|
return {
|
|
descriptorTags: [],
|
|
semanticTags: [],
|
|
stateTags: []
|
|
};
|
|
}
|
|
|
|
const computedStyle = getComputedStyleSafe(element);
|
|
const rect = getElementRectSafe(element);
|
|
const tagName = getTagName(element);
|
|
const ariaDisabled = String(element.getAttribute?.("aria-disabled") || "").trim().toLowerCase() === "true";
|
|
const ariaBusy = String(element.getAttribute?.("aria-busy") || "").trim().toLowerCase() === "true";
|
|
const ariaChecked = String(element.getAttribute?.("aria-checked") || "").trim().toLowerCase() === "true";
|
|
const ariaCurrent = normalizeText(element.getAttribute?.("aria-current"));
|
|
const ariaInvalid = String(element.getAttribute?.("aria-invalid") || "").trim().toLowerCase() === "true";
|
|
const ariaPressed = String(element.getAttribute?.("aria-pressed") || "").trim().toLowerCase() === "true";
|
|
const ariaReadonly = String(element.getAttribute?.("aria-readonly") || "").trim().toLowerCase() === "true";
|
|
const ariaRequired = String(element.getAttribute?.("aria-required") || "").trim().toLowerCase() === "true";
|
|
const ariaSelected = String(element.getAttribute?.("aria-selected") || "").trim().toLowerCase() === "true";
|
|
const helperStateTags = readSerializedTagList(element, "data-space-browser-state-tags");
|
|
const helperSemanticTags = readSerializedTagList(element, "data-space-browser-semantic-tags");
|
|
const closestInert = typeof element.closest === "function" ? element.closest("[inert]") : null;
|
|
const pointerEventsNone = normalizeText(computedStyle?.pointerEvents || "").toLowerCase() === "none";
|
|
const disabled = Boolean(element.disabled || ariaDisabled || closestInert || helperStateTags.includes("disabled"));
|
|
const blocked = !disabled && (pointerEventsNone || helperStateTags.includes("blocked"));
|
|
const checked = Boolean(element.checked || ariaChecked || helperStateTags.includes("checked"));
|
|
const selected = tagName === "OPTION"
|
|
? Boolean(element.selected)
|
|
: Boolean(ariaSelected || helperStateTags.includes("selected"));
|
|
const invalid = Boolean(ariaInvalid || helperStateTags.includes("invalid") || element.matches?.(":invalid"));
|
|
const readonly = Boolean(element.readOnly || ariaReadonly);
|
|
const required = Boolean(element.required || ariaRequired);
|
|
const expanded = String(element.getAttribute?.("aria-expanded") || "").trim().toLowerCase() === "true" || helperStateTags.includes("expanded");
|
|
const pressed = ariaPressed || helperStateTags.includes("pressed");
|
|
const busy = ariaBusy || helperStateTags.includes("busy");
|
|
const current = Boolean((ariaCurrent && ariaCurrent !== "false") || helperStateTags.includes("current"));
|
|
const zeroRect = Boolean(
|
|
rect
|
|
&& element.ownerDocument === globalThis.document
|
|
&& rect.width <= 1
|
|
&& rect.height <= 1
|
|
);
|
|
const opacity = Number(computedStyle?.opacity || 1);
|
|
const semanticTone = helperSemanticTags[0] || detectSemanticTone(element, computedStyle, {
|
|
blocked,
|
|
disabled
|
|
});
|
|
const stateTags = helperStateTags.length
|
|
? helperStateTags.slice()
|
|
: [
|
|
disabled ? "disabled" : "",
|
|
!disabled && (blocked || zeroRect) ? "blocked" : "",
|
|
checked ? "checked" : "",
|
|
selected && tagName !== "SELECT" ? "selected" : "",
|
|
invalid ? "invalid" : "",
|
|
expanded ? "expanded" : "",
|
|
pressed ? "pressed" : ""
|
|
].filter(Boolean);
|
|
|
|
const semanticTags = helperSemanticTags.length
|
|
? helperSemanticTags.slice(0, 1)
|
|
: (semanticTone ? [semanticTone] : []);
|
|
const descriptorTags = [
|
|
...(options.includeStateTags !== false ? stateTags : []),
|
|
...(options.includeSemanticTags !== false ? semanticTags : [])
|
|
];
|
|
|
|
return {
|
|
blocked,
|
|
busy,
|
|
checked,
|
|
current,
|
|
cursor: normalizeText(computedStyle?.cursor || "").toLowerCase(),
|
|
descriptorTags,
|
|
disabled,
|
|
expanded,
|
|
invalid,
|
|
opacity,
|
|
pointerEventsNone,
|
|
pressed,
|
|
readonly,
|
|
required,
|
|
selected,
|
|
semanticTags,
|
|
semanticTone,
|
|
stateTags,
|
|
visible: !isHiddenElement(element),
|
|
zeroRect
|
|
};
|
|
}
|
|
|
|
function getReferenceValueMetadata(element) {
|
|
const tagName = getTagName(element);
|
|
const helperLiveValue = normalizeText(element?.getAttribute?.("data-space-browser-live-value"));
|
|
const helperSelectedValue = normalizeText(element?.getAttribute?.("data-space-browser-selected-text"));
|
|
if (tagName === "INPUT") {
|
|
const inputType = String(element.getAttribute?.("type") || element.type || "text").toLowerCase();
|
|
if (inputType === "password") {
|
|
return "";
|
|
}
|
|
return truncateText(helperLiveValue || element.value || element.getAttribute?.("value") || "", 96);
|
|
}
|
|
|
|
if (tagName === "TEXTAREA") {
|
|
return truncateText(helperLiveValue || element.value || "", 96);
|
|
}
|
|
|
|
if (tagName === "SELECT") {
|
|
if (helperSelectedValue) {
|
|
return helperSelectedValue;
|
|
}
|
|
const selectedOptions = [...(element.selectedOptions || [])]
|
|
.map((option) => truncateText(option.textContent || option.label || option.value || "", 48))
|
|
.filter(Boolean);
|
|
return selectedOptions.join(" | ");
|
|
}
|
|
|
|
if (String(element.getAttribute?.("contenteditable") || "").toLowerCase() === "true") {
|
|
return truncateText(element.textContent || "", 96);
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function collectMetaLines(doc = globalThis.document) {
|
|
const lines = [];
|
|
const title = normalizeAttributeText(doc?.title || "");
|
|
const description = normalizeAttributeText(
|
|
doc?.querySelector?.('meta[name="description"]')?.getAttribute?.("content") || ""
|
|
);
|
|
const url = String(globalThis.location?.href || "");
|
|
|
|
if (!title && !description && !url) {
|
|
return "";
|
|
}
|
|
|
|
lines.push("---");
|
|
if (title) {
|
|
lines.push(`title: ${quoteText(title)}`);
|
|
}
|
|
if (description) {
|
|
lines.push(`description: ${quoteText(description)}`);
|
|
}
|
|
if (url) {
|
|
lines.push(`url: ${quoteText(url)}`);
|
|
}
|
|
lines.push("---");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function summarizeUrl(value) {
|
|
const normalizedValue = String(value || "").trim();
|
|
if (!normalizedValue) {
|
|
return "";
|
|
}
|
|
|
|
try {
|
|
const url = new URL(normalizedValue, globalThis.location?.href || "http://localhost/");
|
|
if (url.origin === globalThis.location?.origin) {
|
|
const relative = `${url.pathname || "/"}${url.search || ""}${url.hash || ""}`;
|
|
return truncateText(relative || "/", 96);
|
|
}
|
|
|
|
return truncateText(`${url.hostname}${url.pathname || "/"}`, 96);
|
|
} catch {
|
|
return truncateText(normalizedValue, 96);
|
|
}
|
|
}
|
|
|
|
function getElementText(element) {
|
|
const readableText = normalizeText(getReadableNodeText(element));
|
|
return readableText || normalizeText(element?.textContent || "");
|
|
}
|
|
|
|
function isLabelableControlForText(element) {
|
|
return ["BUTTON", "INPUT", "METER", "OUTPUT", "PROGRESS", "SELECT", "TEXTAREA"].includes(getTagName(element));
|
|
}
|
|
|
|
function getLabelElementText(labelElement, controlElement = null) {
|
|
const collect = (node) => {
|
|
if (isTextNode(node)) {
|
|
return node.textContent || "";
|
|
}
|
|
|
|
if (!isElementNode(node) || isHiddenElement(node)) {
|
|
return "";
|
|
}
|
|
|
|
if (node !== labelElement && (node === controlElement || isLabelableControlForText(node))) {
|
|
return "";
|
|
}
|
|
|
|
return getReadableChildNodes(node)
|
|
.map((childNode) => collect(childNode))
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
};
|
|
|
|
return normalizeText(collect(labelElement)) || getElementText(labelElement);
|
|
}
|
|
|
|
function collectLabelCandidates(element, options = {}) {
|
|
const includeAlt = options.includeAlt !== false;
|
|
const includeDescendantImageAlt = options.includeDescendantImageAlt !== false;
|
|
const includePlaceholder = options.includePlaceholder === true;
|
|
const includeText = options.includeText !== false;
|
|
const collectedLabels = [];
|
|
|
|
try {
|
|
if (Array.isArray(element?.labels) || typeof element?.labels?.forEach === "function") {
|
|
element.labels.forEach((labelElement) => {
|
|
const text = getLabelElementText(labelElement, element);
|
|
if (text) {
|
|
collectedLabels.push(text);
|
|
}
|
|
});
|
|
}
|
|
} catch {
|
|
// Ignore labels lookup failures from non-form elements.
|
|
}
|
|
|
|
[
|
|
element?.getAttribute?.("aria-label"),
|
|
element?.getAttribute?.("title")
|
|
].forEach((candidate) => {
|
|
const text = normalizeAttributeText(candidate);
|
|
if (text) {
|
|
collectedLabels.push(text);
|
|
}
|
|
});
|
|
|
|
if (includeAlt) {
|
|
const altText = normalizeAttributeText(element?.getAttribute?.("alt"));
|
|
if (altText) {
|
|
collectedLabels.push(altText);
|
|
}
|
|
}
|
|
|
|
if (includePlaceholder) {
|
|
const placeholderText = normalizeAttributeText(element?.getAttribute?.("placeholder"));
|
|
if (placeholderText) {
|
|
collectedLabels.push(placeholderText);
|
|
}
|
|
}
|
|
|
|
if (includeDescendantImageAlt) {
|
|
try {
|
|
[...(element?.querySelectorAll?.("img[alt], img[title]") || [])]
|
|
.slice(0, 3)
|
|
.forEach((mediaElement) => {
|
|
const text = normalizeAttributeText(
|
|
mediaElement.getAttribute?.("alt")
|
|
|| mediaElement.getAttribute?.("title")
|
|
);
|
|
if (text) {
|
|
collectedLabels.push(text);
|
|
}
|
|
});
|
|
} catch {
|
|
// Ignore descendant-media lookup failures.
|
|
}
|
|
}
|
|
|
|
if (includeText) {
|
|
const textContent = getElementText(element);
|
|
if (textContent) {
|
|
collectedLabels.push(textContent);
|
|
}
|
|
}
|
|
|
|
return [...new Set(collectedLabels.filter(Boolean))];
|
|
}
|
|
|
|
function getLabelText(element, options = {}) {
|
|
return collectLabelCandidates(element, options)[0] || "";
|
|
}
|
|
|
|
function serializeElementSnapshot(element) {
|
|
if (!isElementNode(element)) {
|
|
return "";
|
|
}
|
|
|
|
try {
|
|
if (typeof element.outerHTML === "string" && element.outerHTML) {
|
|
return element.outerHTML;
|
|
}
|
|
} catch {
|
|
// Fall through to XMLSerializer.
|
|
}
|
|
|
|
try {
|
|
if (typeof globalThis.XMLSerializer === "function") {
|
|
return new globalThis.XMLSerializer().serializeToString(element);
|
|
}
|
|
} catch {
|
|
// Ignore serialization errors.
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function getReferenceKind(element) {
|
|
const tagName = getTagName(element);
|
|
const role = String(element.getAttribute?.("role") || "").trim().toLowerCase();
|
|
const inputType = String(element.getAttribute?.("type") || element.type || "text").toLowerCase();
|
|
|
|
if (tagName === "A" || role === "link") {
|
|
return "link";
|
|
}
|
|
|
|
if (tagName === "IMG") {
|
|
return "image";
|
|
}
|
|
|
|
if (tagName === "BUTTON" || ["button", "menuitem", "tab"].includes(role)) {
|
|
return "button";
|
|
}
|
|
|
|
if (tagName === "TEXTAREA") {
|
|
return "textarea";
|
|
}
|
|
|
|
if (tagName === "SELECT" || role === "combobox") {
|
|
return "select";
|
|
}
|
|
|
|
if (tagName === "SUMMARY") {
|
|
return "summary";
|
|
}
|
|
|
|
if (tagName === "INPUT") {
|
|
if (["button", "submit", "reset"].includes(inputType)) {
|
|
return "button";
|
|
}
|
|
|
|
if (inputType === "checkbox") {
|
|
return "checkbox";
|
|
}
|
|
|
|
if (inputType === "radio") {
|
|
return "radio";
|
|
}
|
|
|
|
return `input ${inputType || "text"}`;
|
|
}
|
|
|
|
if (tagName === "LABEL" && isFileInputLabel(element)) {
|
|
return "file input label";
|
|
}
|
|
|
|
if (String(element.getAttribute?.("contenteditable") || "").toLowerCase() === "true") {
|
|
return "editable";
|
|
}
|
|
|
|
if (role === "searchbox") {
|
|
return "input search";
|
|
}
|
|
|
|
if (role === "textbox") {
|
|
return "input text";
|
|
}
|
|
|
|
if (hasHelperManagedNodeReference(element) || hasInteractiveEventHandler(element)) {
|
|
return "button";
|
|
}
|
|
|
|
return role || tagName.toLowerCase();
|
|
}
|
|
|
|
function collectReferenceSummaryData(element, options = {}) {
|
|
const tagName = getTagName(element);
|
|
const role = String(element.getAttribute?.("role") || "").trim().toLowerCase();
|
|
const id = normalizeAttributeText(element.getAttribute?.("id"));
|
|
const name = normalizeAttributeText(element.getAttribute?.("name"));
|
|
const kind = getReferenceKind(element);
|
|
const stateMetadata = collectElementStateMetadata(element, options);
|
|
const formatValue = (value) => formatSummaryValue(value, options);
|
|
const includeLinkUrls = options.includeLinkUrls === true;
|
|
const parts = [];
|
|
const appendFallbackIdOrName = () => {
|
|
if (id) {
|
|
parts.push(`#${id}`);
|
|
return;
|
|
}
|
|
|
|
if (name) {
|
|
parts.push(`name=${formatValue(name)}`);
|
|
}
|
|
};
|
|
|
|
if (tagName === "A" || role === "link") {
|
|
const hrefSummary = summarizeUrl(element.getAttribute?.("href") || element.href || "");
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: true,
|
|
includePlaceholder: false,
|
|
includeText: true
|
|
}), 120);
|
|
const displayLabel = label || hrefSummary;
|
|
|
|
if (displayLabel) {
|
|
parts.push(formatValue(displayLabel));
|
|
} else {
|
|
appendFallbackIdOrName();
|
|
}
|
|
|
|
if (includeLinkUrls) {
|
|
if (hrefSummary && hrefSummary !== displayLabel) {
|
|
parts.push(`-> ${hrefSummary}`);
|
|
}
|
|
}
|
|
} else if (tagName === "BUTTON" || ["button", "menuitem", "tab"].includes(role)) {
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: true,
|
|
includePlaceholder: false,
|
|
includeText: true
|
|
}), 120);
|
|
if (label) {
|
|
parts.push(formatValue(label));
|
|
} else {
|
|
appendFallbackIdOrName();
|
|
}
|
|
} else if (tagName === "TEXTAREA" || role === "textbox" || role === "searchbox") {
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: false,
|
|
includePlaceholder: false,
|
|
includeText: true
|
|
}), 120);
|
|
if (label) {
|
|
parts.push(formatValue(label));
|
|
}
|
|
const placeholder = normalizeAttributeText(element.getAttribute?.("placeholder"));
|
|
if (placeholder) {
|
|
parts.push(`placeholder=${formatValue(placeholder)}`);
|
|
} else if (!label) {
|
|
appendFallbackIdOrName();
|
|
}
|
|
} else if (tagName === "SELECT" || role === "combobox") {
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: false,
|
|
includePlaceholder: false,
|
|
includeText: true
|
|
}), 120);
|
|
if (label) {
|
|
parts.push(formatValue(label));
|
|
} else {
|
|
appendFallbackIdOrName();
|
|
}
|
|
|
|
const selectedValue = getReferenceValueMetadata(element);
|
|
const selectedOptions = selectedValue
|
|
? [selectedValue]
|
|
: [...(element.selectedOptions || [])]
|
|
.map((option) => truncateText(option.textContent || "", 48))
|
|
.filter(Boolean);
|
|
if (selectedOptions.length) {
|
|
parts.push(`selected=${formatValue(selectedOptions.join(" | "))}`);
|
|
}
|
|
} else if (tagName === "SUMMARY") {
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: true,
|
|
includePlaceholder: false,
|
|
includeText: true
|
|
}), 120);
|
|
if (label) {
|
|
parts.push(formatValue(label));
|
|
} else {
|
|
appendFallbackIdOrName();
|
|
}
|
|
} else if (tagName === "INPUT") {
|
|
const inputType = String(element.getAttribute?.("type") || element.type || "text").toLowerCase();
|
|
if (["button", "submit", "reset"].includes(inputType)) {
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: false,
|
|
includePlaceholder: false,
|
|
includeText: false
|
|
}) || element.value || "", 120);
|
|
if (label) {
|
|
parts.push(formatValue(label));
|
|
} else {
|
|
appendFallbackIdOrName();
|
|
}
|
|
} else if (["checkbox", "radio"].includes(inputType)) {
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: false,
|
|
includePlaceholder: false,
|
|
includeText: false
|
|
}), 120);
|
|
if (label) {
|
|
parts.push(formatValue(label));
|
|
} else {
|
|
appendFallbackIdOrName();
|
|
}
|
|
} else if (inputType === "file") {
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: false,
|
|
includePlaceholder: false,
|
|
includeText: false
|
|
}), 120);
|
|
if (label) {
|
|
parts.push(formatValue(label));
|
|
} else {
|
|
appendFallbackIdOrName();
|
|
}
|
|
} else {
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: false,
|
|
includePlaceholder: false,
|
|
includeText: false
|
|
}), 120);
|
|
if (label) {
|
|
parts.push(formatValue(label));
|
|
}
|
|
|
|
const placeholder = normalizeAttributeText(element.getAttribute?.("placeholder"));
|
|
const value = inputType === "password"
|
|
? ""
|
|
: getReferenceValueMetadata(element);
|
|
|
|
if (placeholder) {
|
|
parts.push(`placeholder=${formatValue(placeholder)}`);
|
|
}
|
|
if (value) {
|
|
parts.push(`value=${formatValue(value)}`);
|
|
}
|
|
if (!label && !placeholder && !value) {
|
|
appendFallbackIdOrName();
|
|
}
|
|
}
|
|
} else if (String(element.getAttribute?.("contenteditable") || "").toLowerCase() === "true") {
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: false,
|
|
includePlaceholder: false,
|
|
includeText: true
|
|
}), 120);
|
|
if (label) {
|
|
parts.push(formatValue(label));
|
|
} else {
|
|
appendFallbackIdOrName();
|
|
}
|
|
} else if (tagName === "IMG") {
|
|
const srcSummary = summarizeUrl(element.currentSrc || element.getAttribute?.("src") || element.src || "");
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: true,
|
|
includeDescendantImageAlt: false,
|
|
includePlaceholder: false,
|
|
includeText: false
|
|
}), 120);
|
|
const displayLabel = label || srcSummary;
|
|
if (displayLabel) {
|
|
parts.push(formatValue(displayLabel));
|
|
} else {
|
|
appendFallbackIdOrName();
|
|
}
|
|
} else if (role) {
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: true,
|
|
includePlaceholder: false,
|
|
includeText: true
|
|
}), 120);
|
|
if (label) {
|
|
parts.push(formatValue(label));
|
|
} else {
|
|
appendFallbackIdOrName();
|
|
}
|
|
} else {
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: true,
|
|
includePlaceholder: false,
|
|
includeText: true
|
|
}), 120);
|
|
if (label) {
|
|
parts.push(formatValue(label));
|
|
} else {
|
|
appendFallbackIdOrName();
|
|
}
|
|
}
|
|
|
|
return {
|
|
descriptorTags: stateMetadata.descriptorTags.slice(),
|
|
kind,
|
|
semanticTags: stateMetadata.semanticTags.slice(),
|
|
state: stateMetadata,
|
|
summary: parts.filter(Boolean).join(" ")
|
|
};
|
|
}
|
|
|
|
function createReferenceEntry(element, referenceId, options = {}) {
|
|
const nodeId = normalizeAttributeText(element.getAttribute?.("data-space-browser-node-id"));
|
|
const frameId = normalizeAttributeText(element.getAttribute?.("data-space-browser-frame-id"));
|
|
const frameChain = normalizeFrameChain(element.getAttribute?.("data-space-browser-frame-chain"));
|
|
const helperBacked = Boolean(nodeId && frameChain.length);
|
|
const summaryData = collectReferenceSummaryData(element, options);
|
|
|
|
return {
|
|
connected: helperBacked ? true : element.isConnected !== false,
|
|
dom: serializeElementSnapshot(element),
|
|
descriptorTags: summaryData.descriptorTags,
|
|
element: helperBacked ? null : element,
|
|
frameChain,
|
|
frameId,
|
|
helperBacked,
|
|
id: normalizeAttributeText(element.getAttribute?.("id")),
|
|
name: normalizeAttributeText(element.getAttribute?.("name")),
|
|
nodeId,
|
|
referenceId,
|
|
kind: summaryData.kind,
|
|
semanticTags: summaryData.semanticTags,
|
|
state: summaryData.state,
|
|
summary: summaryData.summary,
|
|
tagName: getTagName(element)
|
|
};
|
|
}
|
|
|
|
function ensureReference(element, context) {
|
|
if (context.referenceIdsByElement.has(element)) {
|
|
return context.referenceIdsByElement.get(element);
|
|
}
|
|
|
|
const referenceId = String(context.nextReferenceId++);
|
|
const entry = createReferenceEntry(element, referenceId, context.options);
|
|
context.referenceIdsByElement.set(element, referenceId);
|
|
context.entries.set(referenceId, entry);
|
|
return referenceId;
|
|
}
|
|
|
|
function renderReference(element, context) {
|
|
const referenceId = ensureReference(element, context);
|
|
const entry = context.entries.get(referenceId);
|
|
const kind = normalizeText(entry?.kind || getTagName(element).toLowerCase());
|
|
const descriptorTags = Array.isArray(entry?.descriptorTags)
|
|
? entry.descriptorTags.map((tag) => normalizeText(tag)).filter(Boolean)
|
|
: [];
|
|
const summary = normalizeText(entry?.summary || "");
|
|
const descriptor = [...descriptorTags, kind, referenceId].filter(Boolean).join(" ");
|
|
return summary ? `[${descriptor}] ${summary}` : `[${descriptor}]`;
|
|
}
|
|
|
|
function isReferenceableElement(element) {
|
|
return isInteractiveElement(element) || getTagName(element) === "IMG" || isFileInputLabel(element);
|
|
}
|
|
|
|
function collectLabelControlElements(labelElement) {
|
|
const controls = [];
|
|
const seen = new Set();
|
|
const addControl = (element) => {
|
|
if (!isElementNode(element) || seen.has(element) || !isReferenceableElement(element)) {
|
|
return;
|
|
}
|
|
|
|
seen.add(element);
|
|
controls.push(element);
|
|
};
|
|
|
|
[
|
|
"input",
|
|
"textarea",
|
|
"select",
|
|
"button",
|
|
"summary",
|
|
"a[href]",
|
|
"[role]",
|
|
"[contenteditable='true']",
|
|
"[contenteditable='']"
|
|
].forEach((selector) => {
|
|
try {
|
|
[...(labelElement.querySelectorAll?.(selector) || [])].forEach(addControl);
|
|
} catch {
|
|
// Ignore unsupported selectors in unusual DOMs.
|
|
}
|
|
});
|
|
|
|
return controls;
|
|
}
|
|
|
|
function renderControlLabelReferences(labelElement, context) {
|
|
return collectLabelControlElements(labelElement)
|
|
.map((controlElement) => renderReference(controlElement, context))
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
}
|
|
|
|
function renderInlineNode(node, context) {
|
|
if (isTextNode(node)) {
|
|
const textContent = normalizeText(node.textContent || "");
|
|
if (shouldDropReadableText(textContent)) {
|
|
return "";
|
|
}
|
|
|
|
return escapeMarkdownText(textContent);
|
|
}
|
|
|
|
if (!isElementNode(node) || isHiddenElement(node)) {
|
|
return "";
|
|
}
|
|
|
|
if (isReferenceableElement(node)) {
|
|
return renderReference(node, context);
|
|
}
|
|
|
|
const tagName = getTagName(node);
|
|
|
|
if (tagName === "LABEL" && (node.getAttribute?.("for") || node.querySelector?.("input, textarea, select, button"))) {
|
|
return renderControlLabelReferences(node, context);
|
|
}
|
|
|
|
if (tagName === "BR") {
|
|
return "\n";
|
|
}
|
|
|
|
if (tagName === "STRONG" || tagName === "B") {
|
|
const content = renderInlineChildren(node, context);
|
|
return content ? `**${content}**` : "";
|
|
}
|
|
|
|
if (tagName === "EM" || tagName === "I") {
|
|
const content = renderInlineChildren(node, context);
|
|
return content ? `*${content}*` : "";
|
|
}
|
|
|
|
if (tagName === "S" || tagName === "STRIKE" || tagName === "DEL") {
|
|
const content = renderInlineChildren(node, context);
|
|
return content ? `~~${content}~~` : "";
|
|
}
|
|
|
|
if (tagName === "CODE") {
|
|
const content = normalizeText(node.textContent || "");
|
|
return content ? `\`${content.replace(/`/gu, "\\`")}\`` : "";
|
|
}
|
|
|
|
return renderInlineChildren(node, context);
|
|
}
|
|
|
|
function renderInlineChildren(element, context) {
|
|
const parts = [];
|
|
|
|
getReadableChildNodes(element).forEach((childNode) => {
|
|
const renderedChild = renderInlineNode(childNode, context);
|
|
if (renderedChild) {
|
|
parts.push(renderedChild);
|
|
}
|
|
});
|
|
|
|
return joinInlineParts(parts);
|
|
}
|
|
|
|
function renderParagraph(element, context) {
|
|
return renderInlineChildren(element, context);
|
|
}
|
|
|
|
function renderHeading(element, context) {
|
|
const level = Math.min(6, Math.max(1, Number.parseInt(getTagName(element).slice(1), 10) || 1));
|
|
const content = renderInlineChildren(element, context);
|
|
return content ? `${"#".repeat(level)} ${content}` : "";
|
|
}
|
|
|
|
function renderCodeBlock(element) {
|
|
const content = String(element.textContent || "").trimEnd();
|
|
if (!content) {
|
|
return "";
|
|
}
|
|
|
|
return `\`\`\`\n${content.replace(/```/gu, "\\`\\`\\`")}\n\`\`\``;
|
|
}
|
|
|
|
function renderBlockquote(element, context) {
|
|
const content = renderBlockChildren(element, context);
|
|
if (!content) {
|
|
return "";
|
|
}
|
|
|
|
return content
|
|
.split("\n")
|
|
.map((line) => `> ${line}`)
|
|
.join("\n");
|
|
}
|
|
|
|
function renderListItem(element, context, depth, index, ordered) {
|
|
const includeListMarkers = context.options.includeListMarkers === true;
|
|
const includeListIndentation = context.options.includeListIndentation !== false;
|
|
const marker = includeListMarkers ? (ordered ? `${index + 1}.` : "-") : "";
|
|
const indentation = includeListIndentation ? " ".repeat(Math.max(0, depth)) : "";
|
|
const inlineParts = [];
|
|
const nestedBlocks = [];
|
|
|
|
getReadableChildNodes(element).forEach((childNode) => {
|
|
if (isElementNode(childNode) && (getTagName(childNode) === "UL" || getTagName(childNode) === "OL")) {
|
|
const nestedList = renderList(childNode, context, depth + 1);
|
|
if (nestedList) {
|
|
nestedBlocks.push(nestedList);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const renderedChild = renderInlineNode(childNode, context);
|
|
if (renderedChild) {
|
|
inlineParts.push(renderedChild);
|
|
}
|
|
});
|
|
|
|
const head = joinInlineParts(inlineParts);
|
|
const linePrefix = marker ? `${indentation}${marker} ` : indentation;
|
|
const lines = [`${linePrefix}${head || "(empty)"}`];
|
|
nestedBlocks.forEach((nestedBlock) => {
|
|
lines.push(indentBlock(nestedBlock, includeListIndentation ? 1 : 0));
|
|
});
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function renderList(element, context, depth = 0) {
|
|
const ordered = getTagName(element) === "OL";
|
|
return getReadableElementChildren(element)
|
|
.filter((child) => getTagName(child) === "LI" && !isHiddenElement(child))
|
|
.map((item, index) => renderListItem(item, context, depth, index, ordered))
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
}
|
|
|
|
function renderTableCell(element, context) {
|
|
return renderInlineChildren(element, context);
|
|
}
|
|
|
|
function renderTable(element, context) {
|
|
const rows = [...element.querySelectorAll?.(":scope > thead > tr, :scope > tbody > tr, :scope > tr, :scope > tfoot > tr") || []]
|
|
.filter((row) => getTagName(row) === "TR");
|
|
|
|
if (!rows.length) {
|
|
return "";
|
|
}
|
|
|
|
const renderedRows = rows.map((row) => {
|
|
return [...row.children]
|
|
.filter((cell) => ["TD", "TH"].includes(getTagName(cell)) && !isHiddenElement(cell))
|
|
.map((cell) => renderTableCell(cell, context));
|
|
}).filter((cells) => cells.length);
|
|
|
|
if (!renderedRows.length) {
|
|
return "";
|
|
}
|
|
|
|
const columnCount = Math.max(...renderedRows.map((cells) => cells.length));
|
|
const normalizedRows = renderedRows.map((cells) => {
|
|
const nextCells = cells.slice();
|
|
while (nextCells.length < columnCount) {
|
|
nextCells.push("");
|
|
}
|
|
return nextCells;
|
|
});
|
|
|
|
const headerRow = normalizedRows[0];
|
|
const separatorRow = headerRow.map(() => "---");
|
|
const tableLines = [
|
|
`| ${headerRow.join(" | ")} |`,
|
|
`| ${separatorRow.join(" | ")} |`
|
|
];
|
|
|
|
normalizedRows.slice(1).forEach((row) => {
|
|
tableLines.push(`| ${row.join(" | ")} |`);
|
|
});
|
|
|
|
return tableLines.join("\n");
|
|
}
|
|
|
|
function renderGenericContainer(element, context) {
|
|
return renderBlockChildren(element, context);
|
|
}
|
|
|
|
function renderElementAsBlock(element, context) {
|
|
if (!isElementNode(element) || isHiddenElement(element)) {
|
|
return "";
|
|
}
|
|
|
|
if (isReferenceableElement(element)) {
|
|
return renderReference(element, context);
|
|
}
|
|
|
|
const tagName = getTagName(element);
|
|
|
|
if (tagName === "LABEL" && (element.getAttribute?.("for") || element.querySelector?.("input, textarea, select, button"))) {
|
|
return renderControlLabelReferences(element, context);
|
|
}
|
|
|
|
if (/^H[1-6]$/u.test(tagName)) {
|
|
return renderHeading(element, context);
|
|
}
|
|
|
|
if (tagName === "P") {
|
|
return renderParagraph(element, context);
|
|
}
|
|
|
|
if (tagName === "PRE") {
|
|
return renderCodeBlock(element);
|
|
}
|
|
|
|
if (tagName === "BLOCKQUOTE") {
|
|
return renderBlockquote(element, context);
|
|
}
|
|
|
|
if (tagName === "UL" || tagName === "OL") {
|
|
return renderList(element, context);
|
|
}
|
|
|
|
if (tagName === "TABLE") {
|
|
return renderTable(element, context);
|
|
}
|
|
|
|
if (tagName === "HR") {
|
|
return "---";
|
|
}
|
|
|
|
return renderGenericContainer(element, context);
|
|
}
|
|
|
|
function renderBlockChildren(element, context) {
|
|
const blocks = [];
|
|
const inlineParts = [];
|
|
|
|
const flushInlineParts = () => {
|
|
const inlineText = joinInlineParts(inlineParts.splice(0, inlineParts.length));
|
|
if (inlineText) {
|
|
blocks.push(inlineText);
|
|
}
|
|
};
|
|
|
|
getReadableChildNodes(element).forEach((childNode) => {
|
|
if (isTextNode(childNode)) {
|
|
const rawTextContent = normalizeText(childNode.textContent || "");
|
|
if (shouldDropReadableText(rawTextContent)) {
|
|
return;
|
|
}
|
|
|
|
const textContent = escapeMarkdownText(rawTextContent);
|
|
if (textContent) {
|
|
inlineParts.push(textContent);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!isElementNode(childNode) || isHiddenElement(childNode)) {
|
|
return;
|
|
}
|
|
|
|
const renderedChild = renderElementAsBlock(childNode, context);
|
|
if (!renderedChild) {
|
|
return;
|
|
}
|
|
|
|
if (isBlockElement(childNode) || isReferenceableElement(childNode)) {
|
|
flushInlineParts();
|
|
blocks.push(renderedChild);
|
|
return;
|
|
}
|
|
|
|
inlineParts.push(renderedChild);
|
|
});
|
|
|
|
flushInlineParts();
|
|
return joinBlocks(blocks);
|
|
}
|
|
|
|
function createCaptureContext(payload = null) {
|
|
return {
|
|
entries: new Map(),
|
|
nextReferenceId: 1,
|
|
options: {
|
|
includeLabelQuotes: normalizeIncludeLabelQuotes(payload),
|
|
includeLinkUrls: normalizeIncludeLinkUrls(payload),
|
|
includeSemanticTags: normalizeIncludeSemanticTags(payload),
|
|
includeStateTags: normalizeIncludeStateTags(payload),
|
|
includeListIndentation: normalizeIncludeListIndentation(payload),
|
|
includeListMarkers: normalizeIncludeListMarkers(payload)
|
|
},
|
|
referenceIdsByElement: new WeakMap()
|
|
};
|
|
}
|
|
|
|
function resolveSelectorTargets(payload, doc = globalThis.document) {
|
|
const selectors = normalizeSelectorList(payload);
|
|
if (!selectors.length) {
|
|
return {
|
|
includeMetaData: true,
|
|
items: [
|
|
{
|
|
key: "document",
|
|
targets: [doc?.body || doc?.documentElement].filter(Boolean)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
return {
|
|
includeMetaData: false,
|
|
items: selectors.map((selector) => {
|
|
let targets = [];
|
|
try {
|
|
targets = doc === globalThis.document
|
|
? querySelectorAllDeep(selector, doc)
|
|
: [...(doc?.querySelectorAll?.(selector) || [])];
|
|
} catch (error) {
|
|
throw createNamedError(
|
|
"BrowserPageContentSelectorError",
|
|
`Browser page content could not resolve selector "${selector}".`,
|
|
{
|
|
code: "browser_page_content_selector_error",
|
|
details: {
|
|
selector
|
|
},
|
|
cause: error
|
|
}
|
|
);
|
|
}
|
|
|
|
return {
|
|
key: selector,
|
|
targets
|
|
};
|
|
})
|
|
};
|
|
}
|
|
|
|
function parseSnapshotFragment(html, parser) {
|
|
return parser.parseFromString(
|
|
`<!DOCTYPE html><html><body>${String(html || "")}</body></html>`,
|
|
"text/html"
|
|
);
|
|
}
|
|
|
|
function renderSnapshotFragment(html, captureContext, parser) {
|
|
const parsedDocument = parseSnapshotFragment(html, parser);
|
|
const blocks = [];
|
|
const inlineParts = [];
|
|
|
|
const flushInlineParts = () => {
|
|
const inlineText = joinInlineParts(inlineParts.splice(0, inlineParts.length));
|
|
if (inlineText) {
|
|
blocks.push(inlineText);
|
|
}
|
|
};
|
|
|
|
parsedDocument.body.childNodes.forEach((childNode) => {
|
|
if (isTextNode(childNode)) {
|
|
const rawTextContent = normalizeText(childNode.textContent || "");
|
|
if (shouldDropReadableText(rawTextContent)) {
|
|
return;
|
|
}
|
|
|
|
const textContent = escapeMarkdownText(rawTextContent);
|
|
if (textContent) {
|
|
inlineParts.push(textContent);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!isElementNode(childNode) || isHiddenElement(childNode)) {
|
|
return;
|
|
}
|
|
|
|
const renderedChild = renderElementAsBlock(childNode, captureContext);
|
|
if (!renderedChild) {
|
|
return;
|
|
}
|
|
|
|
if (isBlockElement(childNode) || isReferenceableElement(childNode)) {
|
|
flushInlineParts();
|
|
blocks.push(renderedChild);
|
|
return;
|
|
}
|
|
|
|
inlineParts.push(renderedChild);
|
|
});
|
|
|
|
flushInlineParts();
|
|
return cleanReadableMarkdown(joinBlocks(blocks));
|
|
}
|
|
|
|
function captureLive(payload = null) {
|
|
const captureContext = createCaptureContext(payload);
|
|
const resolvedTargets = resolveSelectorTargets(payload);
|
|
const snapshot = {};
|
|
|
|
resolvedTargets.items.forEach((item) => {
|
|
const blocks = [];
|
|
if (resolvedTargets.includeMetaData && item.key === "document") {
|
|
const meta = collectMetaLines(globalThis.document);
|
|
if (meta) {
|
|
blocks.push(meta);
|
|
}
|
|
}
|
|
|
|
item.targets.forEach((target) => {
|
|
const renderedTarget = renderElementAsBlock(target, captureContext);
|
|
if (renderedTarget) {
|
|
blocks.push(renderedTarget);
|
|
}
|
|
});
|
|
|
|
snapshot[item.key] = cleanReadableMarkdown(joinBlocks(blocks));
|
|
});
|
|
|
|
state.captureId += 1;
|
|
state.capturedAt = Date.now();
|
|
state.backend = "live";
|
|
state.captureOptions = { ...captureContext.options };
|
|
state.entries = captureContext.entries;
|
|
return snapshot;
|
|
}
|
|
|
|
async function captureWithDomHelper(payload = null) {
|
|
const helper = requireDomHelper("capture content");
|
|
const selectors = normalizeSelectorList(payload);
|
|
const helperPayload = {
|
|
snapshotMode: "content"
|
|
};
|
|
if (selectors.length) {
|
|
helperPayload.selectors = selectors;
|
|
}
|
|
const documentSnapshot = await helper.captureDocument({
|
|
...helperPayload
|
|
});
|
|
const snapshot = {};
|
|
const parser = new globalThis.DOMParser();
|
|
const captureContext = createCaptureContext(payload);
|
|
try {
|
|
if (selectors.length && documentSnapshot?.targets && typeof documentSnapshot.targets === "object") {
|
|
selectors.forEach((selector) => {
|
|
snapshot[selector] = renderSnapshotFragment(documentSnapshot.targets?.[selector] || "", captureContext, parser);
|
|
});
|
|
|
|
state.captureId += 1;
|
|
state.capturedAt = Date.now();
|
|
state.backend = "dom_helper";
|
|
state.captureOptions = { ...captureContext.options };
|
|
state.entries = captureContext.entries;
|
|
return snapshot;
|
|
}
|
|
|
|
const parsedDocument = parser.parseFromString(String(documentSnapshot?.html || ""), "text/html");
|
|
const resolvedTargets = resolveSelectorTargets(payload, parsedDocument);
|
|
|
|
resolvedTargets.items.forEach((item) => {
|
|
const blocks = [];
|
|
if (resolvedTargets.includeMetaData && item.key === "document") {
|
|
const meta = collectMetaLines(parsedDocument);
|
|
if (meta) {
|
|
blocks.push(meta);
|
|
}
|
|
}
|
|
|
|
item.targets.forEach((target) => {
|
|
const renderedTarget = renderElementAsBlock(target, captureContext);
|
|
if (renderedTarget) {
|
|
blocks.push(renderedTarget);
|
|
}
|
|
});
|
|
|
|
snapshot[item.key] = cleanReadableMarkdown(joinBlocks(blocks));
|
|
});
|
|
|
|
state.captureId += 1;
|
|
state.capturedAt = Date.now();
|
|
state.backend = "dom_helper";
|
|
state.captureOptions = { ...captureContext.options };
|
|
state.entries = captureContext.entries;
|
|
return snapshot;
|
|
} catch (error) {
|
|
if (!isTrustedHtmlRequirementError(error)) {
|
|
throw error;
|
|
}
|
|
|
|
return captureLive(payload);
|
|
}
|
|
}
|
|
|
|
async function capture(payload = null) {
|
|
if (getDomHelper()) {
|
|
return captureWithDomHelper(payload);
|
|
}
|
|
|
|
return captureLive(payload);
|
|
}
|
|
|
|
function detailLive(entry) {
|
|
const liveState = entry.connected && entry.element
|
|
? collectElementStateMetadata(entry.element, state.captureOptions)
|
|
: entry.state || collectElementStateMetadata(null);
|
|
return {
|
|
captureId: state.captureId,
|
|
capturedAt: state.capturedAt,
|
|
connected: entry.connected,
|
|
descriptorTags: liveState.descriptorTags,
|
|
dom: entry.connected ? serializeElementSnapshot(entry.element) || entry.dom : entry.dom,
|
|
referenceId: entry.referenceId,
|
|
semanticTags: liveState.semanticTags,
|
|
state: liveState,
|
|
summary: entry.summary,
|
|
tagName: entry.tagName
|
|
};
|
|
}
|
|
|
|
async function detail(referenceId) {
|
|
const entry = requireReferenceEntry(referenceId, {
|
|
actionLabel: "detail",
|
|
requireConnected: false
|
|
});
|
|
|
|
if (entry.helperBacked) {
|
|
const helper = requireDomHelper("resolve detail");
|
|
const resolvedDetail = await helper.detailNode(entry.frameChain, entry.nodeId);
|
|
return {
|
|
captureId: state.captureId,
|
|
capturedAt: state.capturedAt,
|
|
connected: resolvedDetail?.connected !== false,
|
|
descriptorTags: Array.isArray(resolvedDetail?.descriptorTags) ? resolvedDetail.descriptorTags : (entry.descriptorTags || []),
|
|
dom: String(resolvedDetail?.dom || entry.dom || ""),
|
|
frameChain: entry.frameChain.slice(),
|
|
frameId: entry.frameId,
|
|
nodeId: entry.nodeId,
|
|
referenceId: entry.referenceId,
|
|
semanticTags: Array.isArray(resolvedDetail?.semanticTags) ? resolvedDetail.semanticTags : (entry.semanticTags || []),
|
|
state: resolvedDetail?.state || entry.state || collectElementStateMetadata(null),
|
|
summary: entry.summary,
|
|
tagName: String(resolvedDetail?.tagName || entry.tagName || "")
|
|
};
|
|
}
|
|
|
|
return detailLive(entry);
|
|
}
|
|
|
|
function requireReferenceEntry(referenceId, options = {}) {
|
|
const normalizedReferenceId = normalizeReferenceId(referenceId);
|
|
if (!normalizedReferenceId) {
|
|
throw createNamedError(
|
|
"BrowserPageContentReferenceError",
|
|
"Browser page content requests require a reference id.",
|
|
{
|
|
code: "browser_page_content_reference_required",
|
|
details: {
|
|
action: String(options.actionLabel || "resolve")
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
if (!state.entries.size) {
|
|
throw createNamedError(
|
|
"BrowserPageContentReferenceError",
|
|
`Browser page content has no reference capture for "${normalizedReferenceId}".`,
|
|
{
|
|
code: "browser_page_content_reference_missing_capture",
|
|
details: {
|
|
action: String(options.actionLabel || "resolve"),
|
|
referenceId: normalizedReferenceId
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const entry = state.entries.get(normalizedReferenceId);
|
|
if (!entry) {
|
|
throw createNamedError(
|
|
"BrowserPageContentReferenceError",
|
|
`Browser page content could not find reference "${normalizedReferenceId}".`,
|
|
{
|
|
code: "browser_page_content_reference_not_found",
|
|
details: {
|
|
action: String(options.actionLabel || "resolve"),
|
|
referenceId: normalizedReferenceId
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
refreshReferenceEntry(entry);
|
|
|
|
if (options.requireConnected !== false && !entry.connected) {
|
|
throw createNamedError(
|
|
"BrowserPageContentReferenceError",
|
|
`Browser page content reference "${normalizedReferenceId}" is no longer connected.`,
|
|
{
|
|
code: "browser_page_content_reference_disconnected",
|
|
details: {
|
|
action: String(options.actionLabel || "resolve"),
|
|
referenceId: normalizedReferenceId
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
return entry;
|
|
}
|
|
|
|
function computeStableSelector(el) {
|
|
if (!el || el.nodeType !== 1) return null;
|
|
const doc = el.ownerDocument || document;
|
|
if (el.id && /^[A-Za-z_][\w-]*$/.test(el.id)) {
|
|
const sel = "#" + (typeof CSS !== "undefined" && CSS.escape ? CSS.escape(el.id) : el.id);
|
|
try {
|
|
if (doc.querySelectorAll(sel).length === 1) return sel;
|
|
} catch (_) {}
|
|
}
|
|
const parts = [];
|
|
let node = el;
|
|
while (node && node.nodeType === 1 && node !== doc.documentElement) {
|
|
let part = node.tagName.toLowerCase();
|
|
if (node.id && /^[A-Za-z_][\w-]*$/.test(node.id)) {
|
|
const idSel = "#" + (typeof CSS !== "undefined" && CSS.escape ? CSS.escape(node.id) : node.id);
|
|
try {
|
|
if (doc.querySelectorAll(idSel).length === 1) {
|
|
parts.unshift(idSel);
|
|
break;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
const parent = node.parentElement;
|
|
if (parent) {
|
|
const sibs = parent.children;
|
|
let idx = 0;
|
|
let sameTag = 0;
|
|
for (let i = 0; i < sibs.length; i++) {
|
|
if (sibs[i].tagName === node.tagName) {
|
|
sameTag++;
|
|
if (sibs[i] === node) idx = sameTag;
|
|
}
|
|
}
|
|
if (sameTag > 1) part += ":nth-of-type(" + idx + ")";
|
|
}
|
|
parts.unshift(part);
|
|
node = parent;
|
|
}
|
|
const sel = parts.join(" > ");
|
|
if (!sel) return null;
|
|
try {
|
|
if (doc.querySelectorAll(sel).length === 1) return sel;
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
function boundingBoxFor(referenceId) {
|
|
const entry = requireReferenceEntry(referenceId, {
|
|
actionLabel: "boundingBox",
|
|
requireConnected: false
|
|
});
|
|
if (entry.helperBacked || !entry.element) return null;
|
|
const el = entry.element;
|
|
if (typeof el.getBoundingClientRect !== "function") return null;
|
|
try {
|
|
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
} catch (_) {}
|
|
const r = el.getBoundingClientRect();
|
|
const selector = computeStableSelector(el);
|
|
const hasBox = r && r.width > 0 && r.height > 0;
|
|
if (!hasBox && !selector) return null;
|
|
return {
|
|
x: hasBox ? r.left : 0,
|
|
y: hasBox ? r.top : 0,
|
|
width: hasBox ? r.width : 0,
|
|
height: hasBox ? r.height : 0,
|
|
selector: selector || null
|
|
};
|
|
}
|
|
|
|
function pointFor(referenceId, offsets = {}) {
|
|
const entry = requireReferenceEntry(referenceId, {
|
|
actionLabel: "point",
|
|
requireConnected: true
|
|
});
|
|
if (entry.helperBacked || !entry.element) {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content cannot resolve point for helper-backed reference "${entry.referenceId}".`,
|
|
{
|
|
code: "browser_page_content_point_helper_backed"
|
|
}
|
|
);
|
|
}
|
|
|
|
const element = entry.element;
|
|
scrollElementIntoView(element);
|
|
const rect = getElementRectSafe(element);
|
|
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content reference "${entry.referenceId}" has no visible viewport box.`,
|
|
{
|
|
code: "browser_page_content_point_no_box"
|
|
}
|
|
);
|
|
}
|
|
|
|
const offsetX = Number(offsets?.offset_x ?? offsets?.offsetX ?? 0) || 0;
|
|
const offsetY = Number(offsets?.offset_y ?? offsets?.offsetY ?? 0) || 0;
|
|
const useOffsets = offsets?.useOffsets === true || offsetX !== 0 || offsetY !== 0;
|
|
return {
|
|
rect,
|
|
selector: computeStableSelector(element),
|
|
x: rect.x + (useOffsets ? offsetX : rect.width / 2),
|
|
y: rect.y + (useOffsets ? offsetY : rect.height / 2)
|
|
};
|
|
}
|
|
|
|
function normalizeActionValues(valueOrValues) {
|
|
if (Array.isArray(valueOrValues)) {
|
|
return valueOrValues.map((value) => String(value ?? ""));
|
|
}
|
|
|
|
if (valueOrValues === null || valueOrValues === undefined) {
|
|
return [];
|
|
}
|
|
|
|
return [String(valueOrValues)];
|
|
}
|
|
|
|
function optionMatchesValue(option, value) {
|
|
const normalizedValue = normalizeText(value);
|
|
const candidates = [
|
|
option?.value,
|
|
option?.label,
|
|
option?.textContent,
|
|
option?.getAttribute?.("aria-label"),
|
|
option?.getAttribute?.("data-value"),
|
|
option?.getAttribute?.("id")
|
|
].map((candidate) => normalizeText(candidate));
|
|
return candidates.some((candidate) => candidate === normalizedValue);
|
|
}
|
|
|
|
function findNativeSelectOption(selectElement, value) {
|
|
const options = [...(selectElement.options || [])];
|
|
return options.find((option) => optionMatchesValue(option, value)) || null;
|
|
}
|
|
|
|
function setNativeChecked(element, checked) {
|
|
const descriptor = Object.getOwnPropertyDescriptor(globalThis.HTMLInputElement?.prototype || {}, "checked");
|
|
if (typeof descriptor?.set === "function") {
|
|
descriptor.set.call(element, Boolean(checked));
|
|
} else {
|
|
element.checked = Boolean(checked);
|
|
}
|
|
}
|
|
|
|
async function selectNativeElement(entry, values) {
|
|
const element = entry.element;
|
|
const beforeSnapshot = captureActionEffectSnapshot(element);
|
|
const requestedValues = values.length ? values : [""];
|
|
const appliedValues = [];
|
|
|
|
const {
|
|
observedMutations
|
|
} = await withObservedActionWindow(beforeSnapshot.observationRoot, async () => {
|
|
scrollElementIntoView(element);
|
|
focusElement(element);
|
|
|
|
if (element.multiple) {
|
|
const matchedOptions = requestedValues.map((requestedValue) => {
|
|
const option = findNativeSelectOption(element, requestedValue);
|
|
if (!option) {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content could not find select option "${requestedValue}".`,
|
|
{
|
|
code: "browser_page_content_select_option_not_found"
|
|
}
|
|
);
|
|
}
|
|
return option;
|
|
});
|
|
const matchedSet = new Set(matchedOptions);
|
|
[...(element.options || [])].forEach((option) => {
|
|
option.selected = matchedSet.has(option);
|
|
});
|
|
matchedOptions.forEach((option) => appliedValues.push(option.value));
|
|
} else {
|
|
const option = findNativeSelectOption(element, requestedValues[0]);
|
|
if (!option) {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content could not find select option "${requestedValues[0]}".`,
|
|
{
|
|
code: "browser_page_content_select_option_not_found"
|
|
}
|
|
);
|
|
}
|
|
appliedValues.push(setNativeValue(element, option.value));
|
|
}
|
|
|
|
dispatchDomEvent(element, "input", "InputEvent", {
|
|
inputType: "insertReplacementText"
|
|
});
|
|
dispatchDomEvent(element, "change");
|
|
return appliedValues.slice();
|
|
});
|
|
|
|
refreshReferenceEntry(entry);
|
|
return buildActionResult(entry, {
|
|
...buildActionEffectResult(entry, beforeSnapshot, captureActionEffectSnapshot(element), observedMutations),
|
|
values: appliedValues.slice()
|
|
});
|
|
}
|
|
|
|
function ariaOptionMatchesValue(option, value) {
|
|
const normalizedValue = normalizeText(value);
|
|
const candidates = [
|
|
option?.getAttribute?.("aria-label"),
|
|
option?.getAttribute?.("data-value"),
|
|
option?.getAttribute?.("value"),
|
|
option?.getAttribute?.("id"),
|
|
getElementText(option)
|
|
].map((candidate) => normalizeText(candidate));
|
|
return candidates.some((candidate) => candidate === normalizedValue);
|
|
}
|
|
|
|
function visibleAriaOptions(root) {
|
|
const scope = isElementNode(root) && String(root.getAttribute?.("role") || "").trim().toLowerCase() === "listbox"
|
|
? root
|
|
: globalThis.document;
|
|
try {
|
|
return [...(scope.querySelectorAll?.("[role='option']") || [])]
|
|
.filter((option) => isElementNode(option) && !isHiddenElement(option));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function findAriaOption(root, value) {
|
|
const matches = visibleAriaOptions(root).filter((option) => ariaOptionMatchesValue(option, value));
|
|
return matches.length === 1 ? matches[0] : null;
|
|
}
|
|
|
|
async function selectAriaElement(entry, values) {
|
|
const element = entry.element;
|
|
const role = String(element.getAttribute?.("role") || "").trim().toLowerCase();
|
|
if (!["combobox", "listbox"].includes(role)) {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content cannot select options on <${getTagName(element).toLowerCase()}>.`,
|
|
{
|
|
code: "browser_page_content_select_unsupported"
|
|
}
|
|
);
|
|
}
|
|
|
|
const beforeSnapshot = captureActionEffectSnapshot(element);
|
|
const requestedValues = values.length ? values : [""];
|
|
const appliedValues = [];
|
|
const {
|
|
observedMutations
|
|
} = await withObservedActionWindow(beforeSnapshot.observationRoot, async () => {
|
|
scrollElementIntoView(element);
|
|
focusElement(element);
|
|
if (role === "combobox") {
|
|
dispatchDomEvent(element, "mousedown", "MouseEvent", { button: 0 });
|
|
if (typeof element.click === "function") {
|
|
element.click();
|
|
} else {
|
|
dispatchDomEvent(element, "click", "MouseEvent", { button: 0 });
|
|
}
|
|
await delayMs(80);
|
|
}
|
|
|
|
for (const requestedValue of requestedValues) {
|
|
const option = findAriaOption(element, requestedValue);
|
|
if (!option) {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content could not safely find one ARIA option "${requestedValue}".`,
|
|
{
|
|
code: "browser_page_content_aria_option_not_found"
|
|
}
|
|
);
|
|
}
|
|
scrollElementIntoView(option);
|
|
dispatchDomEvent(option, "mousedown", "MouseEvent", { button: 0 });
|
|
if (typeof option.click === "function") {
|
|
option.click();
|
|
} else {
|
|
dispatchDomEvent(option, "click", "MouseEvent", { button: 0 });
|
|
}
|
|
appliedValues.push(requestedValue);
|
|
await delayMs(40);
|
|
}
|
|
});
|
|
|
|
refreshReferenceEntry(entry);
|
|
return buildActionResult(entry, {
|
|
...buildActionEffectResult(entry, beforeSnapshot, captureActionEffectSnapshot(element), observedMutations),
|
|
values: appliedValues.slice()
|
|
});
|
|
}
|
|
|
|
async function selectReference(referenceId, valueOrValues) {
|
|
const entry = requireReferenceEntry(referenceId, {
|
|
actionLabel: "select"
|
|
});
|
|
if (entry.helperBacked) {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content cannot select helper-backed reference "${entry.referenceId}".`,
|
|
{
|
|
code: "browser_page_content_select_helper_backed"
|
|
}
|
|
);
|
|
}
|
|
|
|
const element = entry.element;
|
|
const values = normalizeActionValues(valueOrValues);
|
|
if (getTagName(element) === "SELECT") {
|
|
return selectNativeElement(entry, values);
|
|
}
|
|
|
|
return selectAriaElement(entry, values);
|
|
}
|
|
|
|
function checkedStateForElement(element) {
|
|
const tagName = getTagName(element);
|
|
const role = String(element.getAttribute?.("role") || "").trim().toLowerCase();
|
|
if (tagName === "INPUT") {
|
|
const inputType = String(element.getAttribute?.("type") || element.type || "").toLowerCase();
|
|
if (["checkbox", "radio"].includes(inputType)) {
|
|
return Boolean(element.checked);
|
|
}
|
|
}
|
|
if (["checkbox", "radio", "switch", "menuitemcheckbox", "menuitemradio"].includes(role)) {
|
|
return String(element.getAttribute?.("aria-checked") || "").trim().toLowerCase() === "true";
|
|
}
|
|
if (role === "button" && element.hasAttribute?.("aria-pressed")) {
|
|
return String(element.getAttribute?.("aria-pressed") || "").trim().toLowerCase() === "true";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function setCheckedReference(referenceId, checked = true) {
|
|
const entry = requireReferenceEntry(referenceId, {
|
|
actionLabel: "setChecked"
|
|
});
|
|
if (entry.helperBacked) {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content cannot set helper-backed reference "${entry.referenceId}".`,
|
|
{
|
|
code: "browser_page_content_checked_helper_backed"
|
|
}
|
|
);
|
|
}
|
|
|
|
const element = entry.element;
|
|
const beforeSnapshot = captureActionEffectSnapshot(element);
|
|
const desiredChecked = Boolean(checked);
|
|
const tagName = getTagName(element);
|
|
const role = String(element.getAttribute?.("role") || "").trim().toLowerCase();
|
|
const currentChecked = checkedStateForElement(element);
|
|
if (currentChecked === null) {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content cannot set checked state on <${tagName.toLowerCase()}>.`,
|
|
{
|
|
code: "browser_page_content_checked_unsupported"
|
|
}
|
|
);
|
|
}
|
|
|
|
const {
|
|
observedMutations
|
|
} = await withObservedActionWindow(beforeSnapshot.observationRoot, async () => {
|
|
scrollElementIntoView(element);
|
|
focusElement(element);
|
|
|
|
if (tagName === "INPUT") {
|
|
setNativeChecked(element, desiredChecked);
|
|
dispatchDomEvent(element, "input", "InputEvent", {
|
|
inputType: "insertReplacementText"
|
|
});
|
|
dispatchDomEvent(element, "change");
|
|
} else if (["checkbox", "radio", "switch", "menuitemcheckbox", "menuitemradio"].includes(role)) {
|
|
if (currentChecked !== desiredChecked) {
|
|
if (typeof element.click === "function") {
|
|
element.click();
|
|
} else {
|
|
dispatchDomEvent(element, "click", "MouseEvent", {
|
|
button: 0
|
|
});
|
|
}
|
|
await delayMs(40);
|
|
}
|
|
if (checkedStateForElement(element) !== desiredChecked) {
|
|
element.setAttribute("aria-checked", desiredChecked ? "true" : "false");
|
|
dispatchDomEvent(element, "input", "InputEvent", {
|
|
inputType: "insertReplacementText"
|
|
});
|
|
dispatchDomEvent(element, "change");
|
|
}
|
|
} else if (role === "button" && element.hasAttribute?.("aria-pressed")) {
|
|
if (currentChecked !== desiredChecked) {
|
|
if (typeof element.click === "function") {
|
|
element.click();
|
|
} else {
|
|
dispatchDomEvent(element, "click", "MouseEvent", {
|
|
button: 0
|
|
});
|
|
}
|
|
await delayMs(40);
|
|
}
|
|
if (checkedStateForElement(element) !== desiredChecked) {
|
|
element.setAttribute("aria-pressed", desiredChecked ? "true" : "false");
|
|
}
|
|
}
|
|
});
|
|
|
|
refreshReferenceEntry(entry);
|
|
return buildActionResult(entry, {
|
|
...buildActionEffectResult(entry, beforeSnapshot, captureActionEffectSnapshot(element), observedMutations),
|
|
checked: desiredChecked
|
|
});
|
|
}
|
|
|
|
function resolveFileInputElement(referenceId) {
|
|
const entry = requireReferenceEntry(referenceId, {
|
|
actionLabel: "fileInput"
|
|
});
|
|
if (entry.helperBacked) {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content cannot upload files through helper-backed reference "${entry.referenceId}".`,
|
|
{
|
|
code: "browser_page_content_file_input_helper_backed"
|
|
}
|
|
);
|
|
}
|
|
|
|
const element = entry.element;
|
|
const isFileInput = (candidate) => {
|
|
return getTagName(candidate) === "INPUT"
|
|
&& String(candidate.getAttribute?.("type") || candidate.type || "").toLowerCase() === "file";
|
|
};
|
|
|
|
if (isFileInput(element)) {
|
|
return element;
|
|
}
|
|
|
|
if (getTagName(element) === "LABEL") {
|
|
if (isFileInput(element.control)) {
|
|
return element.control;
|
|
}
|
|
const labelledInput = element.querySelector?.("input[type='file']");
|
|
if (isFileInput(labelledInput)) {
|
|
return labelledInput;
|
|
}
|
|
const forId = normalizeAttributeText(element.getAttribute?.("for"));
|
|
if (forId) {
|
|
const byId = element.ownerDocument?.getElementById?.(forId);
|
|
if (isFileInput(byId)) {
|
|
return byId;
|
|
}
|
|
}
|
|
}
|
|
|
|
const descendantInput = element.querySelector?.("input[type='file']");
|
|
if (isFileInput(descendantInput)) {
|
|
return descendantInput;
|
|
}
|
|
|
|
const closestLabel = element.closest?.("label");
|
|
if (closestLabel) {
|
|
if (isFileInput(closestLabel.control)) {
|
|
return closestLabel.control;
|
|
}
|
|
const labelledInput = closestLabel.querySelector?.("input[type='file']");
|
|
if (isFileInput(labelledInput)) {
|
|
return labelledInput;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function fileInputFor(referenceId) {
|
|
const input = resolveFileInputElement(referenceId);
|
|
if (!input) {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content reference "${normalizeReferenceId(referenceId)}" is not a file input or associated label.`,
|
|
{
|
|
code: "browser_page_content_file_input_not_found"
|
|
}
|
|
);
|
|
}
|
|
return {
|
|
accept: normalizeAttributeText(input.getAttribute?.("accept")),
|
|
multiple: Boolean(input.multiple),
|
|
name: normalizeAttributeText(input.getAttribute?.("name")),
|
|
selector: computeStableSelector(input),
|
|
tagName: getTagName(input),
|
|
type: String(input.getAttribute?.("type") || input.type || "").toLowerCase()
|
|
};
|
|
}
|
|
|
|
function fileInputElementFor(referenceId) {
|
|
return resolveFileInputElement(referenceId);
|
|
}
|
|
|
|
function refreshReferenceEntry(entry) {
|
|
if (!entry || entry.helperBacked || !entry.element) {
|
|
return entry;
|
|
}
|
|
|
|
entry.connected = entry.element.isConnected !== false;
|
|
if (entry.connected) {
|
|
entry.dom = serializeElementSnapshot(entry.element) || entry.dom;
|
|
entry.id = normalizeAttributeText(entry.element.getAttribute?.("id"));
|
|
entry.name = normalizeAttributeText(entry.element.getAttribute?.("name"));
|
|
const summaryData = collectReferenceSummaryData(entry.element, state.captureOptions);
|
|
entry.descriptorTags = summaryData.descriptorTags;
|
|
entry.kind = summaryData.kind;
|
|
entry.semanticTags = summaryData.semanticTags;
|
|
entry.state = summaryData.state;
|
|
entry.summary = summaryData.summary;
|
|
entry.tagName = getTagName(entry.element);
|
|
}
|
|
|
|
return entry;
|
|
}
|
|
|
|
function scrollElementIntoView(element) {
|
|
try {
|
|
element.scrollIntoView?.({
|
|
behavior: "auto",
|
|
block: "center",
|
|
inline: "center"
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function focusElement(element) {
|
|
try {
|
|
element.focus?.({
|
|
preventScroll: true
|
|
});
|
|
return true;
|
|
} catch {
|
|
try {
|
|
element.focus?.();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function describeActiveElement(element) {
|
|
if (!isElementNode(element)) {
|
|
return "";
|
|
}
|
|
|
|
const tagName = getTagName(element).toLowerCase();
|
|
const id = normalizeAttributeText(element.getAttribute?.("id"));
|
|
const name = normalizeAttributeText(element.getAttribute?.("name"));
|
|
const label = truncateText(getLabelText(element, {
|
|
includeAlt: false,
|
|
includeDescendantImageAlt: true,
|
|
includePlaceholder: false,
|
|
includeText: false
|
|
}), 48);
|
|
return [tagName, id ? `#${id}` : "", name ? `name=${name}` : "", label].filter(Boolean).join(" ");
|
|
}
|
|
|
|
function getActionObservationRoot(element) {
|
|
if (!isElementNode(element)) {
|
|
return globalThis.document?.body || globalThis.document?.documentElement || null;
|
|
}
|
|
|
|
return element.closest?.("form, fieldset, dialog, [role='dialog'], [role='alert'], [role='status'], [aria-live], article, section, main, li, tr, td, th")
|
|
|| element.parentElement
|
|
|| element;
|
|
}
|
|
|
|
function getElementDirectText(element) {
|
|
if (!isElementNode(element)) {
|
|
return "";
|
|
}
|
|
|
|
return normalizeText(
|
|
[...(element.childNodes || [])]
|
|
.filter((node) => isTextNode(node))
|
|
.map((node) => node.textContent || "")
|
|
.join(" ")
|
|
);
|
|
}
|
|
|
|
function collectNearbyTextEntries(root, limit = 24) {
|
|
if (!isElementNode(root)) {
|
|
return [];
|
|
}
|
|
|
|
const entries = [];
|
|
const seen = new Set();
|
|
const acceptElement = (element) => {
|
|
if (!isElementNode(element) || isHiddenElement(element) || entries.length >= limit) {
|
|
return;
|
|
}
|
|
|
|
const role = normalizeText(element.getAttribute?.("role")).toLowerCase();
|
|
const directText = getElementDirectText(element);
|
|
const fallbackText = ["alert", "status"].includes(role) || element.hasAttribute?.("aria-live")
|
|
? getElementText(element)
|
|
: "";
|
|
const text = truncateText(directText || fallbackText, 220);
|
|
if (!text) {
|
|
return;
|
|
}
|
|
|
|
const key = `${role}|${text}`;
|
|
if (seen.has(key)) {
|
|
return;
|
|
}
|
|
seen.add(key);
|
|
const state = collectElementStateMetadata(element, {
|
|
includeSemanticTags: true,
|
|
includeStateTags: true
|
|
});
|
|
entries.push({
|
|
invalid: state.invalid === true,
|
|
role,
|
|
semanticTone: state.semanticTone || "",
|
|
text
|
|
});
|
|
};
|
|
|
|
acceptElement(root);
|
|
const walker = globalThis.document?.createTreeWalker?.(root, globalThis.NodeFilter?.SHOW_ELEMENT ?? 1);
|
|
if (!walker) {
|
|
return entries;
|
|
}
|
|
|
|
let currentNode = walker.nextNode();
|
|
while (currentNode && entries.length < limit) {
|
|
acceptElement(currentNode);
|
|
currentNode = walker.nextNode();
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function captureActionEffectSnapshot(element) {
|
|
const observationRoot = getActionObservationRoot(element);
|
|
return {
|
|
activeElement: describeActiveElement(globalThis.document?.activeElement),
|
|
observationRoot,
|
|
observationText: truncateText(getElementText(observationRoot), 2000),
|
|
targetDom: truncateText(serializeElementSnapshot(element), 2000),
|
|
targetState: collectElementStateMetadata(element, {
|
|
includeSemanticTags: true,
|
|
includeStateTags: true
|
|
}),
|
|
textEntries: collectNearbyTextEntries(observationRoot),
|
|
value: getReferenceValueMetadata(element)
|
|
};
|
|
}
|
|
|
|
async function waitForObservedActionWindow(observationRoot, {
|
|
quietMs = 40,
|
|
timeoutMs = 180
|
|
} = {}) {
|
|
const target = observationRoot?.ownerDocument?.body
|
|
|| observationRoot?.ownerDocument?.documentElement
|
|
|| globalThis.document?.body
|
|
|| globalThis.document?.documentElement;
|
|
if (!target || typeof globalThis.MutationObserver !== "function") {
|
|
await delayMs(timeoutMs);
|
|
return {
|
|
attributeNames: [],
|
|
mutationCount: 0
|
|
};
|
|
}
|
|
|
|
const attributeNames = new Set();
|
|
let lastMutationAt = 0;
|
|
let mutationCount = 0;
|
|
const observer = new globalThis.MutationObserver((mutations) => {
|
|
mutationCount += mutations.length;
|
|
lastMutationAt = Date.now();
|
|
mutations.forEach((mutation) => {
|
|
if (mutation.type === "attributes" && mutation.attributeName) {
|
|
attributeNames.add(String(mutation.attributeName));
|
|
}
|
|
});
|
|
});
|
|
|
|
try {
|
|
observer.observe(target, {
|
|
attributes: true,
|
|
characterData: true,
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
await delayMs(20);
|
|
if (mutationCount > 0 && Date.now() - lastMutationAt >= quietMs) {
|
|
break;
|
|
}
|
|
}
|
|
} finally {
|
|
observer.disconnect();
|
|
}
|
|
|
|
return {
|
|
attributeNames: [...attributeNames],
|
|
mutationCount
|
|
};
|
|
}
|
|
|
|
async function withObservedActionWindow(observationRoot, action, options = {}) {
|
|
const target = observationRoot?.ownerDocument?.body
|
|
|| observationRoot?.ownerDocument?.documentElement
|
|
|| globalThis.document?.body
|
|
|| globalThis.document?.documentElement;
|
|
if (!target || typeof globalThis.MutationObserver !== "function") {
|
|
const result = await action();
|
|
const observedMutations = await waitForObservedActionWindow(observationRoot, options);
|
|
return {
|
|
observedMutations,
|
|
result
|
|
};
|
|
}
|
|
|
|
const attributeNames = new Set();
|
|
let lastMutationAt = 0;
|
|
let mutationCount = 0;
|
|
const observer = new globalThis.MutationObserver((mutations) => {
|
|
mutationCount += mutations.length;
|
|
lastMutationAt = Date.now();
|
|
mutations.forEach((mutation) => {
|
|
if (mutation.type === "attributes" && mutation.attributeName) {
|
|
attributeNames.add(String(mutation.attributeName));
|
|
}
|
|
});
|
|
});
|
|
|
|
try {
|
|
observer.observe(target, {
|
|
attributes: true,
|
|
characterData: true,
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
const result = await action();
|
|
const quietMs = Math.max(0, Number(options.quietMs) || 40);
|
|
const timeoutMs = Math.max(0, Number(options.timeoutMs) || 180);
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
await delayMs(20);
|
|
if (mutationCount > 0 && Date.now() - lastMutationAt >= quietMs) {
|
|
break;
|
|
}
|
|
}
|
|
return {
|
|
observedMutations: {
|
|
attributeNames: [...attributeNames],
|
|
mutationCount
|
|
},
|
|
result
|
|
};
|
|
} finally {
|
|
observer.disconnect();
|
|
}
|
|
}
|
|
|
|
function compareDescriptorTags(beforeTags = [], afterTags = []) {
|
|
const beforeValue = beforeTags.filter(Boolean).join("|");
|
|
const afterValue = afterTags.filter(Boolean).join("|");
|
|
return beforeValue !== afterValue;
|
|
}
|
|
|
|
function buildActionEffectResult(entry, beforeSnapshot, afterSnapshot, observedMutations, extra = {}) {
|
|
const newTextEntries = afterSnapshot.textEntries.filter((entryData) => {
|
|
return !beforeSnapshot.textEntries.some((beforeEntry) => beforeEntry.text === entryData.text);
|
|
});
|
|
const validationEntries = newTextEntries.filter((entryData) => {
|
|
return entryData.invalid
|
|
|| ["alert", "status"].includes(entryData.role)
|
|
|| ["error", "warning"].includes(entryData.semanticTone);
|
|
});
|
|
const focusChanged = beforeSnapshot.activeElement !== afterSnapshot.activeElement;
|
|
const nearbyTextChanged = beforeSnapshot.observationText !== afterSnapshot.observationText;
|
|
const valueChanged = beforeSnapshot.value !== afterSnapshot.value;
|
|
const checkedChanged = beforeSnapshot.targetState.checked !== afterSnapshot.targetState.checked;
|
|
const selectedChanged = beforeSnapshot.targetState.selected !== afterSnapshot.targetState.selected;
|
|
const expandedChanged = beforeSnapshot.targetState.expanded !== afterSnapshot.targetState.expanded;
|
|
const pressedChanged = beforeSnapshot.targetState.pressed !== afterSnapshot.targetState.pressed;
|
|
const descriptorChanged = compareDescriptorTags(beforeSnapshot.targetState.descriptorTags, afterSnapshot.targetState.descriptorTags);
|
|
const targetDomChanged = beforeSnapshot.targetDom !== afterSnapshot.targetDom;
|
|
const domChanged = Boolean(observedMutations.mutationCount) || targetDomChanged || nearbyTextChanged;
|
|
const status = {
|
|
alertTextAdded: newTextEntries.some((entryData) => ["alert", "status"].includes(entryData.role)),
|
|
checkedChanged,
|
|
descriptorChanged,
|
|
domChanged,
|
|
expandedChanged,
|
|
focusChanged,
|
|
nearbyTextChanged,
|
|
pressedChanged,
|
|
reacted: false,
|
|
selectedChanged,
|
|
targetChanged: descriptorChanged || targetDomChanged || valueChanged || checkedChanged || selectedChanged || expandedChanged || pressedChanged,
|
|
targetDomChanged,
|
|
valueChanged,
|
|
validationTextAdded: validationEntries.length > 0
|
|
};
|
|
status.reacted = Object.entries(status).some(([key, value]) => key !== "reacted" && value === true);
|
|
status.noObservedEffect = !status.reacted;
|
|
|
|
return {
|
|
...extra,
|
|
descriptorTags: afterSnapshot.targetState.descriptorTags.slice(),
|
|
effect: {
|
|
mutationAttributes: observedMutations.attributeNames.slice(0, 8),
|
|
mutationCount: observedMutations.mutationCount,
|
|
newText: newTextEntries.map((entryData) => entryData.text).slice(0, 3),
|
|
semanticHints: [...new Set(newTextEntries.map((entryData) => entryData.semanticTone).filter(Boolean))].slice(0, 3),
|
|
validationText: validationEntries.map((entryData) => entryData.text).slice(0, 3)
|
|
},
|
|
semanticTags: afterSnapshot.targetState.semanticTags.slice(),
|
|
state: afterSnapshot.targetState,
|
|
status
|
|
};
|
|
}
|
|
|
|
function buildActionResult(entry, extra = {}) {
|
|
return {
|
|
captureId: state.captureId,
|
|
descriptorTags: Array.isArray(entry?.descriptorTags) ? entry.descriptorTags.slice() : [],
|
|
referenceId: entry.referenceId,
|
|
semanticTags: Array.isArray(entry?.semanticTags) ? entry.semanticTags.slice() : [],
|
|
state: entry.state || collectElementStateMetadata(entry.element, state.captureOptions),
|
|
summary: entry.summary,
|
|
tagName: entry.tagName,
|
|
...extra
|
|
};
|
|
}
|
|
|
|
function buildHelperBackedActionResult(entry, helperResult, extra = {}) {
|
|
return {
|
|
captureId: state.captureId,
|
|
descriptorTags: Array.isArray(helperResult?.descriptorTags) ? helperResult.descriptorTags : (entry.descriptorTags || []),
|
|
frameChain: entry.frameChain.slice(),
|
|
frameId: entry.frameId,
|
|
nodeId: entry.nodeId,
|
|
referenceId: entry.referenceId,
|
|
semanticTags: Array.isArray(helperResult?.semanticTags) ? helperResult.semanticTags : (entry.semanticTags || []),
|
|
state: helperResult?.state || entry.state || collectElementStateMetadata(null),
|
|
summary: entry.summary,
|
|
tagName: String(helperResult?.tagName || entry.tagName || ""),
|
|
...extra
|
|
};
|
|
}
|
|
|
|
function mergeActionOutcomeResults(...results) {
|
|
const normalizedResults = results.filter(Boolean);
|
|
const mergedStatus = {};
|
|
const mergedEffect = {
|
|
mutationAttributes: [],
|
|
mutationCount: 0,
|
|
newText: [],
|
|
semanticHints: [],
|
|
validationText: []
|
|
};
|
|
|
|
normalizedResults.forEach((result) => {
|
|
Object.entries(result?.status || {}).forEach(([key, value]) => {
|
|
if (typeof value === "boolean") {
|
|
mergedStatus[key] = mergedStatus[key] === true || value === true;
|
|
}
|
|
});
|
|
if (Number.isFinite(result?.effect?.mutationCount)) {
|
|
mergedEffect.mutationCount += Number(result.effect.mutationCount);
|
|
}
|
|
["mutationAttributes", "newText", "semanticHints", "validationText"].forEach((key) => {
|
|
const values = Array.isArray(result?.effect?.[key]) ? result.effect[key] : [];
|
|
values.forEach((value) => {
|
|
if (value && !mergedEffect[key].includes(value)) {
|
|
mergedEffect[key].push(value);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
mergedStatus.reacted = Object.entries(mergedStatus).some(([key, value]) => key !== "reacted" && key !== "noObservedEffect" && value === true);
|
|
mergedStatus.noObservedEffect = !mergedStatus.reacted;
|
|
return {
|
|
effect: mergedEffect,
|
|
status: mergedStatus
|
|
};
|
|
}
|
|
|
|
function dispatchDomEvent(target, eventName, EventType = "Event", options = {}) {
|
|
const EventConstructor = typeof globalThis[EventType] === "function"
|
|
? globalThis[EventType]
|
|
: globalThis.Event;
|
|
const event = new EventConstructor(eventName, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
composed: true,
|
|
...options
|
|
});
|
|
target.dispatchEvent(event);
|
|
return event;
|
|
}
|
|
|
|
function dispatchKeyboardEvent(target, eventName, options = {}) {
|
|
const KeyboardEventConstructor = typeof globalThis.KeyboardEvent === "function"
|
|
? globalThis.KeyboardEvent
|
|
: globalThis.Event;
|
|
const event = new KeyboardEventConstructor(eventName, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
composed: true,
|
|
code: "Enter",
|
|
key: "Enter",
|
|
...options
|
|
});
|
|
|
|
[
|
|
["charCode", Number(options.charCode ?? 0)],
|
|
["keyCode", Number(options.keyCode ?? 13)],
|
|
["which", Number(options.which ?? 13)]
|
|
].forEach(([propertyName, propertyValue]) => {
|
|
try {
|
|
if (typeof event[propertyName] !== "number") {
|
|
Object.defineProperty(event, propertyName, {
|
|
configurable: true,
|
|
enumerable: true,
|
|
value: propertyValue
|
|
});
|
|
}
|
|
} catch {
|
|
// Ignore read-only KeyboardEvent properties.
|
|
}
|
|
});
|
|
|
|
target.dispatchEvent(event);
|
|
return event;
|
|
}
|
|
|
|
function setNativeValue(element, nextValue) {
|
|
const tagName = getTagName(element);
|
|
const normalizedValue = String(nextValue ?? "");
|
|
|
|
if (tagName === "INPUT") {
|
|
const descriptor = Object.getOwnPropertyDescriptor(globalThis.HTMLInputElement?.prototype || {}, "value");
|
|
if (typeof descriptor?.set === "function") {
|
|
descriptor.set.call(element, normalizedValue);
|
|
} else {
|
|
element.value = normalizedValue;
|
|
}
|
|
return normalizedValue;
|
|
}
|
|
|
|
if (tagName === "TEXTAREA") {
|
|
const descriptor = Object.getOwnPropertyDescriptor(globalThis.HTMLTextAreaElement?.prototype || {}, "value");
|
|
if (typeof descriptor?.set === "function") {
|
|
descriptor.set.call(element, normalizedValue);
|
|
} else {
|
|
element.value = normalizedValue;
|
|
}
|
|
return normalizedValue;
|
|
}
|
|
|
|
if (tagName === "SELECT") {
|
|
const matchedOption = [...(element.options || [])].find((option) => {
|
|
return option.value === normalizedValue
|
|
|| normalizeText(option.textContent || "") === normalizeText(normalizedValue)
|
|
|| normalizeText(option.label || "") === normalizeText(normalizedValue);
|
|
});
|
|
|
|
const resolvedValue = matchedOption ? matchedOption.value : normalizedValue;
|
|
const descriptor = Object.getOwnPropertyDescriptor(globalThis.HTMLSelectElement?.prototype || {}, "value");
|
|
if (typeof descriptor?.set === "function") {
|
|
descriptor.set.call(element, resolvedValue);
|
|
} else {
|
|
element.value = resolvedValue;
|
|
}
|
|
return resolvedValue;
|
|
}
|
|
|
|
if (String(element.getAttribute?.("contenteditable") || "").toLowerCase() === "true") {
|
|
element.textContent = normalizedValue;
|
|
return normalizedValue;
|
|
}
|
|
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content cannot type into <${getTagName(element).toLowerCase()}>.`,
|
|
{
|
|
code: "browser_page_content_type_unsupported"
|
|
}
|
|
);
|
|
}
|
|
|
|
async function updateElementValue(referenceId, value) {
|
|
const entry = requireReferenceEntry(referenceId, {
|
|
actionLabel: "type"
|
|
});
|
|
|
|
if (entry.helperBacked) {
|
|
const helper = requireDomHelper("type into reference");
|
|
const typedResult = await helper.typeNode(entry.frameChain, entry.nodeId, value);
|
|
return buildHelperBackedActionResult(entry, typedResult, {
|
|
effect: typedResult?.effect || {},
|
|
status: typedResult?.status || {},
|
|
value: typedResult?.value ?? String(value ?? "")
|
|
});
|
|
}
|
|
|
|
const element = entry.element;
|
|
const beforeSnapshot = captureActionEffectSnapshot(element);
|
|
|
|
const {
|
|
result: appliedValue,
|
|
observedMutations
|
|
} = await withObservedActionWindow(beforeSnapshot.observationRoot, async () => {
|
|
scrollElementIntoView(element);
|
|
focusElement(element);
|
|
const nextValue = setNativeValue(element, value);
|
|
|
|
if (typeof element.setSelectionRange === "function") {
|
|
try {
|
|
element.setSelectionRange(String(nextValue).length, String(nextValue).length);
|
|
} catch {
|
|
// Ignore selection errors for unsupported input types.
|
|
}
|
|
}
|
|
|
|
dispatchDomEvent(element, "beforeinput", "InputEvent", {
|
|
data: String(value ?? ""),
|
|
inputType: "insertText"
|
|
});
|
|
dispatchDomEvent(element, "input", "InputEvent", {
|
|
data: String(value ?? ""),
|
|
inputType: "insertText"
|
|
});
|
|
dispatchDomEvent(element, "change");
|
|
return nextValue;
|
|
});
|
|
|
|
refreshReferenceEntry(entry);
|
|
return buildActionResult(entry, {
|
|
...buildActionEffectResult(entry, beforeSnapshot, captureActionEffectSnapshot(element), observedMutations),
|
|
value: appliedValue
|
|
});
|
|
}
|
|
|
|
async function activateElement(referenceId) {
|
|
const entry = requireReferenceEntry(referenceId, {
|
|
actionLabel: "click"
|
|
});
|
|
|
|
if (entry.helperBacked) {
|
|
const helper = requireDomHelper("click reference");
|
|
const clickedResult = await helper.clickNode(entry.frameChain, entry.nodeId);
|
|
return buildHelperBackedActionResult(entry, clickedResult, {
|
|
effect: clickedResult?.effect || {},
|
|
status: clickedResult?.status || {}
|
|
});
|
|
}
|
|
|
|
const element = entry.element;
|
|
const beforeSnapshot = captureActionEffectSnapshot(element);
|
|
|
|
scrollElementIntoView(element);
|
|
focusElement(element);
|
|
|
|
if (beforeSnapshot.targetState.disabled) {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content reference "${entry.referenceId}" is disabled.`,
|
|
{
|
|
code: "browser_page_content_click_disabled"
|
|
}
|
|
);
|
|
}
|
|
|
|
const {
|
|
observedMutations
|
|
} = await withObservedActionWindow(beforeSnapshot.observationRoot, async () => {
|
|
if (typeof element.click === "function") {
|
|
element.click();
|
|
} else {
|
|
dispatchDomEvent(element, "click", "MouseEvent", {
|
|
button: 0
|
|
});
|
|
}
|
|
});
|
|
|
|
refreshReferenceEntry(entry);
|
|
return buildActionResult(entry, buildActionEffectResult(
|
|
entry,
|
|
beforeSnapshot,
|
|
captureActionEffectSnapshot(element),
|
|
observedMutations
|
|
));
|
|
}
|
|
|
|
async function submitElement(referenceId) {
|
|
const entry = requireReferenceEntry(referenceId, {
|
|
actionLabel: "submit"
|
|
});
|
|
|
|
if (entry.helperBacked) {
|
|
const helper = requireDomHelper("submit reference");
|
|
const submittedResult = await helper.submitNode(entry.frameChain, entry.nodeId);
|
|
return buildHelperBackedActionResult(entry, submittedResult, {
|
|
effect: submittedResult?.effect || {},
|
|
status: submittedResult?.status || {}
|
|
});
|
|
}
|
|
|
|
const element = entry.element;
|
|
const tagName = getTagName(element);
|
|
const beforeSnapshot = captureActionEffectSnapshot(element);
|
|
|
|
const {
|
|
observedMutations
|
|
} = await withObservedActionWindow(beforeSnapshot.observationRoot, async () => {
|
|
scrollElementIntoView(element);
|
|
focusElement(element);
|
|
|
|
if (tagName === "FORM") {
|
|
if (typeof element.requestSubmit === "function") {
|
|
element.requestSubmit();
|
|
} else {
|
|
const submitEvent = dispatchDomEvent(element, "submit");
|
|
if (!submitEvent.defaultPrevented) {
|
|
element.submit?.();
|
|
}
|
|
}
|
|
} else if (typeof element.form?.requestSubmit === "function") {
|
|
if (tagName === "BUTTON" || tagName === "INPUT") {
|
|
element.form.requestSubmit(element);
|
|
} else {
|
|
element.form.requestSubmit();
|
|
}
|
|
} else if (element.form) {
|
|
const submitEvent = dispatchDomEvent(element.form, "submit");
|
|
if (!submitEvent.defaultPrevented) {
|
|
element.form.submit?.();
|
|
}
|
|
} else if (typeof element.click === "function") {
|
|
element.click();
|
|
} else {
|
|
throw createNamedError(
|
|
"BrowserPageContentActionError",
|
|
`Browser page content cannot submit reference "${entry.referenceId}".`,
|
|
{
|
|
code: "browser_page_content_submit_unsupported"
|
|
}
|
|
);
|
|
}
|
|
});
|
|
|
|
refreshReferenceEntry(entry);
|
|
return buildActionResult(entry, buildActionEffectResult(
|
|
entry,
|
|
beforeSnapshot,
|
|
captureActionEffectSnapshot(element),
|
|
observedMutations
|
|
));
|
|
}
|
|
|
|
function shouldEnterSubmitForm(element) {
|
|
const tagName = getTagName(element);
|
|
if (tagName !== "INPUT") {
|
|
return false;
|
|
}
|
|
|
|
const inputType = String(element.getAttribute?.("type") || element.type || "text").toLowerCase();
|
|
return ![
|
|
"button",
|
|
"checkbox",
|
|
"color",
|
|
"file",
|
|
"hidden",
|
|
"image",
|
|
"radio",
|
|
"range",
|
|
"reset",
|
|
"submit"
|
|
].includes(inputType);
|
|
}
|
|
|
|
async function pressEnterElement(referenceId, actionLabel = "type_submit") {
|
|
const entry = requireReferenceEntry(referenceId, {
|
|
actionLabel
|
|
});
|
|
|
|
if (entry.helperBacked) {
|
|
const helper = requireDomHelper("press enter on reference");
|
|
const submittedResult = await helper.typeSubmitNode(entry.frameChain, entry.nodeId, "");
|
|
return buildHelperBackedActionResult(entry, submittedResult, {
|
|
effect: submittedResult?.effect || {},
|
|
status: submittedResult?.status || {}
|
|
});
|
|
}
|
|
|
|
const element = entry.element;
|
|
const beforeSnapshot = captureActionEffectSnapshot(element);
|
|
|
|
const {
|
|
observedMutations
|
|
} = await withObservedActionWindow(beforeSnapshot.observationRoot, async () => {
|
|
scrollElementIntoView(element);
|
|
focusElement(element);
|
|
|
|
const keydownEvent = dispatchKeyboardEvent(element, "keydown", {
|
|
charCode: 0,
|
|
keyCode: 13,
|
|
which: 13
|
|
});
|
|
const keypressEvent = dispatchKeyboardEvent(element, "keypress", {
|
|
charCode: 13,
|
|
keyCode: 13,
|
|
which: 13
|
|
});
|
|
const keyupEvent = dispatchKeyboardEvent(element, "keyup", {
|
|
charCode: 0,
|
|
keyCode: 13,
|
|
which: 13
|
|
});
|
|
|
|
if (
|
|
!keydownEvent.defaultPrevented
|
|
&& !keypressEvent.defaultPrevented
|
|
&& !keyupEvent.defaultPrevented
|
|
&& shouldEnterSubmitForm(element)
|
|
) {
|
|
if (typeof element.form?.requestSubmit === "function") {
|
|
element.form.requestSubmit();
|
|
} else if (element.form) {
|
|
const submitEvent = dispatchDomEvent(element.form, "submit");
|
|
if (!submitEvent.defaultPrevented) {
|
|
element.form.submit?.();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
refreshReferenceEntry(entry);
|
|
return buildActionResult(entry, buildActionEffectResult(
|
|
entry,
|
|
beforeSnapshot,
|
|
captureActionEffectSnapshot(element),
|
|
observedMutations
|
|
));
|
|
}
|
|
|
|
async function typeAndSubmit(referenceId, value) {
|
|
const entry = requireReferenceEntry(referenceId, {
|
|
actionLabel: "type_submit"
|
|
});
|
|
|
|
if (entry.helperBacked) {
|
|
const helper = requireDomHelper("type and submit reference");
|
|
const submittedResult = await helper.typeSubmitNode(entry.frameChain, entry.nodeId, value);
|
|
return buildHelperBackedActionResult(entry, submittedResult, {
|
|
effect: submittedResult?.effect || {},
|
|
status: submittedResult?.status || {},
|
|
value: submittedResult?.value ?? String(value ?? "")
|
|
});
|
|
}
|
|
|
|
const typed = await updateElementValue(referenceId, value);
|
|
const submitted = await pressEnterElement(referenceId);
|
|
const mergedOutcome = mergeActionOutcomeResults(typed, submitted);
|
|
|
|
return {
|
|
...submitted,
|
|
...mergedOutcome,
|
|
value: typed.value
|
|
};
|
|
}
|
|
|
|
async function scrollToReference(referenceId) {
|
|
const entry = requireReferenceEntry(referenceId, {
|
|
actionLabel: "scroll"
|
|
});
|
|
|
|
if (entry.helperBacked) {
|
|
const helper = requireDomHelper("scroll to reference");
|
|
const scrollResult = await helper.scrollNode(entry.frameChain, entry.nodeId);
|
|
return buildHelperBackedActionResult(entry, scrollResult, {
|
|
effect: scrollResult?.effect || {},
|
|
status: scrollResult?.status || {}
|
|
});
|
|
}
|
|
|
|
const beforeSnapshot = captureActionEffectSnapshot(entry.element);
|
|
scrollElementIntoView(entry.element);
|
|
focusElement(entry.element);
|
|
refreshReferenceEntry(entry);
|
|
const afterSnapshot = captureActionEffectSnapshot(entry.element);
|
|
const scrollEffect = buildActionEffectResult(entry, beforeSnapshot, afterSnapshot, {
|
|
attributeNames: [],
|
|
mutationCount: 0
|
|
});
|
|
return buildActionResult(entry, {
|
|
...scrollEffect,
|
|
status: {
|
|
...scrollEffect.status,
|
|
reacted: true,
|
|
noObservedEffect: false
|
|
}
|
|
});
|
|
}
|
|
|
|
function cssEscape(value) {
|
|
const rawValue = String(value || "");
|
|
if (!rawValue) {
|
|
return "";
|
|
}
|
|
|
|
if (typeof globalThis.CSS?.escape === "function") {
|
|
return globalThis.CSS.escape(rawValue);
|
|
}
|
|
|
|
return rawValue.replace(/[^a-zA-Z0-9_-]/gu, (character) => `\\${character}`);
|
|
}
|
|
|
|
function getClassSummary(element) {
|
|
try {
|
|
return [...(element?.classList || [])]
|
|
.map((className) => normalizeAttributeText(className))
|
|
.filter(Boolean)
|
|
.slice(0, 4)
|
|
.join(" ");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function buildCssSelector(element) {
|
|
if (!isElementNode(element)) {
|
|
return "";
|
|
}
|
|
|
|
const id = normalizeAttributeText(element.getAttribute?.("id"));
|
|
if (id) {
|
|
return `#${cssEscape(id)}`;
|
|
}
|
|
|
|
const parts = [];
|
|
let current = element;
|
|
while (isElementNode(current) && current !== globalThis.document?.documentElement && parts.length < 6) {
|
|
const tagName = getTagName(current).toLowerCase();
|
|
if (!tagName) {
|
|
break;
|
|
}
|
|
|
|
let part = tagName;
|
|
const classes = getClassSummary(current)
|
|
.split(/\s+/u)
|
|
.filter(Boolean)
|
|
.slice(0, 2);
|
|
if (classes.length && !["body", "html"].includes(tagName)) {
|
|
part += classes.map((className) => `.${cssEscape(className)}`).join("");
|
|
}
|
|
|
|
const parent = current.parentElement;
|
|
if (parent) {
|
|
const siblings = [...parent.children].filter((sibling) => getTagName(sibling) === getTagName(current));
|
|
if (siblings.length > 1) {
|
|
part += `:nth-of-type(${siblings.indexOf(current) + 1})`;
|
|
}
|
|
}
|
|
|
|
parts.unshift(part);
|
|
if (tagName === "body") {
|
|
break;
|
|
}
|
|
current = parent;
|
|
}
|
|
|
|
return parts.join(" > ");
|
|
}
|
|
|
|
function sanitizeAnnotationDom(value) {
|
|
return truncateText(
|
|
String(value || "")
|
|
.replace(/(<input\b(?=[^>]*\btype\s*=\s*(["'])?password\2?)[^>]*?)\s+value\s*=\s*(["'])[\s\S]*?\3/giu, "$1 value=\"[redacted]\"")
|
|
.replace(/\svalue\s*=\s*(["'])[\s\S]{0,600}?\1/giu, " value=\"[redacted]\"")
|
|
.replace(/\sdata-space-browser-live-value\s*=\s*(["'])[\s\S]{0,600}?\1/giu, "")
|
|
.replace(/\sdata-space-browser-selected-text\s*=\s*(["'])[\s\S]{0,600}?\1/giu, ""),
|
|
1200
|
|
);
|
|
}
|
|
|
|
function summarizeAnnotationElement(element) {
|
|
if (!isElementNode(element)) {
|
|
return null;
|
|
}
|
|
|
|
const summaryData = collectReferenceSummaryData(element, {
|
|
includeLabelQuotes: false,
|
|
includeLinkUrls: true,
|
|
includeSemanticTags: true,
|
|
includeStateTags: true
|
|
});
|
|
const rawDom = serializeElementSnapshot(element);
|
|
return {
|
|
classes: getClassSummary(element),
|
|
dom: sanitizeAnnotationDom(rawDom),
|
|
id: normalizeAttributeText(element.getAttribute?.("id")),
|
|
kind: summaryData.kind,
|
|
name: normalizeAttributeText(element.getAttribute?.("name")),
|
|
rect: getElementRectSafe(element),
|
|
role: normalizeAttributeText(element.getAttribute?.("role")).toLowerCase(),
|
|
selector: buildCssSelector(element),
|
|
semanticTags: Array.isArray(summaryData.semanticTags) ? summaryData.semanticTags.slice(0, 4) : [],
|
|
stateTags: Array.isArray(summaryData.state?.stateTags) ? summaryData.state.stateTags.slice(0, 8) : [],
|
|
summary: truncateText(summaryData.summary || getLabelText(element, {
|
|
includeAlt: true,
|
|
includeDescendantImageAlt: true,
|
|
includePlaceholder: true,
|
|
includeText: true
|
|
}), 240),
|
|
tagName: getTagName(element)
|
|
};
|
|
}
|
|
|
|
function annotationViewport() {
|
|
return {
|
|
height: Math.max(0, Number(globalThis.innerHeight || globalThis.document?.documentElement?.clientHeight || 0)),
|
|
scrollX: Number(globalThis.scrollX || globalThis.pageXOffset || 0),
|
|
scrollY: Number(globalThis.scrollY || globalThis.pageYOffset || 0),
|
|
width: Math.max(0, Number(globalThis.innerWidth || globalThis.document?.documentElement?.clientWidth || 0))
|
|
};
|
|
}
|
|
|
|
function normalizeAnnotationPoint(payload = {}, viewport = annotationViewport()) {
|
|
const source = payload?.point && typeof payload.point === "object" ? payload.point : payload;
|
|
const width = Math.max(1, Number(viewport.width || 1));
|
|
const height = Math.max(1, Number(viewport.height || 1));
|
|
return {
|
|
x: Math.max(0, Math.min(width, Number(source?.x || 0))),
|
|
y: Math.max(0, Math.min(height, Number(source?.y || 0)))
|
|
};
|
|
}
|
|
|
|
function normalizeAnnotationRectPayload(payload = {}, viewport = annotationViewport()) {
|
|
const source = payload?.rect && typeof payload.rect === "object" ? payload.rect : payload;
|
|
const width = Math.max(1, Number(viewport.width || 1));
|
|
const height = Math.max(1, Number(viewport.height || 1));
|
|
const x = Math.max(0, Math.min(width, Number(source?.x || 0)));
|
|
const y = Math.max(0, Math.min(height, Number(source?.y || 0)));
|
|
return {
|
|
height: Math.max(1, Math.min(height - y, Number(source?.height || source?.h || 1))),
|
|
width: Math.max(1, Math.min(width - x, Number(source?.width || source?.w || 1))),
|
|
x,
|
|
y
|
|
};
|
|
}
|
|
|
|
function intersectRects(leftRect, rightRect) {
|
|
if (!leftRect || !rightRect) {
|
|
return null;
|
|
}
|
|
|
|
const x = Math.max(Number(leftRect.x || 0), Number(rightRect.x || 0));
|
|
const y = Math.max(Number(leftRect.y || 0), Number(rightRect.y || 0));
|
|
const right = Math.min(
|
|
Number(leftRect.x || 0) + Number(leftRect.width || 0),
|
|
Number(rightRect.x || 0) + Number(rightRect.width || 0)
|
|
);
|
|
const bottom = Math.min(
|
|
Number(leftRect.y || 0) + Number(leftRect.height || 0),
|
|
Number(rightRect.y || 0) + Number(rightRect.height || 0)
|
|
);
|
|
const width = right - x;
|
|
const height = bottom - y;
|
|
if (width <= 0 || height <= 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
area: width * height,
|
|
height,
|
|
width,
|
|
x,
|
|
y
|
|
};
|
|
}
|
|
|
|
function deepElementFromPoint(x, y) {
|
|
let element = null;
|
|
try {
|
|
element = globalThis.document?.elementFromPoint?.(x, y) || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
let guard = 0;
|
|
while (isElementNode(element) && element.shadowRoot && guard < 8) {
|
|
guard += 1;
|
|
try {
|
|
const nestedElement = element.shadowRoot.elementFromPoint?.(x, y);
|
|
if (!nestedElement || nestedElement === element) {
|
|
break;
|
|
}
|
|
element = nestedElement;
|
|
} catch {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return element;
|
|
}
|
|
|
|
function findAnnotationTarget(element) {
|
|
if (!isElementNode(element)) {
|
|
return null;
|
|
}
|
|
|
|
const selector = [
|
|
"a[href]",
|
|
"button",
|
|
"input",
|
|
"textarea",
|
|
"select",
|
|
"summary",
|
|
"[role]",
|
|
"img",
|
|
"label",
|
|
"form",
|
|
"h1",
|
|
"h2",
|
|
"h3",
|
|
"h4",
|
|
"h5",
|
|
"h6",
|
|
"p",
|
|
"li",
|
|
"td",
|
|
"th",
|
|
"article",
|
|
"section",
|
|
"nav",
|
|
"header",
|
|
"main",
|
|
"footer"
|
|
].join(",");
|
|
const target = element.closest?.(selector) || element;
|
|
return isElementNode(target) && !isHiddenElement(target) ? target : element;
|
|
}
|
|
|
|
function isMeaningfulAnnotationElement(element) {
|
|
if (!isElementNode(element) || isHiddenElement(element)) {
|
|
return false;
|
|
}
|
|
|
|
if (isInteractiveElement(element) || getTagName(element) === "IMG") {
|
|
return true;
|
|
}
|
|
|
|
const tagName = getTagName(element);
|
|
const role = normalizeAttributeText(element.getAttribute?.("role")).toLowerCase();
|
|
return Boolean(
|
|
role
|
|
|| /^H[1-6]$/u.test(tagName)
|
|
|| ["ARTICLE", "SECTION", "MAIN", "NAV", "HEADER", "FOOTER", "FORM", "LABEL", "P", "LI", "TD", "TH"].includes(tagName)
|
|
);
|
|
}
|
|
|
|
function collectIntersectingAnnotationElements(rect) {
|
|
const selector = [
|
|
"a[href]",
|
|
"button",
|
|
"input",
|
|
"textarea",
|
|
"select",
|
|
"summary",
|
|
"[role]",
|
|
"img",
|
|
"label",
|
|
"form",
|
|
"h1",
|
|
"h2",
|
|
"h3",
|
|
"h4",
|
|
"h5",
|
|
"h6",
|
|
"p",
|
|
"li",
|
|
"td",
|
|
"th",
|
|
"article",
|
|
"section",
|
|
"main",
|
|
"nav",
|
|
"header",
|
|
"footer"
|
|
].join(",");
|
|
let candidates = [];
|
|
try {
|
|
candidates = [...(globalThis.document?.querySelectorAll?.(selector) || [])];
|
|
} catch {
|
|
candidates = [];
|
|
}
|
|
|
|
const seen = new Set();
|
|
return candidates
|
|
.map((element) => {
|
|
if (!isMeaningfulAnnotationElement(element) || seen.has(element)) {
|
|
return null;
|
|
}
|
|
seen.add(element);
|
|
const elementRect = getElementRectSafe(element);
|
|
const intersection = intersectRects(rect, elementRect);
|
|
if (!intersection || intersection.area < 48) {
|
|
return null;
|
|
}
|
|
return {
|
|
element,
|
|
elementArea: Math.max(1, Number(elementRect.width || 0) * Number(elementRect.height || 0)),
|
|
intersection
|
|
};
|
|
})
|
|
.filter(Boolean)
|
|
.sort((left, right) => {
|
|
if (right.intersection.area !== left.intersection.area) {
|
|
return right.intersection.area - left.intersection.area;
|
|
}
|
|
return left.elementArea - right.elementArea;
|
|
})
|
|
.slice(0, 12)
|
|
.map((entry) => summarizeAnnotationElement(entry.element))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function annotate(payload = null) {
|
|
const request = payload && typeof payload === "object" ? payload : {};
|
|
const viewport = annotationViewport();
|
|
const kind = request.kind === "area" || request.rect ? "area" : "element";
|
|
|
|
if (kind === "area") {
|
|
const rect = normalizeAnnotationRectPayload(request, viewport);
|
|
const point = {
|
|
x: rect.x + rect.width / 2,
|
|
y: rect.y + rect.height / 2
|
|
};
|
|
const elements = collectIntersectingAnnotationElements(rect);
|
|
const fallbackElement = findAnnotationTarget(deepElementFromPoint(point.x, point.y));
|
|
const fallbackTarget = fallbackElement ? summarizeAnnotationElement(fallbackElement) : null;
|
|
return {
|
|
elements,
|
|
kind,
|
|
point,
|
|
rect,
|
|
status: elements.length || fallbackTarget ? "ok" : "empty",
|
|
target: elements[0] || fallbackTarget,
|
|
viewport
|
|
};
|
|
}
|
|
|
|
const point = normalizeAnnotationPoint(request, viewport);
|
|
const rawElement = deepElementFromPoint(point.x, point.y);
|
|
const targetElement = findAnnotationTarget(rawElement);
|
|
const target = targetElement ? summarizeAnnotationElement(targetElement) : null;
|
|
return {
|
|
kind,
|
|
point,
|
|
rect: target?.rect || {
|
|
height: 1,
|
|
width: 1,
|
|
x: point.x,
|
|
y: point.y
|
|
},
|
|
status: target ? "ok" : "empty",
|
|
target,
|
|
viewport
|
|
};
|
|
}
|
|
|
|
globalThis[GLOBAL_KEY] = {
|
|
click(referenceId) {
|
|
return activateElement(referenceId);
|
|
},
|
|
annotate,
|
|
capture,
|
|
clear() {
|
|
state.captureId = 0;
|
|
state.capturedAt = 0;
|
|
state.captureOptions = {
|
|
includeLabelQuotes: false,
|
|
includeLinkUrls: false,
|
|
includeSemanticTags: true,
|
|
includeStateTags: true,
|
|
includeListIndentation: true,
|
|
includeListMarkers: false
|
|
};
|
|
state.entries = new Map();
|
|
},
|
|
detail,
|
|
getState() {
|
|
return {
|
|
captureId: state.captureId,
|
|
capturedAt: state.capturedAt,
|
|
includeLabelQuotes: state.captureOptions.includeLabelQuotes === true,
|
|
includeLinkUrls: state.captureOptions.includeLinkUrls === true,
|
|
includeSemanticTags: state.captureOptions.includeSemanticTags !== false,
|
|
includeStateTags: state.captureOptions.includeStateTags !== false,
|
|
includeListIndentation: state.captureOptions.includeListIndentation !== false,
|
|
includeListMarkers: state.captureOptions.includeListMarkers === true,
|
|
referenceCount: state.entries.size
|
|
};
|
|
},
|
|
scroll(referenceId) {
|
|
return scrollToReference(referenceId);
|
|
},
|
|
submit(referenceId) {
|
|
return submitElement(referenceId);
|
|
},
|
|
type(referenceId, value) {
|
|
return updateElementValue(referenceId, value);
|
|
},
|
|
typeSubmit(referenceId, value) {
|
|
return typeAndSubmit(referenceId, value);
|
|
},
|
|
boundingBoxFor,
|
|
fileInputElementFor,
|
|
fileInputFor,
|
|
pointFor,
|
|
select(referenceId, valueOrValues) {
|
|
return selectReference(referenceId, valueOrValues);
|
|
},
|
|
setChecked(referenceId, checked) {
|
|
return setCheckedReference(referenceId, checked);
|
|
},
|
|
version: VERSION
|
|
};
|
|
})();
|