@ricsam/r5d-browser 0.0.70 → 0.0.72
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/README.md +2 -0
- package/dist/cjs/browser-runtime.cjs +325 -40
- package/dist/cjs/main.cjs +10 -1
- package/dist/cjs/package.json +1 -1
- package/dist/mjs/browser-runtime.mjs +325 -40
- package/dist/mjs/main.mjs +10 -1
- package/dist/mjs/package.json +1 -1
- package/dist/types/browser-runtime.d.ts +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ r5d-browser start --chrome-path "/Applications/Google Chrome.app/Contents/MacOS/
|
|
|
22
22
|
|
|
23
23
|
The profile is stored under `~/.r5d/browser/chrome-profile`. The previous Chrome for Testing profile at `~/.r5d/browser/profile` is preserved but is not migrated automatically, so sign in again the first time you use the new profile. Browser binaries previously installed under `~/.r5d/browser/browsers` are also left untouched, but new versions of `r5d-browser` do not use them. Completed downloads are retained under `~/.r5d/browser/downloads`; agents can list them and copy a selected file into the active session's artifacts. That artifact is then synchronized to every connected worker. Browser screenshots are session artifacts in r5d.dev and are not retained by the browser process.
|
|
24
24
|
|
|
25
|
+
Agents read pages through `browser_read_page`, an accessibility outline with stable element refs (`[ref=e12]`) on interactable elements and `[box=x,y,w,h]` viewport coordinates on everything visible. Refs feed the one-call interaction tools: `browser_mouse_click` clicks a ref (scrolled into view, with actionability checks) or an x/y point, `browser_form_input` sets values on text inputs, selects, checkboxes, and contenteditable elements with trusted events that controlled (React-style) components honor, and `browser_scroll` scrolls an element into view or dispatches trusted wheel input. Same-origin iframe content appears inline with frame-prefixed refs such as `f1e3`; cross-origin frame content may be omitted. Refs come from the most recent snapshot of a tab, so agents re-read the page after navigations or large re-renders. `browser_wait_for` waits for a selector, visible text, a URL glob, or a load state instead of sleeping, and `browser_keyboard_type` has a `keys` mode that presses each character as a real key event (up to 500 characters per call) for type-ahead widgets — characters without key codes, such as emoji, still fall back to text insertion. Screenshots capture the viewport by default; `fullPage: true` captures the scrollable page up to 4000 CSS px of height.
|
|
26
|
+
|
|
25
27
|
Agents running JavaScript in a page can save captured text straight into the active session's artifacts by calling `r5dCreateArtifact(filename, content)`, which takes two strings and must be called before the agent's code returns. Each captured file is written to the session artifacts in r5d.dev, synchronized to every connected worker, and reported back as a `$R5D_ARTIFACTS_DIR` path; a single call may create at most 32 files totalling 4 MiB. The function is passed into the agent's code rather than assigned to `window`, so pages can neither call it nor use it to detect automation.
|
|
26
28
|
|
|
27
29
|
Agents can also create loopback-only TCP port forwards from this Mac to the worker bound to a chat. For example, a forward can map Chrome's `localhost:4323` to `127.0.0.1:3232` on that worker. The relay preserves raw TCP traffic, including HTTP, HTTPS, WebSockets, HMR, and SSE; it never exposes a public listener or permits an arbitrary target host.
|
|
@@ -37,6 +37,38 @@ const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
|
|
|
37
37
|
const MAX_RUN_JS_RESULT_BYTES = 1024 * 1024;
|
|
38
38
|
const MAX_RUN_JS_ARTIFACT_BYTES = 4 * 1024 * 1024;
|
|
39
39
|
const MAX_RUN_JS_ARTIFACT_COUNT = 32;
|
|
40
|
+
const REF_ACTION_TIMEOUT_MS = 5e3;
|
|
41
|
+
const NAVIGATION_TIMEOUT_MS = 2e4;
|
|
42
|
+
const READ_PAGE_TIMEOUT_MS = 1e4;
|
|
43
|
+
const MAX_FULL_PAGE_HEIGHT = 4e3;
|
|
44
|
+
const MAX_FULL_PAGE_WIDTH = 4096;
|
|
45
|
+
const MAX_TYPED_KEYS_CHARS = 500;
|
|
46
|
+
const MAX_TYPED_KEYS_TOTAL_MS = 2e4;
|
|
47
|
+
function clampInt(value, min, max, fallback) {
|
|
48
|
+
const num = value === void 0 ? fallback : Number(value);
|
|
49
|
+
if (!Number.isFinite(num)) return fallback;
|
|
50
|
+
return Math.min(max, Math.max(min, Math.round(num)));
|
|
51
|
+
}
|
|
52
|
+
function requireRef(value) {
|
|
53
|
+
if (typeof value !== "string" || !/^(f\d+)?e\d+$/.test(value)) {
|
|
54
|
+
throw new Error('ref must look like "e12" or "f1e3", exactly as printed by browser_read_page ([ref=e12]).');
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function stripCallLog(message) {
|
|
59
|
+
const withoutCallLog = message.split(/\n\s*Call log:/)[0] ?? message;
|
|
60
|
+
return withoutCallLog.split("\n").filter((line) => !/^\s+at\s/.test(line)).join("\n").trim();
|
|
61
|
+
}
|
|
62
|
+
function isDestroyedContext(message) {
|
|
63
|
+
return /Execution context was destroyed|because of a navigation|Cannot find context with specified id|Target closed/i.test(message);
|
|
64
|
+
}
|
|
65
|
+
function refActionError(error, ref, verb) {
|
|
66
|
+
const message = stripCallLog(error instanceof Error ? error.message : String(error));
|
|
67
|
+
return new Error(
|
|
68
|
+
`${message}
|
|
69
|
+
Ref ${ref} comes from the most recent browser_read_page snapshot of this tab. If the page navigated or re-rendered since, take a new snapshot and retry. If another element covers the target, dismiss the overlay before the ${verb}.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
40
72
|
class BrowserRuntime {
|
|
41
73
|
constructor(context, downloadsPath) {
|
|
42
74
|
this.context = context;
|
|
@@ -171,6 +203,18 @@ class BrowserRuntime {
|
|
|
171
203
|
if (!page || page.isClosed()) throw new Error(`Browser tab ${tabId} is no longer open.`);
|
|
172
204
|
return page;
|
|
173
205
|
}
|
|
206
|
+
refLocator(page, ref) {
|
|
207
|
+
return page.locator(`aria-ref=${ref}`);
|
|
208
|
+
}
|
|
209
|
+
async gotoSettled(page, url) {
|
|
210
|
+
try {
|
|
211
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS });
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
214
|
+
if (!/ERR_ABORTED|interrupted by another navigation/i.test(message)) throw new Error(stripCallLog(message));
|
|
215
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => void 0);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
174
218
|
async tabInfo(page) {
|
|
175
219
|
const tabId = this.track(page);
|
|
176
220
|
let windowId = "unknown";
|
|
@@ -216,7 +260,7 @@ class BrowserRuntime {
|
|
|
216
260
|
page2 = await this.context.newPage();
|
|
217
261
|
}
|
|
218
262
|
const url = typeof input.url === "string" ? input.url : "about:blank";
|
|
219
|
-
if (url !== "about:blank") await
|
|
263
|
+
if (url !== "about:blank") await this.gotoSettled(page2, url);
|
|
220
264
|
return { tab: await this.tabInfo(page2) };
|
|
221
265
|
}
|
|
222
266
|
const page = this.page(input.tabId);
|
|
@@ -226,10 +270,57 @@ class BrowserRuntime {
|
|
|
226
270
|
await page.close();
|
|
227
271
|
return { closed: true, tabId };
|
|
228
272
|
case "navigate":
|
|
229
|
-
await
|
|
273
|
+
await this.gotoSettled(page, String(input.url));
|
|
230
274
|
return { tab: await this.tabInfo(page) };
|
|
275
|
+
case "read_page": {
|
|
276
|
+
const maxChars = clampInt(input.maxChars, 1e3, 2e5, 5e4);
|
|
277
|
+
const depth = input.depth === void 0 ? void 0 : clampInt(input.depth, 1, 50, 50);
|
|
278
|
+
const options = { mode: "ai", boxes: true, timeout: READ_PAGE_TIMEOUT_MS, ...depth === void 0 ? {} : { depth } };
|
|
279
|
+
let snapshot;
|
|
280
|
+
try {
|
|
281
|
+
snapshot = await page.ariaSnapshot(options);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
284
|
+
if (isDestroyedContext(message)) {
|
|
285
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => void 0);
|
|
286
|
+
snapshot = await page.ariaSnapshot(options);
|
|
287
|
+
} else if (/Timeout/i.test(message)) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`read_page timed out after ${READ_PAGE_TIMEOUT_MS}ms building the outline. The page is very large or busy; retry with a depth limit (e.g. depth: 10).`
|
|
290
|
+
);
|
|
291
|
+
} else {
|
|
292
|
+
throw new Error(stripCallLog(message));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
let text = snapshot;
|
|
296
|
+
let truncated = false;
|
|
297
|
+
if (text.length > maxChars) {
|
|
298
|
+
const cut = text.lastIndexOf("\n", maxChars);
|
|
299
|
+
text = text.slice(0, cut > 0 ? cut : maxChars);
|
|
300
|
+
truncated = true;
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
tabId,
|
|
304
|
+
url: page.url(),
|
|
305
|
+
title: await page.title().catch(() => ""),
|
|
306
|
+
text,
|
|
307
|
+
truncated,
|
|
308
|
+
...truncated ? { note: `Truncated at ${maxChars} of ${snapshot.length} chars. Raise maxChars, pass depth for an overview, or scroll and re-read.` } : {}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
231
311
|
case "screenshot": {
|
|
232
|
-
|
|
312
|
+
if (input.fullPage === true) {
|
|
313
|
+
const metrics = await page.evaluate(() => ({
|
|
314
|
+
docWidth: Math.max(document.documentElement.scrollWidth, window.innerWidth),
|
|
315
|
+
docHeight: Math.max(document.documentElement.scrollHeight, window.innerHeight)
|
|
316
|
+
}));
|
|
317
|
+
const width = Math.min(metrics.docWidth, MAX_FULL_PAGE_WIDTH);
|
|
318
|
+
const height = Math.min(metrics.docHeight, MAX_FULL_PAGE_HEIGHT);
|
|
319
|
+
const truncated = metrics.docHeight > MAX_FULL_PAGE_HEIGHT || metrics.docWidth > MAX_FULL_PAGE_WIDTH;
|
|
320
|
+
const bytes2 = truncated ? await page.screenshot({ type: "png", scale: "css", timeout: 15e3, clip: { x: 0, y: 0, width, height } }) : await page.screenshot({ type: "png", scale: "css", timeout: 15e3, fullPage: true });
|
|
321
|
+
return { base64: bytes2.toString("base64"), width, height, fullPage: true, pageHeight: metrics.docHeight, truncated };
|
|
322
|
+
}
|
|
323
|
+
const bytes = await page.screenshot({ type: "png", scale: "css" });
|
|
233
324
|
const viewport = page.viewportSize();
|
|
234
325
|
const cursor = this.cursor.get(tabId);
|
|
235
326
|
return {
|
|
@@ -249,12 +340,181 @@ class BrowserRuntime {
|
|
|
249
340
|
return { tabId, x, y };
|
|
250
341
|
}
|
|
251
342
|
case "mouse_click": {
|
|
252
|
-
const cursor = this.cursor.get(tabId);
|
|
253
|
-
if (!cursor) throw new Error("Move the mouse in this tab before clicking.");
|
|
254
343
|
const button = input.button === "middle" || input.button === "right" ? input.button : "left";
|
|
255
|
-
|
|
344
|
+
const clickCount = input.clickCount === 2 ? 2 : 1;
|
|
345
|
+
if (input.ref !== void 0) {
|
|
346
|
+
if (input.x !== void 0 || input.y !== void 0) throw new Error("mouse_click accepts ref or x/y, not both.");
|
|
347
|
+
const ref = requireRef(input.ref);
|
|
348
|
+
const locator = this.refLocator(page, ref);
|
|
349
|
+
try {
|
|
350
|
+
await locator.click({ button, clickCount, timeout: REF_ACTION_TIMEOUT_MS });
|
|
351
|
+
} catch (error) {
|
|
352
|
+
throw refActionError(error, ref, "click");
|
|
353
|
+
}
|
|
354
|
+
const box = await locator.boundingBox().catch(() => null);
|
|
355
|
+
if (box) this.cursor.set(tabId, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
|
356
|
+
return {
|
|
357
|
+
tabId,
|
|
358
|
+
ref,
|
|
359
|
+
button,
|
|
360
|
+
clickCount,
|
|
361
|
+
...box ? { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } : {}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (input.x !== void 0 || input.y !== void 0) {
|
|
365
|
+
const x = Number(input.x);
|
|
366
|
+
const y = Number(input.y);
|
|
367
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("mouse_click by coordinates requires numeric x and y.");
|
|
368
|
+
await page.mouse.move(x, y);
|
|
369
|
+
this.cursor.set(tabId, { x, y });
|
|
370
|
+
await page.mouse.click(x, y, { button, clickCount });
|
|
371
|
+
return { tabId, x, y, button, clickCount };
|
|
372
|
+
}
|
|
373
|
+
const cursor = this.cursor.get(tabId);
|
|
374
|
+
if (!cursor) throw new Error("Pass ref or x/y, or move the mouse in this tab before clicking.");
|
|
375
|
+
await page.mouse.click(cursor.x, cursor.y, { button, clickCount });
|
|
256
376
|
return { tabId, ...cursor, button };
|
|
257
377
|
}
|
|
378
|
+
case "scroll": {
|
|
379
|
+
if (input.ref !== void 0) {
|
|
380
|
+
if (input.direction !== void 0) throw new Error("scroll accepts ref or direction, not both.");
|
|
381
|
+
const ref = requireRef(input.ref);
|
|
382
|
+
const locator = this.refLocator(page, ref);
|
|
383
|
+
try {
|
|
384
|
+
await locator.scrollIntoViewIfNeeded({ timeout: REF_ACTION_TIMEOUT_MS });
|
|
385
|
+
} catch (error) {
|
|
386
|
+
throw refActionError(error, ref, "scroll");
|
|
387
|
+
}
|
|
388
|
+
const box = await locator.boundingBox().catch(() => null);
|
|
389
|
+
if (box) this.cursor.set(tabId, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
|
390
|
+
return { tabId, ref, ...box ? { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } : {} };
|
|
391
|
+
}
|
|
392
|
+
const direction = input.direction;
|
|
393
|
+
if (direction !== "up" && direction !== "down" && direction !== "left" && direction !== "right") {
|
|
394
|
+
throw new Error('scroll requires ref, or direction "up", "down", "left", or "right".');
|
|
395
|
+
}
|
|
396
|
+
const amount = clampInt(input.amount, 1, 2e4, 600);
|
|
397
|
+
let pointer;
|
|
398
|
+
if (input.x !== void 0 || input.y !== void 0) {
|
|
399
|
+
const x = Number(input.x);
|
|
400
|
+
const y = Number(input.y);
|
|
401
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("scroll requires numeric x and y when provided.");
|
|
402
|
+
pointer = { x, y };
|
|
403
|
+
} else {
|
|
404
|
+
pointer = this.cursor.get(tabId) ?? await page.evaluate(() => ({ x: Math.round(window.innerWidth / 2), y: Math.round(window.innerHeight / 2) }));
|
|
405
|
+
}
|
|
406
|
+
await page.mouse.move(pointer.x, pointer.y);
|
|
407
|
+
this.cursor.set(tabId, pointer);
|
|
408
|
+
const deltaX = direction === "left" ? -amount : direction === "right" ? amount : 0;
|
|
409
|
+
const deltaY = direction === "up" ? -amount : direction === "down" ? amount : 0;
|
|
410
|
+
await page.mouse.wheel(deltaX, deltaY);
|
|
411
|
+
return { tabId, direction, amount, ...pointer };
|
|
412
|
+
}
|
|
413
|
+
case "wait_for": {
|
|
414
|
+
const timeout = clampInt(input.timeoutMs, 500, 25e3, 1e4);
|
|
415
|
+
const provided = ["selector", "text", "url", "loadState"].filter((key) => input[key] !== void 0);
|
|
416
|
+
const condition = provided[0];
|
|
417
|
+
if (provided.length !== 1 || condition === void 0) {
|
|
418
|
+
throw new Error("wait_for requires exactly one of selector, text, url, or loadState.");
|
|
419
|
+
}
|
|
420
|
+
const state = input.state === "hidden" ? "hidden" : "visible";
|
|
421
|
+
const started = Date.now();
|
|
422
|
+
try {
|
|
423
|
+
if (typeof input.selector === "string") {
|
|
424
|
+
await page.locator(input.selector).first().waitFor({ state, timeout });
|
|
425
|
+
} else if (typeof input.text === "string") {
|
|
426
|
+
await page.getByText(input.text).first().waitFor({ state, timeout });
|
|
427
|
+
} else if (typeof input.url === "string") {
|
|
428
|
+
await page.waitForURL(input.url, { timeout, waitUntil: "domcontentloaded" });
|
|
429
|
+
} else if (input.loadState === "load" || input.loadState === "domcontentloaded" || input.loadState === "networkidle") {
|
|
430
|
+
await page.waitForLoadState(input.loadState, { timeout });
|
|
431
|
+
} else {
|
|
432
|
+
throw new Error('wait_for loadState must be "load", "domcontentloaded", or "networkidle".');
|
|
433
|
+
}
|
|
434
|
+
} catch (error) {
|
|
435
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
436
|
+
if (!/Timeout|exceeded/i.test(message)) throw new Error(stripCallLog(message));
|
|
437
|
+
throw new Error(
|
|
438
|
+
`wait_for gave up after ${timeout}ms waiting for ${condition} ${JSON.stringify(input[condition])}${condition === "selector" || condition === "text" ? ` to become ${state}` : ""}. Current URL: ${page.url()}. Take a browser_read_page snapshot to see the actual page state.`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
return { tabId, matched: true, elapsedMs: Date.now() - started, url: page.url(), title: await page.title().catch(() => "") };
|
|
442
|
+
}
|
|
443
|
+
case "form_input": {
|
|
444
|
+
const ref = requireRef(input.ref);
|
|
445
|
+
const provided = ["value", "values", "checked"].filter((key) => input[key] !== void 0);
|
|
446
|
+
if (provided.length !== 1) throw new Error("form_input requires exactly one of value, values, or checked.");
|
|
447
|
+
const locator = this.refLocator(page, ref);
|
|
448
|
+
let info;
|
|
449
|
+
try {
|
|
450
|
+
info = await locator.evaluate(
|
|
451
|
+
(el) => {
|
|
452
|
+
const tag = el.tagName.toLowerCase();
|
|
453
|
+
if (el instanceof HTMLSelectElement) {
|
|
454
|
+
return {
|
|
455
|
+
kind: el.multiple ? "multiselect" : "select",
|
|
456
|
+
tag,
|
|
457
|
+
options: [...el.options].map((option) => ({ value: option.value, label: option.label.trim() }))
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) return { kind: "checkable", tag };
|
|
461
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return { kind: "text", tag };
|
|
462
|
+
if (el instanceof HTMLElement && el.isContentEditable) return { kind: "contenteditable", tag };
|
|
463
|
+
return { kind: "other", tag };
|
|
464
|
+
},
|
|
465
|
+
void 0,
|
|
466
|
+
{ timeout: REF_ACTION_TIMEOUT_MS }
|
|
467
|
+
);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
throw refActionError(error, ref, "form_input");
|
|
470
|
+
}
|
|
471
|
+
const expectField = (field) => {
|
|
472
|
+
if (provided[0] !== field) throw new Error(`Ref ${ref} is a ${info.kind} (${info.tag}); pass ${field}, not ${provided[0]}.`);
|
|
473
|
+
};
|
|
474
|
+
const matchOption = (entry) => {
|
|
475
|
+
const match = info.options.find((option) => option.value === entry) ?? info.options.find((option) => option.label === entry.trim());
|
|
476
|
+
if (!match) {
|
|
477
|
+
const available = info.options.slice(0, 20).map((option) => `${JSON.stringify(option.label)} (value ${JSON.stringify(option.value)})`).join(", ");
|
|
478
|
+
throw new Error(`No option matches ${JSON.stringify(entry)}. Available: ${available}`);
|
|
479
|
+
}
|
|
480
|
+
return match.value;
|
|
481
|
+
};
|
|
482
|
+
try {
|
|
483
|
+
switch (info.kind) {
|
|
484
|
+
case "select": {
|
|
485
|
+
expectField("value");
|
|
486
|
+
await locator.selectOption(matchOption(input.value), { timeout: REF_ACTION_TIMEOUT_MS });
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
case "multiselect": {
|
|
490
|
+
expectField("values");
|
|
491
|
+
const entries = input.values;
|
|
492
|
+
if (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string")) {
|
|
493
|
+
throw new Error("form_input values must be an array of strings.");
|
|
494
|
+
}
|
|
495
|
+
await locator.selectOption(entries.map((entry) => matchOption(entry)), { timeout: REF_ACTION_TIMEOUT_MS });
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
case "checkable":
|
|
499
|
+
expectField("checked");
|
|
500
|
+
await locator.setChecked(input.checked === true, { timeout: REF_ACTION_TIMEOUT_MS });
|
|
501
|
+
break;
|
|
502
|
+
case "text":
|
|
503
|
+
case "contenteditable":
|
|
504
|
+
expectField("value");
|
|
505
|
+
await locator.fill(input.value, { timeout: REF_ACTION_TIMEOUT_MS });
|
|
506
|
+
break;
|
|
507
|
+
default:
|
|
508
|
+
throw new Error(
|
|
509
|
+
`Ref ${ref} (${info.tag}) does not accept direct value input. Click it with browser_mouse_click and type with browser_keyboard_type mode "keys".`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (error instanceof Error && !/Timeout|exceeded|waiting for/i.test(error.message)) throw error;
|
|
514
|
+
throw refActionError(error, ref, "form_input");
|
|
515
|
+
}
|
|
516
|
+
return { tabId, ref, kind: info.kind };
|
|
517
|
+
}
|
|
258
518
|
case "keyboard": {
|
|
259
519
|
const keys = Array.isArray(input.keys) ? input.keys : [];
|
|
260
520
|
const modifiers = Array.isArray(input.modifiers) ? input.modifiers.filter((value) => typeof value === "string") : [];
|
|
@@ -269,47 +529,72 @@ class BrowserRuntime {
|
|
|
269
529
|
}
|
|
270
530
|
return { tabId, keys, modifiers };
|
|
271
531
|
}
|
|
272
|
-
case "keyboard_type":
|
|
532
|
+
case "keyboard_type": {
|
|
273
533
|
if (typeof input.text !== "string") throw new Error("keyboard_type requires text.");
|
|
534
|
+
if (input.mode === "keys") {
|
|
535
|
+
if (input.text.length > MAX_TYPED_KEYS_CHARS) {
|
|
536
|
+
throw new Error(
|
|
537
|
+
`keyboard_type mode "keys" is limited to ${MAX_TYPED_KEYS_CHARS} characters per call; split the text or use the default insert mode.`
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
const delay = clampInt(input.delayMs, 0, 100, 20);
|
|
541
|
+
if (input.text.length * delay > MAX_TYPED_KEYS_TOTAL_MS) {
|
|
542
|
+
throw new Error(`text.length x delayMs must stay at or below ${MAX_TYPED_KEYS_TOTAL_MS}ms; lower delayMs or split the text.`);
|
|
543
|
+
}
|
|
544
|
+
await page.keyboard.type(input.text, { delay });
|
|
545
|
+
return { tabId, characters: input.text.length, mode: "keys" };
|
|
546
|
+
}
|
|
274
547
|
await page.keyboard.insertText(input.text);
|
|
275
548
|
return { tabId, characters: input.text.length };
|
|
549
|
+
}
|
|
276
550
|
case "run_js": {
|
|
277
551
|
if (typeof input.code !== "string") throw new Error("run_js requires code.");
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
552
|
+
let evaluated;
|
|
553
|
+
try {
|
|
554
|
+
evaluated = await page.evaluate(
|
|
555
|
+
async ({ code, maxBytes, maxCount }) => {
|
|
556
|
+
const artifacts = [];
|
|
557
|
+
let totalBytes = 0;
|
|
558
|
+
const r5dCreateArtifact = (filename, content) => {
|
|
559
|
+
if (typeof filename !== "string" || !filename.trim()) {
|
|
560
|
+
throw new TypeError("r5dCreateArtifact(filename, content): filename must be a non-empty string.");
|
|
561
|
+
}
|
|
562
|
+
if (typeof content !== "string") {
|
|
563
|
+
throw new TypeError(
|
|
564
|
+
`r5dCreateArtifact(${JSON.stringify(filename)}, content): content must be a string. Use JSON.stringify(value) for objects.`
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
if (artifacts.length >= maxCount) {
|
|
568
|
+
throw new RangeError(`r5dCreateArtifact: at most ${maxCount} artifacts can be created in one browser_run_js call.`);
|
|
569
|
+
}
|
|
570
|
+
totalBytes += new TextEncoder().encode(content).length;
|
|
571
|
+
if (totalBytes > maxBytes) {
|
|
572
|
+
throw new RangeError(`r5dCreateArtifact: artifact content exceeded the ${maxBytes} byte budget for one browser_run_js call.`);
|
|
573
|
+
}
|
|
574
|
+
artifacts.push({ filename, content });
|
|
575
|
+
return filename;
|
|
576
|
+
};
|
|
577
|
+
const invoke = new Function("r5dCreateArtifact", `"use strict"; return (async () => {
|
|
302
578
|
${code}
|
|
303
579
|
})()`);
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
580
|
+
try {
|
|
581
|
+
return { result: await invoke(r5dCreateArtifact), artifacts };
|
|
582
|
+
} catch (error) {
|
|
583
|
+
if (artifacts.length === 0) throw error;
|
|
584
|
+
return { artifacts, failure: error instanceof Error ? error.message : String(error) };
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
{ code: input.code, maxBytes: MAX_RUN_JS_ARTIFACT_BYTES, maxCount: MAX_RUN_JS_ARTIFACT_COUNT }
|
|
588
|
+
);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
591
|
+
if (isDestroyedContext(message)) {
|
|
592
|
+
throw new Error(
|
|
593
|
+
"The page navigated or reloaded while the script was running, destroying its JavaScript context. The script was NOT retried because it may have side effects \u2014 if part of it ran (clicks, submits), verify the page state with browser_read_page before repeating anything. Wait out navigations with browser_wait_for, or restructure the script so navigation-triggering actions come last."
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
throw new Error(stripCallLog(message));
|
|
597
|
+
}
|
|
313
598
|
const serialized = JSON.stringify(evaluated.result);
|
|
314
599
|
if (serialized && Buffer.byteLength(serialized) > MAX_RUN_JS_RESULT_BYTES) {
|
|
315
600
|
throw new Error("Browser JavaScript result exceeded the 1 MiB limit.");
|
package/dist/cjs/main.cjs
CHANGED
|
@@ -150,7 +150,16 @@ async function connectLoop(params) {
|
|
|
150
150
|
version: findVersion(),
|
|
151
151
|
chromiumVersion: params.chromiumVersion,
|
|
152
152
|
profilePath: params.profilePath,
|
|
153
|
-
capabilities: {
|
|
153
|
+
capabilities: {
|
|
154
|
+
portForwarding: true,
|
|
155
|
+
runJsArtifacts: true,
|
|
156
|
+
readPage: true,
|
|
157
|
+
refActions: true,
|
|
158
|
+
waitFor: true,
|
|
159
|
+
formInput: true,
|
|
160
|
+
typedKeys: true,
|
|
161
|
+
screenshotFullPage: true
|
|
162
|
+
}
|
|
154
163
|
},
|
|
155
164
|
tabs: await params.runtime.listTabs(),
|
|
156
165
|
portForwards: params.portForwards.list()
|
package/dist/cjs/package.json
CHANGED
|
@@ -4,6 +4,38 @@ const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
|
|
|
4
4
|
const MAX_RUN_JS_RESULT_BYTES = 1024 * 1024;
|
|
5
5
|
const MAX_RUN_JS_ARTIFACT_BYTES = 4 * 1024 * 1024;
|
|
6
6
|
const MAX_RUN_JS_ARTIFACT_COUNT = 32;
|
|
7
|
+
const REF_ACTION_TIMEOUT_MS = 5e3;
|
|
8
|
+
const NAVIGATION_TIMEOUT_MS = 2e4;
|
|
9
|
+
const READ_PAGE_TIMEOUT_MS = 1e4;
|
|
10
|
+
const MAX_FULL_PAGE_HEIGHT = 4e3;
|
|
11
|
+
const MAX_FULL_PAGE_WIDTH = 4096;
|
|
12
|
+
const MAX_TYPED_KEYS_CHARS = 500;
|
|
13
|
+
const MAX_TYPED_KEYS_TOTAL_MS = 2e4;
|
|
14
|
+
function clampInt(value, min, max, fallback) {
|
|
15
|
+
const num = value === void 0 ? fallback : Number(value);
|
|
16
|
+
if (!Number.isFinite(num)) return fallback;
|
|
17
|
+
return Math.min(max, Math.max(min, Math.round(num)));
|
|
18
|
+
}
|
|
19
|
+
function requireRef(value) {
|
|
20
|
+
if (typeof value !== "string" || !/^(f\d+)?e\d+$/.test(value)) {
|
|
21
|
+
throw new Error('ref must look like "e12" or "f1e3", exactly as printed by browser_read_page ([ref=e12]).');
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function stripCallLog(message) {
|
|
26
|
+
const withoutCallLog = message.split(/\n\s*Call log:/)[0] ?? message;
|
|
27
|
+
return withoutCallLog.split("\n").filter((line) => !/^\s+at\s/.test(line)).join("\n").trim();
|
|
28
|
+
}
|
|
29
|
+
function isDestroyedContext(message) {
|
|
30
|
+
return /Execution context was destroyed|because of a navigation|Cannot find context with specified id|Target closed/i.test(message);
|
|
31
|
+
}
|
|
32
|
+
function refActionError(error, ref, verb) {
|
|
33
|
+
const message = stripCallLog(error instanceof Error ? error.message : String(error));
|
|
34
|
+
return new Error(
|
|
35
|
+
`${message}
|
|
36
|
+
Ref ${ref} comes from the most recent browser_read_page snapshot of this tab. If the page navigated or re-rendered since, take a new snapshot and retry. If another element covers the target, dismiss the overlay before the ${verb}.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
7
39
|
class BrowserRuntime {
|
|
8
40
|
constructor(context, downloadsPath) {
|
|
9
41
|
this.context = context;
|
|
@@ -138,6 +170,18 @@ class BrowserRuntime {
|
|
|
138
170
|
if (!page || page.isClosed()) throw new Error(`Browser tab ${tabId} is no longer open.`);
|
|
139
171
|
return page;
|
|
140
172
|
}
|
|
173
|
+
refLocator(page, ref) {
|
|
174
|
+
return page.locator(`aria-ref=${ref}`);
|
|
175
|
+
}
|
|
176
|
+
async gotoSettled(page, url) {
|
|
177
|
+
try {
|
|
178
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS });
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
181
|
+
if (!/ERR_ABORTED|interrupted by another navigation/i.test(message)) throw new Error(stripCallLog(message));
|
|
182
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => void 0);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
141
185
|
async tabInfo(page) {
|
|
142
186
|
const tabId = this.track(page);
|
|
143
187
|
let windowId = "unknown";
|
|
@@ -183,7 +227,7 @@ class BrowserRuntime {
|
|
|
183
227
|
page2 = await this.context.newPage();
|
|
184
228
|
}
|
|
185
229
|
const url = typeof input.url === "string" ? input.url : "about:blank";
|
|
186
|
-
if (url !== "about:blank") await
|
|
230
|
+
if (url !== "about:blank") await this.gotoSettled(page2, url);
|
|
187
231
|
return { tab: await this.tabInfo(page2) };
|
|
188
232
|
}
|
|
189
233
|
const page = this.page(input.tabId);
|
|
@@ -193,10 +237,57 @@ class BrowserRuntime {
|
|
|
193
237
|
await page.close();
|
|
194
238
|
return { closed: true, tabId };
|
|
195
239
|
case "navigate":
|
|
196
|
-
await
|
|
240
|
+
await this.gotoSettled(page, String(input.url));
|
|
197
241
|
return { tab: await this.tabInfo(page) };
|
|
242
|
+
case "read_page": {
|
|
243
|
+
const maxChars = clampInt(input.maxChars, 1e3, 2e5, 5e4);
|
|
244
|
+
const depth = input.depth === void 0 ? void 0 : clampInt(input.depth, 1, 50, 50);
|
|
245
|
+
const options = { mode: "ai", boxes: true, timeout: READ_PAGE_TIMEOUT_MS, ...depth === void 0 ? {} : { depth } };
|
|
246
|
+
let snapshot;
|
|
247
|
+
try {
|
|
248
|
+
snapshot = await page.ariaSnapshot(options);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
251
|
+
if (isDestroyedContext(message)) {
|
|
252
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => void 0);
|
|
253
|
+
snapshot = await page.ariaSnapshot(options);
|
|
254
|
+
} else if (/Timeout/i.test(message)) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`read_page timed out after ${READ_PAGE_TIMEOUT_MS}ms building the outline. The page is very large or busy; retry with a depth limit (e.g. depth: 10).`
|
|
257
|
+
);
|
|
258
|
+
} else {
|
|
259
|
+
throw new Error(stripCallLog(message));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
let text = snapshot;
|
|
263
|
+
let truncated = false;
|
|
264
|
+
if (text.length > maxChars) {
|
|
265
|
+
const cut = text.lastIndexOf("\n", maxChars);
|
|
266
|
+
text = text.slice(0, cut > 0 ? cut : maxChars);
|
|
267
|
+
truncated = true;
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
tabId,
|
|
271
|
+
url: page.url(),
|
|
272
|
+
title: await page.title().catch(() => ""),
|
|
273
|
+
text,
|
|
274
|
+
truncated,
|
|
275
|
+
...truncated ? { note: `Truncated at ${maxChars} of ${snapshot.length} chars. Raise maxChars, pass depth for an overview, or scroll and re-read.` } : {}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
198
278
|
case "screenshot": {
|
|
199
|
-
|
|
279
|
+
if (input.fullPage === true) {
|
|
280
|
+
const metrics = await page.evaluate(() => ({
|
|
281
|
+
docWidth: Math.max(document.documentElement.scrollWidth, window.innerWidth),
|
|
282
|
+
docHeight: Math.max(document.documentElement.scrollHeight, window.innerHeight)
|
|
283
|
+
}));
|
|
284
|
+
const width = Math.min(metrics.docWidth, MAX_FULL_PAGE_WIDTH);
|
|
285
|
+
const height = Math.min(metrics.docHeight, MAX_FULL_PAGE_HEIGHT);
|
|
286
|
+
const truncated = metrics.docHeight > MAX_FULL_PAGE_HEIGHT || metrics.docWidth > MAX_FULL_PAGE_WIDTH;
|
|
287
|
+
const bytes2 = truncated ? await page.screenshot({ type: "png", scale: "css", timeout: 15e3, clip: { x: 0, y: 0, width, height } }) : await page.screenshot({ type: "png", scale: "css", timeout: 15e3, fullPage: true });
|
|
288
|
+
return { base64: bytes2.toString("base64"), width, height, fullPage: true, pageHeight: metrics.docHeight, truncated };
|
|
289
|
+
}
|
|
290
|
+
const bytes = await page.screenshot({ type: "png", scale: "css" });
|
|
200
291
|
const viewport = page.viewportSize();
|
|
201
292
|
const cursor = this.cursor.get(tabId);
|
|
202
293
|
return {
|
|
@@ -216,12 +307,181 @@ class BrowserRuntime {
|
|
|
216
307
|
return { tabId, x, y };
|
|
217
308
|
}
|
|
218
309
|
case "mouse_click": {
|
|
219
|
-
const cursor = this.cursor.get(tabId);
|
|
220
|
-
if (!cursor) throw new Error("Move the mouse in this tab before clicking.");
|
|
221
310
|
const button = input.button === "middle" || input.button === "right" ? input.button : "left";
|
|
222
|
-
|
|
311
|
+
const clickCount = input.clickCount === 2 ? 2 : 1;
|
|
312
|
+
if (input.ref !== void 0) {
|
|
313
|
+
if (input.x !== void 0 || input.y !== void 0) throw new Error("mouse_click accepts ref or x/y, not both.");
|
|
314
|
+
const ref = requireRef(input.ref);
|
|
315
|
+
const locator = this.refLocator(page, ref);
|
|
316
|
+
try {
|
|
317
|
+
await locator.click({ button, clickCount, timeout: REF_ACTION_TIMEOUT_MS });
|
|
318
|
+
} catch (error) {
|
|
319
|
+
throw refActionError(error, ref, "click");
|
|
320
|
+
}
|
|
321
|
+
const box = await locator.boundingBox().catch(() => null);
|
|
322
|
+
if (box) this.cursor.set(tabId, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
|
323
|
+
return {
|
|
324
|
+
tabId,
|
|
325
|
+
ref,
|
|
326
|
+
button,
|
|
327
|
+
clickCount,
|
|
328
|
+
...box ? { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } : {}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (input.x !== void 0 || input.y !== void 0) {
|
|
332
|
+
const x = Number(input.x);
|
|
333
|
+
const y = Number(input.y);
|
|
334
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("mouse_click by coordinates requires numeric x and y.");
|
|
335
|
+
await page.mouse.move(x, y);
|
|
336
|
+
this.cursor.set(tabId, { x, y });
|
|
337
|
+
await page.mouse.click(x, y, { button, clickCount });
|
|
338
|
+
return { tabId, x, y, button, clickCount };
|
|
339
|
+
}
|
|
340
|
+
const cursor = this.cursor.get(tabId);
|
|
341
|
+
if (!cursor) throw new Error("Pass ref or x/y, or move the mouse in this tab before clicking.");
|
|
342
|
+
await page.mouse.click(cursor.x, cursor.y, { button, clickCount });
|
|
223
343
|
return { tabId, ...cursor, button };
|
|
224
344
|
}
|
|
345
|
+
case "scroll": {
|
|
346
|
+
if (input.ref !== void 0) {
|
|
347
|
+
if (input.direction !== void 0) throw new Error("scroll accepts ref or direction, not both.");
|
|
348
|
+
const ref = requireRef(input.ref);
|
|
349
|
+
const locator = this.refLocator(page, ref);
|
|
350
|
+
try {
|
|
351
|
+
await locator.scrollIntoViewIfNeeded({ timeout: REF_ACTION_TIMEOUT_MS });
|
|
352
|
+
} catch (error) {
|
|
353
|
+
throw refActionError(error, ref, "scroll");
|
|
354
|
+
}
|
|
355
|
+
const box = await locator.boundingBox().catch(() => null);
|
|
356
|
+
if (box) this.cursor.set(tabId, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
|
357
|
+
return { tabId, ref, ...box ? { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } : {} };
|
|
358
|
+
}
|
|
359
|
+
const direction = input.direction;
|
|
360
|
+
if (direction !== "up" && direction !== "down" && direction !== "left" && direction !== "right") {
|
|
361
|
+
throw new Error('scroll requires ref, or direction "up", "down", "left", or "right".');
|
|
362
|
+
}
|
|
363
|
+
const amount = clampInt(input.amount, 1, 2e4, 600);
|
|
364
|
+
let pointer;
|
|
365
|
+
if (input.x !== void 0 || input.y !== void 0) {
|
|
366
|
+
const x = Number(input.x);
|
|
367
|
+
const y = Number(input.y);
|
|
368
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("scroll requires numeric x and y when provided.");
|
|
369
|
+
pointer = { x, y };
|
|
370
|
+
} else {
|
|
371
|
+
pointer = this.cursor.get(tabId) ?? await page.evaluate(() => ({ x: Math.round(window.innerWidth / 2), y: Math.round(window.innerHeight / 2) }));
|
|
372
|
+
}
|
|
373
|
+
await page.mouse.move(pointer.x, pointer.y);
|
|
374
|
+
this.cursor.set(tabId, pointer);
|
|
375
|
+
const deltaX = direction === "left" ? -amount : direction === "right" ? amount : 0;
|
|
376
|
+
const deltaY = direction === "up" ? -amount : direction === "down" ? amount : 0;
|
|
377
|
+
await page.mouse.wheel(deltaX, deltaY);
|
|
378
|
+
return { tabId, direction, amount, ...pointer };
|
|
379
|
+
}
|
|
380
|
+
case "wait_for": {
|
|
381
|
+
const timeout = clampInt(input.timeoutMs, 500, 25e3, 1e4);
|
|
382
|
+
const provided = ["selector", "text", "url", "loadState"].filter((key) => input[key] !== void 0);
|
|
383
|
+
const condition = provided[0];
|
|
384
|
+
if (provided.length !== 1 || condition === void 0) {
|
|
385
|
+
throw new Error("wait_for requires exactly one of selector, text, url, or loadState.");
|
|
386
|
+
}
|
|
387
|
+
const state = input.state === "hidden" ? "hidden" : "visible";
|
|
388
|
+
const started = Date.now();
|
|
389
|
+
try {
|
|
390
|
+
if (typeof input.selector === "string") {
|
|
391
|
+
await page.locator(input.selector).first().waitFor({ state, timeout });
|
|
392
|
+
} else if (typeof input.text === "string") {
|
|
393
|
+
await page.getByText(input.text).first().waitFor({ state, timeout });
|
|
394
|
+
} else if (typeof input.url === "string") {
|
|
395
|
+
await page.waitForURL(input.url, { timeout, waitUntil: "domcontentloaded" });
|
|
396
|
+
} else if (input.loadState === "load" || input.loadState === "domcontentloaded" || input.loadState === "networkidle") {
|
|
397
|
+
await page.waitForLoadState(input.loadState, { timeout });
|
|
398
|
+
} else {
|
|
399
|
+
throw new Error('wait_for loadState must be "load", "domcontentloaded", or "networkidle".');
|
|
400
|
+
}
|
|
401
|
+
} catch (error) {
|
|
402
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
403
|
+
if (!/Timeout|exceeded/i.test(message)) throw new Error(stripCallLog(message));
|
|
404
|
+
throw new Error(
|
|
405
|
+
`wait_for gave up after ${timeout}ms waiting for ${condition} ${JSON.stringify(input[condition])}${condition === "selector" || condition === "text" ? ` to become ${state}` : ""}. Current URL: ${page.url()}. Take a browser_read_page snapshot to see the actual page state.`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
return { tabId, matched: true, elapsedMs: Date.now() - started, url: page.url(), title: await page.title().catch(() => "") };
|
|
409
|
+
}
|
|
410
|
+
case "form_input": {
|
|
411
|
+
const ref = requireRef(input.ref);
|
|
412
|
+
const provided = ["value", "values", "checked"].filter((key) => input[key] !== void 0);
|
|
413
|
+
if (provided.length !== 1) throw new Error("form_input requires exactly one of value, values, or checked.");
|
|
414
|
+
const locator = this.refLocator(page, ref);
|
|
415
|
+
let info;
|
|
416
|
+
try {
|
|
417
|
+
info = await locator.evaluate(
|
|
418
|
+
(el) => {
|
|
419
|
+
const tag = el.tagName.toLowerCase();
|
|
420
|
+
if (el instanceof HTMLSelectElement) {
|
|
421
|
+
return {
|
|
422
|
+
kind: el.multiple ? "multiselect" : "select",
|
|
423
|
+
tag,
|
|
424
|
+
options: [...el.options].map((option) => ({ value: option.value, label: option.label.trim() }))
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) return { kind: "checkable", tag };
|
|
428
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return { kind: "text", tag };
|
|
429
|
+
if (el instanceof HTMLElement && el.isContentEditable) return { kind: "contenteditable", tag };
|
|
430
|
+
return { kind: "other", tag };
|
|
431
|
+
},
|
|
432
|
+
void 0,
|
|
433
|
+
{ timeout: REF_ACTION_TIMEOUT_MS }
|
|
434
|
+
);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
throw refActionError(error, ref, "form_input");
|
|
437
|
+
}
|
|
438
|
+
const expectField = (field) => {
|
|
439
|
+
if (provided[0] !== field) throw new Error(`Ref ${ref} is a ${info.kind} (${info.tag}); pass ${field}, not ${provided[0]}.`);
|
|
440
|
+
};
|
|
441
|
+
const matchOption = (entry) => {
|
|
442
|
+
const match = info.options.find((option) => option.value === entry) ?? info.options.find((option) => option.label === entry.trim());
|
|
443
|
+
if (!match) {
|
|
444
|
+
const available = info.options.slice(0, 20).map((option) => `${JSON.stringify(option.label)} (value ${JSON.stringify(option.value)})`).join(", ");
|
|
445
|
+
throw new Error(`No option matches ${JSON.stringify(entry)}. Available: ${available}`);
|
|
446
|
+
}
|
|
447
|
+
return match.value;
|
|
448
|
+
};
|
|
449
|
+
try {
|
|
450
|
+
switch (info.kind) {
|
|
451
|
+
case "select": {
|
|
452
|
+
expectField("value");
|
|
453
|
+
await locator.selectOption(matchOption(input.value), { timeout: REF_ACTION_TIMEOUT_MS });
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
case "multiselect": {
|
|
457
|
+
expectField("values");
|
|
458
|
+
const entries = input.values;
|
|
459
|
+
if (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string")) {
|
|
460
|
+
throw new Error("form_input values must be an array of strings.");
|
|
461
|
+
}
|
|
462
|
+
await locator.selectOption(entries.map((entry) => matchOption(entry)), { timeout: REF_ACTION_TIMEOUT_MS });
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
case "checkable":
|
|
466
|
+
expectField("checked");
|
|
467
|
+
await locator.setChecked(input.checked === true, { timeout: REF_ACTION_TIMEOUT_MS });
|
|
468
|
+
break;
|
|
469
|
+
case "text":
|
|
470
|
+
case "contenteditable":
|
|
471
|
+
expectField("value");
|
|
472
|
+
await locator.fill(input.value, { timeout: REF_ACTION_TIMEOUT_MS });
|
|
473
|
+
break;
|
|
474
|
+
default:
|
|
475
|
+
throw new Error(
|
|
476
|
+
`Ref ${ref} (${info.tag}) does not accept direct value input. Click it with browser_mouse_click and type with browser_keyboard_type mode "keys".`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (error instanceof Error && !/Timeout|exceeded|waiting for/i.test(error.message)) throw error;
|
|
481
|
+
throw refActionError(error, ref, "form_input");
|
|
482
|
+
}
|
|
483
|
+
return { tabId, ref, kind: info.kind };
|
|
484
|
+
}
|
|
225
485
|
case "keyboard": {
|
|
226
486
|
const keys = Array.isArray(input.keys) ? input.keys : [];
|
|
227
487
|
const modifiers = Array.isArray(input.modifiers) ? input.modifiers.filter((value) => typeof value === "string") : [];
|
|
@@ -236,47 +496,72 @@ class BrowserRuntime {
|
|
|
236
496
|
}
|
|
237
497
|
return { tabId, keys, modifiers };
|
|
238
498
|
}
|
|
239
|
-
case "keyboard_type":
|
|
499
|
+
case "keyboard_type": {
|
|
240
500
|
if (typeof input.text !== "string") throw new Error("keyboard_type requires text.");
|
|
501
|
+
if (input.mode === "keys") {
|
|
502
|
+
if (input.text.length > MAX_TYPED_KEYS_CHARS) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
`keyboard_type mode "keys" is limited to ${MAX_TYPED_KEYS_CHARS} characters per call; split the text or use the default insert mode.`
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
const delay = clampInt(input.delayMs, 0, 100, 20);
|
|
508
|
+
if (input.text.length * delay > MAX_TYPED_KEYS_TOTAL_MS) {
|
|
509
|
+
throw new Error(`text.length x delayMs must stay at or below ${MAX_TYPED_KEYS_TOTAL_MS}ms; lower delayMs or split the text.`);
|
|
510
|
+
}
|
|
511
|
+
await page.keyboard.type(input.text, { delay });
|
|
512
|
+
return { tabId, characters: input.text.length, mode: "keys" };
|
|
513
|
+
}
|
|
241
514
|
await page.keyboard.insertText(input.text);
|
|
242
515
|
return { tabId, characters: input.text.length };
|
|
516
|
+
}
|
|
243
517
|
case "run_js": {
|
|
244
518
|
if (typeof input.code !== "string") throw new Error("run_js requires code.");
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
519
|
+
let evaluated;
|
|
520
|
+
try {
|
|
521
|
+
evaluated = await page.evaluate(
|
|
522
|
+
async ({ code, maxBytes, maxCount }) => {
|
|
523
|
+
const artifacts = [];
|
|
524
|
+
let totalBytes = 0;
|
|
525
|
+
const r5dCreateArtifact = (filename, content) => {
|
|
526
|
+
if (typeof filename !== "string" || !filename.trim()) {
|
|
527
|
+
throw new TypeError("r5dCreateArtifact(filename, content): filename must be a non-empty string.");
|
|
528
|
+
}
|
|
529
|
+
if (typeof content !== "string") {
|
|
530
|
+
throw new TypeError(
|
|
531
|
+
`r5dCreateArtifact(${JSON.stringify(filename)}, content): content must be a string. Use JSON.stringify(value) for objects.`
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
if (artifacts.length >= maxCount) {
|
|
535
|
+
throw new RangeError(`r5dCreateArtifact: at most ${maxCount} artifacts can be created in one browser_run_js call.`);
|
|
536
|
+
}
|
|
537
|
+
totalBytes += new TextEncoder().encode(content).length;
|
|
538
|
+
if (totalBytes > maxBytes) {
|
|
539
|
+
throw new RangeError(`r5dCreateArtifact: artifact content exceeded the ${maxBytes} byte budget for one browser_run_js call.`);
|
|
540
|
+
}
|
|
541
|
+
artifacts.push({ filename, content });
|
|
542
|
+
return filename;
|
|
543
|
+
};
|
|
544
|
+
const invoke = new Function("r5dCreateArtifact", `"use strict"; return (async () => {
|
|
269
545
|
${code}
|
|
270
546
|
})()`);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
547
|
+
try {
|
|
548
|
+
return { result: await invoke(r5dCreateArtifact), artifacts };
|
|
549
|
+
} catch (error) {
|
|
550
|
+
if (artifacts.length === 0) throw error;
|
|
551
|
+
return { artifacts, failure: error instanceof Error ? error.message : String(error) };
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
{ code: input.code, maxBytes: MAX_RUN_JS_ARTIFACT_BYTES, maxCount: MAX_RUN_JS_ARTIFACT_COUNT }
|
|
555
|
+
);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
558
|
+
if (isDestroyedContext(message)) {
|
|
559
|
+
throw new Error(
|
|
560
|
+
"The page navigated or reloaded while the script was running, destroying its JavaScript context. The script was NOT retried because it may have side effects \u2014 if part of it ran (clicks, submits), verify the page state with browser_read_page before repeating anything. Wait out navigations with browser_wait_for, or restructure the script so navigation-triggering actions come last."
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
throw new Error(stripCallLog(message));
|
|
564
|
+
}
|
|
280
565
|
const serialized = JSON.stringify(evaluated.result);
|
|
281
566
|
if (serialized && Buffer.byteLength(serialized) > MAX_RUN_JS_RESULT_BYTES) {
|
|
282
567
|
throw new Error("Browser JavaScript result exceeded the 1 MiB limit.");
|
package/dist/mjs/main.mjs
CHANGED
|
@@ -127,7 +127,16 @@ async function connectLoop(params) {
|
|
|
127
127
|
version: findVersion(),
|
|
128
128
|
chromiumVersion: params.chromiumVersion,
|
|
129
129
|
profilePath: params.profilePath,
|
|
130
|
-
capabilities: {
|
|
130
|
+
capabilities: {
|
|
131
|
+
portForwarding: true,
|
|
132
|
+
runJsArtifacts: true,
|
|
133
|
+
readPage: true,
|
|
134
|
+
refActions: true,
|
|
135
|
+
waitFor: true,
|
|
136
|
+
formInput: true,
|
|
137
|
+
typedKeys: true,
|
|
138
|
+
screenshotFullPage: true
|
|
139
|
+
}
|
|
131
140
|
},
|
|
132
141
|
tabs: await params.runtime.listTabs(),
|
|
133
142
|
portForwards: params.portForwards.list()
|
package/dist/mjs/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BrowserContext } from "playwright-core";
|
|
2
|
-
export type BrowserAction = "list_tabs" | "list_downloads" | "get_download" | "open_tab" | "close_tab" | "navigate" | "screenshot" | "move_mouse" | "mouse_click" | "keyboard" | "keyboard_type" | "run_js";
|
|
2
|
+
export type BrowserAction = "list_tabs" | "list_downloads" | "get_download" | "open_tab" | "close_tab" | "navigate" | "read_page" | "screenshot" | "move_mouse" | "mouse_click" | "scroll" | "wait_for" | "form_input" | "keyboard" | "keyboard_type" | "run_js";
|
|
3
3
|
export type TabInfo = {
|
|
4
4
|
tabId: string;
|
|
5
5
|
windowId: string;
|
|
@@ -29,6 +29,8 @@ export declare class BrowserRuntime {
|
|
|
29
29
|
private listDownloads;
|
|
30
30
|
private getDownloadChunk;
|
|
31
31
|
private page;
|
|
32
|
+
private refLocator;
|
|
33
|
+
private gotoSettled;
|
|
32
34
|
private tabInfo;
|
|
33
35
|
listTabs(): Promise<TabInfo[]>;
|
|
34
36
|
drainDownloads(): Promise<void>;
|