char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somadd an interactive headless browser extension40b9c5a

main
5.5 KiB150 linesraw
1import type { Snapshot } from "./types.ts";
2
3/** A DOM-based observation, serialized to run in the page's isolated realm. */
4export async function observe(
5  key: string,
6  prefix: string,
7): Promise<Omit<Snapshot, "tab" | "diagnostics"> & { documentId: string }> {
8  await new Promise<void>((resolve) => {
9    let idle: ReturnType<typeof setTimeout>;
10    const finish = () => {
11      clearTimeout(idle);
12      clearTimeout(deadline);
13      observer.disconnect();
14      resolve();
15    };
16    const observer = new MutationObserver(() => {
17      clearTimeout(idle);
18      idle = setTimeout(finish, 150);
19    });
20    const deadline = setTimeout(finish, 1_000);
21    observer.observe(document, {
22      subtree: true,
23      childList: true,
24      attributes: true,
25      characterData: true,
26    });
27    idle = setTimeout(finish, 150);
28  });
29  const refs = new Map<string, Element>();
30  Object.defineProperty(globalThis, key, { value: refs, configurable: true });
31  const globals = globalThis as unknown as Record<string, string>;
32  const documentKey = key + "_document";
33  if (!globals[documentKey]) Object.defineProperty(globalThis, documentKey, { value: prefix });
34  const clean = (text: string) => text.replace(/\s+/g, " ").trim().slice(0, 500);
35  const visible = (element: Element) => {
36    const style = getComputedStyle(element);
37    return !element.closest('[hidden], [inert], [aria-hidden="true"]') &&
38      style.visibility !== "hidden" && style.display !== "none" &&
39      element.getClientRects().length > 0;
40  };
41  const name = (element: Element) => {
42    const labelled = element.getAttribute("aria-labelledby")?.split(/\s+/).map((id) =>
43      document.getElementById(id)?.textContent ?? ""
44    ).join(" ");
45    const labels = "labels" in element
46      ? [...((element as HTMLInputElement).labels ?? [])].map((label) => {
47        const copy = label.cloneNode(true) as Element;
48        copy.querySelectorAll("input, textarea, select, button").forEach((control) =>
49          control.remove()
50        );
51        return copy.textContent;
52      }).join(" ")
53      : "";
54    return clean(
55      labelled || element.getAttribute("aria-label") || labels ||
56        (element instanceof HTMLInputElement && /^(submit|button|reset)$/.test(element.type)
57          ? element.value
58          : "") ||
59        (element as HTMLElement).innerText || element.getAttribute("alt") ||
60        element.getAttribute("title") || element.getAttribute("placeholder") || "",
61    );
62  };
63  const candidates: Element[] = [];
64  const visit = (root: Document | ShadowRoot) => {
65    for (const element of root.querySelectorAll("*")) {
66      if (
67        element.matches(
68          "a[href], button, input:not([type=hidden]), textarea, select, summary, [role], [contenteditable=true], [tabindex]",
69        ) && visible(element)
70      ) candidates.push(element);
71      if (element.shadowRoot) visit(element.shadowRoot);
72    }
73  };
74  visit(document);
75  const elements = candidates.slice(0, 300).map((element, index) => {
76    const ref = `${prefix}e${index + 1}`;
77    refs.set(ref, element);
78    const input = element instanceof HTMLInputElement ? element : null;
79    const role = element.getAttribute("role") ||
80      (input
81        ? ({
82          checkbox: "checkbox",
83          radio: "radio",
84          range: "slider",
85          number: "spinbutton",
86          submit: "button",
87          button: "button",
88          reset: "button",
89        }[input.type] ?? "textbox")
90        : ({
91          A: "link",
92          BUTTON: "button",
93          TEXTAREA: "textbox",
94          SELECT: "combobox",
95          SUMMARY: "button",
96        }[element.tagName] ??
97          (element.getAttribute("contenteditable") === "true" ? "textbox" : "element")));
98    return {
99      ref,
100      role,
101      name: name(element),
102      ...("value" in element && input?.type !== "password"
103        ? { value: String(element.value).slice(0, 500) }
104        : {}),
105      ...(element.matches(":disabled, [aria-disabled=true]") ? { disabled: true } : {}),
106      ...(input && /^(checkbox|radio)$/.test(input.type) ? { checked: input.checked } : {}),
107      ...(element instanceof HTMLSelectElement
108        ? {
109          options: [...element.options].slice(0, 100).map((option) => ({
110            label: option.label,
111            value: option.value,
112            selected: option.selected,
113          })),
114        }
115        : {}),
116    };
117  });
118  const text = document.body?.innerText ?? "";
119  return {
120    documentId: globals[documentKey],
121    url: location.href,
122    title: document.title,
123    text: text.slice(0, 20_000),
124    elements,
125    truncated: text.length > 20_000 || candidates.length > 300,
126    frames: [...document.querySelectorAll("iframe")].map((frame) => ({
127      url: frame.src,
128      title: frame.title,
129    })),
130  };
131}
132
133/** Resolve a reference only while its original element is still actionable. */
134export function resolveElement(key: string, ref: string): Element {
135  const refs = (globalThis as unknown as Record<string, Map<string, Element>>)[key];
136  const element = refs?.get(ref);
137  if (!element?.isConnected) throw new Error("Stale element reference; take a new snapshot.");
138  if (element.matches(":disabled, [aria-disabled=true]") || element.closest("[inert]")) {
139    throw new Error("Element is disabled.");
140  }
141  element.scrollIntoView({ block: "center", inline: "center" });
142  const style = getComputedStyle(element);
143  if (
144    element.closest("[hidden], [aria-hidden=true]") || style.visibility === "hidden" ||
145    style.display === "none" || !element.getClientRects().length
146  ) {
147    throw new Error("Element is no longer visible; take a new snapshot.");
148  }
149  return element;
150}