runline 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plugins/shiftTranscription/src/index.js +1 -1
- package/dist/plugins/shiftTranscription/src/transcription.js +12 -0
- package/dist/plugins/steel/src/browser.js +11 -79
- package/dist/plugins/steel/src/index.js +2 -0
- package/dist/plugins/steel/src/page/bundle.generated.js +11 -0
- package/dist/plugins/steel/src/page/cdp.js +232 -0
- package/dist/plugins/steel/src/page/driver.js +286 -0
- package/dist/plugins/steel/src/page/entry.js +91 -0
- package/dist/plugins/steel/src/page/install.js +3 -0
- package/dist/plugins/steel/src/page/page-snapshot.js +330 -0
- package/dist/plugins/steel/src/page/tools.js +649 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaSnapshot.js +411 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaSnapshotDistiller.js +207 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaTypes.js +62 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/cssTokenizer.js +949 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/domUtils.js +157 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/roleUtils.js +1193 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/stringUtils.js +194 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/yaml.js +80 -0
- package/dist/plugins/steel/src/page/wait-for.js +30 -0
- package/dist/plugins/steel/src/sessions.js +2 -7
- package/dist/plugins/steel/src/shared.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { generateAriaTree, renderAriaTree, SENSITIVE_VALUE_MARKER, } from "./vendor/aria-snapshot/ariaSnapshot.js";
|
|
2
|
+
export { SENSITIVE_VALUE_MARKER };
|
|
3
|
+
const ARIA_REF = /^(?:f\d+)?e\d+$/;
|
|
4
|
+
const DEFAULT_MAX_CHARS = 60_000;
|
|
5
|
+
export const TARGET_ATTRIBUTE = "data-steel-page-target";
|
|
6
|
+
/**
|
|
7
|
+
* Tokens only need to be unique within this document for the moment
|
|
8
|
+
* between preparing a target and acting on it, so a counter suffices.
|
|
9
|
+
* Deliberately not `crypto.randomUUID`: that requires a secure context
|
|
10
|
+
* and is simply absent on plain-http pages, where automation still has
|
|
11
|
+
* to work.
|
|
12
|
+
*/
|
|
13
|
+
let locatorCounter = 0;
|
|
14
|
+
function nextLocatorToken() {
|
|
15
|
+
locatorCounter += 1;
|
|
16
|
+
return `t${Date.now().toString(36)}-${locatorCounter}`;
|
|
17
|
+
}
|
|
18
|
+
export class BrowserToolError extends Error {
|
|
19
|
+
code;
|
|
20
|
+
retryable;
|
|
21
|
+
constructor(code, message, retryable = true) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.retryable = retryable;
|
|
25
|
+
this.name = "BrowserToolError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Owns the latest Playwright-style aria snapshot for one document and resolves
|
|
30
|
+
* its refs back to live elements. A ref is never resolved from stale state:
|
|
31
|
+
* resolve() regenerates the tree first, so a detached or semantically renamed
|
|
32
|
+
* target fails instead of operating on the wrong element.
|
|
33
|
+
*/
|
|
34
|
+
export class PageSnapshotService {
|
|
35
|
+
document;
|
|
36
|
+
current = null;
|
|
37
|
+
maxChars;
|
|
38
|
+
refPrefix;
|
|
39
|
+
constructor(document, options = {}) {
|
|
40
|
+
this.document = document;
|
|
41
|
+
this.maxChars = options.maxChars ?? DEFAULT_MAX_CHARS;
|
|
42
|
+
this.refPrefix = options.refPrefix;
|
|
43
|
+
}
|
|
44
|
+
capture(options = {}) {
|
|
45
|
+
this.clearPreparedTargets();
|
|
46
|
+
return this.captureCurrent(options);
|
|
47
|
+
}
|
|
48
|
+
captureCurrent(options = {}) {
|
|
49
|
+
const root = this.document.body ?? this.document.documentElement;
|
|
50
|
+
if (!root) {
|
|
51
|
+
throw new BrowserToolError("page_unavailable", "The page has no document root");
|
|
52
|
+
}
|
|
53
|
+
this.current = generateAriaTree(root, {
|
|
54
|
+
mode: "ai",
|
|
55
|
+
refPrefix: this.refPrefix,
|
|
56
|
+
depth: options.depth,
|
|
57
|
+
boxes: options.boxes,
|
|
58
|
+
});
|
|
59
|
+
const rendered = renderAriaTree(this.current, {
|
|
60
|
+
mode: "ai",
|
|
61
|
+
depth: options.depth,
|
|
62
|
+
boxes: options.boxes,
|
|
63
|
+
}).text;
|
|
64
|
+
const snapshot = truncateAtLine(rendered, this.maxChars);
|
|
65
|
+
const view = this.document.defaultView;
|
|
66
|
+
const documentElement = this.document.documentElement;
|
|
67
|
+
const body = this.document.body;
|
|
68
|
+
const scrollHeight = Math.max(documentElement?.scrollHeight ?? 0, body?.scrollHeight ?? 0);
|
|
69
|
+
const viewportHeight = view?.visualViewport?.height ??
|
|
70
|
+
documentElement?.clientHeight ??
|
|
71
|
+
view?.innerHeight ??
|
|
72
|
+
0;
|
|
73
|
+
return {
|
|
74
|
+
url: this.document.location?.href ?? "",
|
|
75
|
+
title: this.document.title,
|
|
76
|
+
snapshot: snapshot.text,
|
|
77
|
+
scroll: {
|
|
78
|
+
y: Math.round(view?.scrollY ?? documentElement?.scrollTop ?? 0),
|
|
79
|
+
max: Math.max(0, Math.round(scrollHeight - viewportHeight)),
|
|
80
|
+
},
|
|
81
|
+
truncated: snapshot.omittedChars > 0,
|
|
82
|
+
omittedChars: snapshot.omittedChars,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
resolveOptions(target, values) {
|
|
86
|
+
const element = this.resolve(target);
|
|
87
|
+
if (!(element instanceof HTMLSelectElement)) {
|
|
88
|
+
throw new BrowserToolError("element_not_select", `Target ${target} is not a select element`, false);
|
|
89
|
+
}
|
|
90
|
+
const requested = new Set(values);
|
|
91
|
+
const matched = new Set();
|
|
92
|
+
const selected = [];
|
|
93
|
+
for (const option of element.options) {
|
|
94
|
+
const aliases = [option.value, option.label, option.text];
|
|
95
|
+
const matches = aliases.filter((value) => requested.has(value));
|
|
96
|
+
if (matches.length > 0) {
|
|
97
|
+
selected.push(option.value);
|
|
98
|
+
for (const match of matches)
|
|
99
|
+
matched.add(match);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (matched.size !== requested.size) {
|
|
103
|
+
throw new BrowserToolError("option_not_found", `Could not match all requested options: ${values.join(", ")}`);
|
|
104
|
+
}
|
|
105
|
+
return selected;
|
|
106
|
+
}
|
|
107
|
+
async prepareTarget(target, options = {}) {
|
|
108
|
+
const element = this.resolve(target);
|
|
109
|
+
if (isDisabled(element)) {
|
|
110
|
+
throw new BrowserToolError("element_disabled", `Target ${target} is disabled`);
|
|
111
|
+
}
|
|
112
|
+
element.scrollIntoView?.({ block: "center", inline: "center" });
|
|
113
|
+
const rect = await stableRect(element, options);
|
|
114
|
+
if (!rect || rect.width <= 0 || rect.height <= 0 || !isVisible(element)) {
|
|
115
|
+
throw new BrowserToolError("element_not_visible", `Target ${target} is not visible`);
|
|
116
|
+
}
|
|
117
|
+
const center = {
|
|
118
|
+
x: rect.x + rect.width / 2,
|
|
119
|
+
y: rect.y + rect.height / 2,
|
|
120
|
+
};
|
|
121
|
+
const hit = this.document.elementFromPoint?.(center.x, center.y);
|
|
122
|
+
if (hit && !belongsToTarget(hit, element)) {
|
|
123
|
+
throw new BrowserToolError("element_obscured", `Target ${target} is covered by another element`);
|
|
124
|
+
}
|
|
125
|
+
const locatorToken = nextLocatorToken();
|
|
126
|
+
element.setAttribute(TARGET_ATTRIBUTE, locatorToken);
|
|
127
|
+
return {
|
|
128
|
+
editable: isEditable(element),
|
|
129
|
+
locatorToken,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
async prepareEditableTarget(target, mode, options = {}) {
|
|
133
|
+
const prepared = await this.prepareTarget(target, options);
|
|
134
|
+
if (!prepared.editable) {
|
|
135
|
+
throw new BrowserToolError("element_not_editable", `Target ${target} does not accept text input`, false);
|
|
136
|
+
}
|
|
137
|
+
const element = this.preparedElement(prepared.locatorToken);
|
|
138
|
+
const previous = editableValue(element);
|
|
139
|
+
return {
|
|
140
|
+
...prepared,
|
|
141
|
+
previousValue: previous.value,
|
|
142
|
+
sensitive: previous.sensitive,
|
|
143
|
+
selectionPrepared: prepareEditableSelection(element, mode),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
readEditableValue(target) {
|
|
147
|
+
const element = this.resolve(target);
|
|
148
|
+
if (!hasEditableValue(element)) {
|
|
149
|
+
throw new BrowserToolError("element_not_editable", `Target ${target} does not have an editable text value`, false);
|
|
150
|
+
}
|
|
151
|
+
return editableValue(element);
|
|
152
|
+
}
|
|
153
|
+
preparedElement(locatorToken) {
|
|
154
|
+
const element = this.document.querySelector(`[${TARGET_ATTRIBUTE}="${locatorToken}"]`);
|
|
155
|
+
if (!element) {
|
|
156
|
+
throw new BrowserToolError("stale_ref", "Prepared browser target is no longer attached");
|
|
157
|
+
}
|
|
158
|
+
return element;
|
|
159
|
+
}
|
|
160
|
+
clearPreparedTargets() {
|
|
161
|
+
for (const element of this.document.querySelectorAll(`[${TARGET_ATTRIBUTE}]`)) {
|
|
162
|
+
element.removeAttribute(TARGET_ATTRIBUTE);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
resolve(target) {
|
|
166
|
+
const normalized = target.trim();
|
|
167
|
+
if (!normalized) {
|
|
168
|
+
throw new BrowserToolError("element_not_found", "Element target cannot be empty");
|
|
169
|
+
}
|
|
170
|
+
if (ARIA_REF.test(normalized)) {
|
|
171
|
+
this.captureCurrent();
|
|
172
|
+
const element = this.current?.info.get(normalized)?.element;
|
|
173
|
+
if (!element?.isConnected) {
|
|
174
|
+
throw new BrowserToolError("stale_ref", `Ref ${normalized} is not present in the current page snapshot. Capture a fresh snapshot and retry.`);
|
|
175
|
+
}
|
|
176
|
+
return element;
|
|
177
|
+
}
|
|
178
|
+
let matches;
|
|
179
|
+
try {
|
|
180
|
+
matches = this.document.querySelectorAll(normalized);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw new BrowserToolError("element_not_found", `Invalid element selector: ${normalized}`, false);
|
|
184
|
+
}
|
|
185
|
+
if (matches.length === 0) {
|
|
186
|
+
throw new BrowserToolError("element_not_found", `Selector ${JSON.stringify(normalized)} did not match any elements`);
|
|
187
|
+
}
|
|
188
|
+
if (matches.length > 1) {
|
|
189
|
+
throw new BrowserToolError("ambiguous_target", `Selector ${JSON.stringify(normalized)} matched ${matches.length} elements; use an exact ref from browser_read_page`);
|
|
190
|
+
}
|
|
191
|
+
return matches[0];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function isEditable(element) {
|
|
195
|
+
if (!hasEditableValue(element))
|
|
196
|
+
return false;
|
|
197
|
+
if (element instanceof HTMLTextAreaElement)
|
|
198
|
+
return !element.readOnly;
|
|
199
|
+
if (element instanceof HTMLInputElement)
|
|
200
|
+
return !element.readOnly;
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
function hasEditableValue(element) {
|
|
204
|
+
if (element instanceof HTMLTextAreaElement)
|
|
205
|
+
return true;
|
|
206
|
+
if (element instanceof HTMLInputElement) {
|
|
207
|
+
return ![
|
|
208
|
+
"button",
|
|
209
|
+
"checkbox",
|
|
210
|
+
"color",
|
|
211
|
+
"file",
|
|
212
|
+
"hidden",
|
|
213
|
+
"image",
|
|
214
|
+
"radio",
|
|
215
|
+
"range",
|
|
216
|
+
"reset",
|
|
217
|
+
"submit",
|
|
218
|
+
].includes(element.type);
|
|
219
|
+
}
|
|
220
|
+
return element instanceof HTMLElement && element.isContentEditable;
|
|
221
|
+
}
|
|
222
|
+
function editableValue(element) {
|
|
223
|
+
if (element instanceof HTMLInputElement) {
|
|
224
|
+
return { value: element.value, sensitive: element.type === "password" };
|
|
225
|
+
}
|
|
226
|
+
if (element instanceof HTMLTextAreaElement) {
|
|
227
|
+
return { value: element.value, sensitive: false };
|
|
228
|
+
}
|
|
229
|
+
const html = element;
|
|
230
|
+
return {
|
|
231
|
+
value: typeof html.innerText === "string"
|
|
232
|
+
? html.innerText
|
|
233
|
+
: (html.textContent ?? ""),
|
|
234
|
+
sensitive: false,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function prepareEditableSelection(element, mode) {
|
|
238
|
+
const html = element;
|
|
239
|
+
html.focus({ preventScroll: true });
|
|
240
|
+
if (element instanceof HTMLInputElement ||
|
|
241
|
+
element instanceof HTMLTextAreaElement) {
|
|
242
|
+
try {
|
|
243
|
+
if (mode === "replace")
|
|
244
|
+
element.select();
|
|
245
|
+
else
|
|
246
|
+
element.setSelectionRange(element.value.length, element.value.length);
|
|
247
|
+
return (element.selectionStart ===
|
|
248
|
+
(mode === "replace" ? 0 : element.value.length) &&
|
|
249
|
+
element.selectionEnd === element.value.length);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const selection = element.ownerDocument.defaultView?.getSelection();
|
|
256
|
+
if (!selection)
|
|
257
|
+
return false;
|
|
258
|
+
const range = element.ownerDocument.createRange();
|
|
259
|
+
range.selectNodeContents(element);
|
|
260
|
+
if (mode === "append")
|
|
261
|
+
range.collapse(false);
|
|
262
|
+
selection.removeAllRanges();
|
|
263
|
+
selection.addRange(range);
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
function isDisabled(element) {
|
|
267
|
+
const nativeDisabled = "disabled" in element &&
|
|
268
|
+
element.disabled === true;
|
|
269
|
+
return nativeDisabled || element.getAttribute("aria-disabled") === "true";
|
|
270
|
+
}
|
|
271
|
+
function isVisible(element) {
|
|
272
|
+
const style = element.ownerDocument.defaultView?.getComputedStyle(element);
|
|
273
|
+
return (style?.display !== "none" &&
|
|
274
|
+
style?.visibility !== "hidden" &&
|
|
275
|
+
style?.visibility !== "collapse" &&
|
|
276
|
+
style?.opacity !== "0");
|
|
277
|
+
}
|
|
278
|
+
async function stableRect(element, options) {
|
|
279
|
+
const interval = options.stabilityIntervalMs;
|
|
280
|
+
const timeout = options.timeoutMs ?? 1_000;
|
|
281
|
+
const started = Date.now();
|
|
282
|
+
let previous = null;
|
|
283
|
+
while (Date.now() - started <= timeout) {
|
|
284
|
+
const current = element.getBoundingClientRect();
|
|
285
|
+
if (!element.isConnected)
|
|
286
|
+
return null;
|
|
287
|
+
if (previous && rectsEqual(previous, current))
|
|
288
|
+
return current;
|
|
289
|
+
previous = current;
|
|
290
|
+
await nextLayoutFrame(element.ownerDocument.defaultView, interval);
|
|
291
|
+
}
|
|
292
|
+
throw new BrowserToolError("element_unstable", "Target element did not settle before the action timeout");
|
|
293
|
+
}
|
|
294
|
+
async function nextLayoutFrame(view, interval) {
|
|
295
|
+
if (interval !== undefined || !view?.requestAnimationFrame) {
|
|
296
|
+
await new Promise((resolve) => setTimeout(resolve, interval ?? 50));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
await new Promise((resolve) => view.requestAnimationFrame(() => resolve()));
|
|
300
|
+
}
|
|
301
|
+
function rectsEqual(a, b) {
|
|
302
|
+
return (Math.abs(a.x - b.x) < 0.5 &&
|
|
303
|
+
Math.abs(a.y - b.y) < 0.5 &&
|
|
304
|
+
Math.abs(a.width - b.width) < 0.5 &&
|
|
305
|
+
Math.abs(a.height - b.height) < 0.5);
|
|
306
|
+
}
|
|
307
|
+
function belongsToTarget(hit, target) {
|
|
308
|
+
if (hit === target || target.contains(hit))
|
|
309
|
+
return true;
|
|
310
|
+
let current = hit;
|
|
311
|
+
while (current) {
|
|
312
|
+
const root = current.getRootNode();
|
|
313
|
+
if (!(root instanceof ShadowRoot))
|
|
314
|
+
return false;
|
|
315
|
+
if (root.host === target || target.contains(root.host))
|
|
316
|
+
return true;
|
|
317
|
+
current = root.host;
|
|
318
|
+
}
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
function truncateAtLine(text, maxChars) {
|
|
322
|
+
if (text.length <= maxChars)
|
|
323
|
+
return { text, omittedChars: 0 };
|
|
324
|
+
const cut = text.lastIndexOf("\n", Math.max(0, maxChars));
|
|
325
|
+
const end = cut > 0 ? cut : maxChars;
|
|
326
|
+
return {
|
|
327
|
+
text: text.slice(0, end),
|
|
328
|
+
omittedChars: text.length - end,
|
|
329
|
+
};
|
|
330
|
+
}
|