Files
openchamber/packages/ui/src/lib/browser/pageActions.ts
T

380 lines
17 KiB
TypeScript
Raw Normal View History

/**
* Scripts the agent's browser actions run inside the page.
*
* Each builder returns a self-contained expression evaluated in the page's own
* context, so none of them may reference anything from this module at runtime.
* Inputs are embedded with `JSON.stringify`, which is what keeps a selector or
* a typed value from terminating the expression and becoming code.
*
* Every script resolves to `{ ok, ... }` instead of throwing, so a failed match
* comes back as an explainable result rather than an opaque evaluation error.
*/
/**
* Budget caps. The snapshot cost is bounded by these, not by the size of the
* page: a document with ten thousand nodes returns the same shape as one with
* two hundred, because only visible interactive elements are collected and both
* lists are cut off here. What the caps drop is always reported, so a partial
* answer never reads as a complete one.
*/
const MAX_TEXT_CHARS = 6_000;
const MAX_ELEMENTS = 120;
/** Enough to recognise a control; full labels are what made entries expensive. */
const MAX_LABEL_CHARS = 80;
/**
* Shared helpers, injected into each script. `describe` builds the same kind of
* selector the other actions accept, so a snapshot result is directly usable as
* input to click or type.
*/
const HELPERS = `
var MAX_ELEMENTS = ${MAX_ELEMENTS};
var MAX_LABEL_CHARS = ${MAX_LABEL_CHARS};
var visible = function (element) {
var rect = element.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return false;
var style = window.getComputedStyle(element);
return style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) !== 0;
};
var label = function (element) {
var aria = element.getAttribute('aria-label');
if (aria) return aria.trim();
var value = element.getAttribute('value');
var text = (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim();
if (text) return text.slice(0, MAX_LABEL_CHARS);
if (value) return String(value).slice(0, MAX_LABEL_CHARS);
var placeholder = element.getAttribute('placeholder');
return placeholder ? placeholder.trim().slice(0, MAX_LABEL_CHARS) : '';
};
var isUnique = function (selector) {
try {
return document.querySelectorAll(selector).length === 1;
} catch (error) {
return false;
}
};
/**
* Names an element by what it is, falling back to where it sits.
*
* A positional chain like 'main > section:nth-of-type(3) > div > a' survives
* only until the markup shifts, and says nothing about what it points at.
* Anything the page states about identity — an id, a test id, an accessible
* name — outlives edits and reads as the thing it selects. The chain remains
* as the last resort, because something always has to work.
*/
var cssPath = function (element) {
var tag = element.tagName.toLowerCase();
if (element.id) {
var byId = '#' + CSS.escape(element.id);
if (isUnique(byId)) return byId;
}
var stableAttrs = ['data-testid', 'data-test-id', 'data-test', 'name', 'aria-label'];
for (var a = 0; a < stableAttrs.length; a += 1) {
var value = element.getAttribute(stableAttrs[a]);
if (!value) continue;
var raw = String(value);
// A value containing a quote would need escaping for no real gain: such
// attributes are rare, and the positional chain still covers them.
if (raw.indexOf('"') !== -1) continue;
var byAttr = tag + '[' + stableAttrs[a] + '="' + raw + '"]';
if (isUnique(byAttr)) return byAttr;
}
var className = typeof element.className === 'string' ? element.className.trim() : '';
if (className) {
var classes = className.split(/\\s+/).filter(Boolean);
for (var c = 0; c < classes.length; c += 1) {
var byClass = tag + '.' + CSS.escape(classes[c]);
if (isUnique(byClass)) return byClass;
}
}
var parts = [];
var node = element;
var depth = 0;
while (node && node.nodeType === 1 && depth < 6) {
var part = node.tagName.toLowerCase();
var parent = node.parentElement;
if (!parent) { parts.unshift(part); break; }
var siblings = Array.prototype.filter.call(parent.children, function (child) {
return child.tagName === node.tagName;
});
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
parts.unshift(part);
if (node.id) { parts[0] = '#' + CSS.escape(node.id); break; }
node = parent;
depth += 1;
}
return parts.join(' > ');
};
/** What a screen reader would announce, or '' when there is nothing to say. */
var accessibleName = function (element) {
var aria = element.getAttribute('aria-label');
if (aria && aria.trim()) return aria.trim();
var labelled = element.getAttribute('aria-labelledby');
if (labelled) {
var source = document.getElementById(labelled.split(/\\s+/)[0]);
if (source && (source.innerText || '').trim()) return source.innerText.trim();
}
var title = element.getAttribute('title');
if (title && title.trim()) return title.trim();
var alt = element.getAttribute('alt');
if (alt && alt.trim()) return alt.trim();
var text = (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim();
if (text) return text;
var value = element.getAttribute('value');
return value && String(value).trim() ? String(value).trim() : '';
};
var findByText = function (needle) {
var wanted = String(needle).replace(/\\s+/g, ' ').trim().toLowerCase();
var candidates = document.querySelectorAll('a, button, [role="button"], [role="link"], input[type="submit"], input[type="button"], summary, label');
var exact = null;
var partial = null;
for (var i = 0; i < candidates.length; i += 1) {
var element = candidates[i];
if (!visible(element)) continue;
var text = label(element).toLowerCase();
if (!text) continue;
if (text === wanted) { exact = element; break; }
if (!partial && text.indexOf(wanted) !== -1) partial = element;
}
return exact || partial;
};
`;
const wrap = (body: string): string => `(() => {\n${HELPERS}\n${body}\n})()`;
/**
* `selector` narrows the snapshot to one subtree.
*
* A long page truncates against the caps no matter how they are tuned, which
* leaves the agent hunting. Scoping answers that directly: ask about the part
* you mean and the caps stop mattering.
*/
export const buildSnapshotScript = ({ selector }: { selector?: string } = {}): string => wrap(`
var scopeSelector = ${JSON.stringify(selector ?? '')};
var root = document;
if (scopeSelector) {
try { root = document.querySelector(scopeSelector); }
catch (error) { return { ok: false, error: 'Invalid selector: ' + scopeSelector }; }
if (!root) return { ok: false, error: 'No element matches ' + scopeSelector };
}
var interactive = root.querySelectorAll('a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [contenteditable="true"]');
var elements = [];
var visibleTotal = 0;
for (var i = 0; i < interactive.length; i += 1) {
var element = interactive[i];
if (!visible(element)) continue;
visibleTotal += 1;
if (elements.length >= MAX_ELEMENTS) continue;
var rect = element.getBoundingClientRect();
// Empty and default-valued fields are left out rather than serialized as
// "" and false. Repeated across a hundred entries that overhead dwarfed
// the information it carried.
var entry = {
selector: cssPath(element),
tag: element.tagName.toLowerCase(),
bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }
};
// The list covers the whole document so anything can be clicked without
// scrolling to it first, which means bounds alone do not say what is on
// screen — a negative y reads as a bug otherwise.
if (rect.bottom > 0 && rect.top < window.innerHeight) entry.inViewport = true;
var type = element.getAttribute('type');
if (type) entry.type = type;
var role = element.getAttribute('role');
if (role) entry.role = role;
var labelText = label(element);
if (labelText) entry.label = labelText;
if (element.disabled === true) entry.disabled = true;
// Flagged rather than described: reporting the accessible name of every
// element would cost more than it tells, while its absence on something
// clickable is a defect worth naming.
if (!accessibleName(element)) entry.missingAccessibleName = true;
elements.push(entry);
}
var body = document.body ? (document.body.innerText || '') : '';
var text = body.replace(/\\n{3,}/g, '\\n\\n').trim();
var docEl = document.documentElement;
var result = {
ok: true,
url: String(location.href),
title: String(document.title || ''),
scope: scopeSelector || 'document',
scrollY: Math.round(window.scrollY),
maxScrollY: Math.max(0, Math.round(docEl.scrollHeight - window.innerHeight)),
text: text.slice(0, ${MAX_TEXT_CHARS}),
elements: elements
};
// State what was dropped. A capped list that reports only its own length
// reads as the whole page, and the agent acts as if it had seen everything.
if (text.length > ${MAX_TEXT_CHARS}) {
result.textTruncated = true;
result.textTotalChars = text.length;
}
if (visibleTotal > elements.length) {
result.elementsTruncated = true;
result.interactiveElementsOnPage = visibleTotal;
}
return result;
`);
export const buildClickScript = ({ selector, text }: { selector?: string; text?: string }): string => wrap(`
var selector = ${JSON.stringify(selector ?? '')};
var text = ${JSON.stringify(text ?? '')};
var target = null;
if (selector) {
try { target = document.querySelector(selector); }
catch (error) { return { ok: false, error: 'Invalid selector: ' + selector }; }
if (!target) return { ok: false, error: 'No element matches ' + selector };
} else {
target = findByText(text);
if (!target) return { ok: false, error: 'No clickable element has the label ' + text };
}
if (target.disabled === true) return { ok: false, error: 'Element is disabled' };
target.scrollIntoView({ block: 'center', inline: 'center' });
target.click();
return { ok: true, clicked: cssPath(target), label: label(target), url: String(location.href) };
`);
export const buildTypeScript = ({ selector, value, submit }: { selector: string; value: string; submit: boolean }): string => wrap(`
var selector = ${JSON.stringify(selector)};
var value = ${JSON.stringify(value)};
var target = null;
try { target = document.querySelector(selector); }
catch (error) { return { ok: false, error: 'Invalid selector: ' + selector }; }
if (!target) return { ok: false, error: 'No element matches ' + selector };
var editable = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
if (!editable) return { ok: false, error: selector + ' is not a text field' };
if (target.disabled === true || target.readOnly === true) return { ok: false, error: 'Field is not editable' };
target.scrollIntoView({ block: 'center' });
target.focus();
if (target.isContentEditable) {
target.textContent = value;
} else {
// Frameworks track the value through the native setter; assigning the
// property directly leaves React and friends unaware of the change.
var prototype = target.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
var setter = Object.getOwnPropertyDescriptor(prototype, 'value');
if (setter && setter.set) setter.set.call(target, value);
else target.value = value;
}
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
if (${submit ? 'true' : 'false'}) {
var enter = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true };
target.dispatchEvent(new KeyboardEvent('keydown', enter));
target.dispatchEvent(new KeyboardEvent('keyup', enter));
var form = target.form;
if (form && typeof form.requestSubmit === 'function') form.requestSubmit();
}
return { ok: true, selector: cssPath(target), url: String(location.href) };
`);
/**
* Scrolling, reported after it has actually happened.
*
* Two things made this lie. Pages commonly set `scroll-behavior: smooth`, which
* turns a programmatic scroll into an animation — so the position read straight
* afterwards is the position before the scroll, and the result said nothing
* moved. And a scroll that is already at the end is indistinguishable from one
* that failed unless the limits are reported too. An agent reading "scrollY: 0"
* from a scroll that worked learns a superstition it will apply for the rest of
* the session.
*/
export const buildScrollScript = ({ selector, direction }: { selector?: string; direction?: string }): string => wrap(`
var selector = ${JSON.stringify(selector ?? '')};
var direction = ${JSON.stringify(direction ?? '')};
var settle = function (extra) {
return new Promise(function (resolve) {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
var doc = document.documentElement;
var maxScrollY = Math.max(0, doc.scrollHeight - window.innerHeight);
var scrollY = Math.round(window.scrollY);
var result = { ok: true, scrollY: scrollY, maxScrollY: Math.round(maxScrollY) };
result.atTop = scrollY <= 1;
result.atBottom = scrollY >= maxScrollY - 1;
for (var key in extra) {
if (Object.prototype.hasOwnProperty.call(extra, key)) result[key] = extra[key];
}
resolve(result);
});
});
});
};
if (selector) {
var target = null;
try { target = document.querySelector(selector); }
catch (error) { return { ok: false, error: 'Invalid selector: ' + selector }; }
if (!target) return { ok: false, error: 'No element matches ' + selector };
// Instant on purpose: the page's own smooth scrolling would still be
// animating when the next action runs against it.
target.scrollIntoView({ block: 'center', behavior: 'instant' });
return settle({ scrolledTo: cssPath(target) });
}
var doc = document.documentElement;
var page = Math.round(window.innerHeight * 0.85);
var bottom = Math.max(0, doc.scrollHeight - window.innerHeight);
if (direction === 'down') window.scrollTo({ top: window.scrollY + page, behavior: 'instant' });
else if (direction === 'up') window.scrollTo({ top: window.scrollY - page, behavior: 'instant' });
else if (direction === 'top') window.scrollTo({ top: 0, behavior: 'instant' });
else if (direction === 'bottom') window.scrollTo({ top: bottom, behavior: 'instant' });
else return { ok: false, error: 'Unknown scroll direction: ' + direction };
return settle({ direction: direction });
`);
/** Properties that answer "how does this look", without dumping the whole cascade. */
const INSPECTED_STYLE_PROPS = [
'color', 'background-color', 'background-image', 'opacity',
'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing', 'text-align',
'border-radius', 'border-width', 'border-style', 'border-color', 'box-shadow',
'display', 'position', 'width', 'height', 'padding', 'margin', 'gap',
'flex-direction', 'justify-content', 'align-items', 'z-index', 'overflow', 'visibility',
];
/**
* Reads how one element actually renders.
*
* The snapshot describes structure, which leaves questions of appearance
* answerable only by reading the source and hoping the build agrees. Computed
* styles come from the live page, so a colour reported from here is the colour
* on screen — and unlike a screenshot it is readable by an agent that cannot
* see images.
*/
export const buildInspectScript = ({ selector }: { selector: string }): string => wrap(`
var selector = ${JSON.stringify(selector)};
var target = null;
try { target = document.querySelector(selector); }
catch (error) { return { ok: false, error: 'Invalid selector: ' + selector }; }
if (!target) return { ok: false, error: 'No element matches ' + selector };
var computed = window.getComputedStyle(target);
var styles = {};
var props = ${JSON.stringify(INSPECTED_STYLE_PROPS)};
for (var i = 0; i < props.length; i += 1) {
var value = computed.getPropertyValue(props[i]);
if (value) styles[props[i]] = String(value).trim();
}
var rect = target.getBoundingClientRect();
return {
ok: true,
selector: cssPath(target),
tag: target.tagName.toLowerCase(),
label: label(target),
bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
inViewport: rect.bottom > 0 && rect.top < window.innerHeight,
styles: styles
};
`);