@ricsam/r5d-browser 0.0.114 → 0.0.116

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 CHANGED
@@ -28,6 +28,10 @@ Agents read pages through `browser_read_page`, an accessibility outline with sta
28
28
 
29
29
  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 under `$R5D_ROOT/artifacts/$R5D_SESSION_ID`; 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.
30
30
 
31
+ For responsive testing that needs an existing login (for example, an app using Google login), agents can call `browser_set_viewport` with `{ tabId, width: 390, height: 844 }`. Both dimensions must be integers from 1 to 4096 CSS pixels. The override is scoped to that tab, even in a shared window: it does not resize the OS window or affect other tabs. It preserves the profile, user agent, touch support, and device pixel ratio; this is layout sizing, not mobile-device emulation. It adds no Chrome launch flags or webdriver spoofing; `navigator.webdriver` remains natively `false`.
32
+
33
+ The viewport survives navigation, reloads, and r5d.dev control-connection reconnects until the agent calls `browser_set_viewport` with `{ tabId, reset: true }`, closes the tab, or exits the browser. Reset restores sizing from the current OS window; omit width/height when resetting. The tool returns the actual width and height. Re-read the page or capture a screenshot after resizing because old coordinates are stale, and reset when testing is finished. To apply a size before the first page load, open `about:blank`, set its viewport, then navigate. An outdated browser client returns an update-and-restart instruction for this tool.
34
+
31
35
  As an escape hatch, agents can create loopback-only TCP port forwards from this Mac to the worker bound to a chat when a local flow specifically needs this browser's authenticated state or human interaction, such as testing Google OAuth. Ordinary local application testing should use Playwright directly on the worker without a forward. 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.
32
36
 
33
37
  Port forwards remain active across agent turns and r5d.dev control-connection reconnects. They are owned by the running `r5d-browser` process, so restarting that process ends them. Agents can list and stop forwards, and the user can inspect or stop them from Settings.
@@ -18,7 +18,9 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
  var browser_input_exports = {};
20
20
  __export(browser_input_exports, {
21
- normalizeBrowserInput: () => normalizeBrowserInput
21
+ MAX_VIEWPORT_DIMENSION: () => MAX_VIEWPORT_DIMENSION,
22
+ normalizeBrowserInput: () => normalizeBrowserInput,
23
+ parseViewportInput: () => parseViewportInput
22
24
  });
23
25
  module.exports = __toCommonJS(browser_input_exports);
24
26
  const browserInputFields = {
@@ -28,6 +30,7 @@ const browserInputFields = {
28
30
  open_tab: { required: [], optional: ["url", "disposition"] },
29
31
  close_tab: { required: ["tabId"], optional: [] },
30
32
  navigate: { required: ["tabId", "url"], optional: [] },
33
+ set_viewport: { required: ["tabId"], optional: ["width", "height", "reset"] },
31
34
  read_page: { required: ["tabId"], optional: ["depth", "maxChars"] },
32
35
  screenshot: { required: ["tabId"], optional: ["fullPage"] },
33
36
  move_mouse: { required: ["tabId", "x", "y"], optional: [] },
@@ -42,6 +45,24 @@ const browserInputFields = {
42
45
  list_port_forwards: { required: [], optional: [] },
43
46
  stop_port_forward: { required: ["forwardId"], optional: [] }
44
47
  };
48
+ const MAX_VIEWPORT_DIMENSION = 4096;
49
+ function parseViewportInput(input) {
50
+ if (input.reset !== void 0 && typeof input.reset !== "boolean") throw new Error("set_viewport reset must be a boolean.");
51
+ if (input.reset === true) {
52
+ if (input.width !== void 0 || input.height !== void 0)
53
+ throw new Error("set_viewport accepts width/height or reset: true, not both.");
54
+ return null;
55
+ }
56
+ const { width, height } = input;
57
+ for (const [name, value] of Object.entries({ width, height })) {
58
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > MAX_VIEWPORT_DIMENSION) {
59
+ throw new Error(
60
+ `set_viewport requires ${name} to be an integer from 1 to ${MAX_VIEWPORT_DIMENSION} CSS pixels, or reset: true without dimensions.`
61
+ );
62
+ }
63
+ }
64
+ return { width, height };
65
+ }
45
66
  function normalizeBrowserInput(action, input) {
46
67
  const fields = browserInputFields[action];
47
68
  for (const key of fields.required) {
@@ -55,5 +76,7 @@ function normalizeBrowserInput(action, input) {
55
76
  }
56
77
  // Annotate the CommonJS export names for ESM import in node:
57
78
  0 && (module.exports = {
58
- normalizeBrowserInput
79
+ MAX_VIEWPORT_DIMENSION,
80
+ normalizeBrowserInput,
81
+ parseViewportInput
59
82
  });
@@ -84,6 +84,7 @@ class BrowserRuntime {
84
84
  ids = /* @__PURE__ */ new WeakMap();
85
85
  pages = /* @__PURE__ */ new Map();
86
86
  cursor = /* @__PURE__ */ new Map();
87
+ viewportSessions = /* @__PURE__ */ new Map();
87
88
  downloadSaveQueue = Promise.resolve();
88
89
  track(page) {
89
90
  const existing = this.ids.get(page);
@@ -100,6 +101,7 @@ class BrowserRuntime {
100
101
  page.once("close", () => {
101
102
  this.pages.delete(id);
102
103
  this.cursor.delete(id);
104
+ this.viewportSessions.delete(id);
103
105
  });
104
106
  return id;
105
107
  }
@@ -235,6 +237,34 @@ class BrowserRuntime {
235
237
  async drainDownloads() {
236
238
  await this.downloadSaveQueue;
237
239
  }
240
+ async setViewport(page, tabId, viewport) {
241
+ const existing = this.viewportSessions.get(tabId);
242
+ if (viewport) {
243
+ const cdp = existing ?? await this.context.newCDPSession(page);
244
+ try {
245
+ await cdp.send("Emulation.setDeviceMetricsOverride", { ...viewport, deviceScaleFactor: 0, mobile: false });
246
+ this.viewportSessions.set(tabId, cdp);
247
+ } catch (error) {
248
+ if (!existing) await cdp.detach().catch(() => void 0);
249
+ throw error;
250
+ }
251
+ } else if (existing) {
252
+ await existing.send("Emulation.clearDeviceMetricsOverride");
253
+ this.viewportSessions.delete(tabId);
254
+ await existing.detach().catch(() => void 0);
255
+ }
256
+ this.cursor.delete(tabId);
257
+ if (viewport) {
258
+ const ready = await page.waitForFunction(
259
+ ({ width, height }) => window.innerWidth === width && window.innerHeight === height,
260
+ viewport,
261
+ { timeout: 5e3 }
262
+ );
263
+ await ready.dispose();
264
+ }
265
+ const actual = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }));
266
+ return { tabId, ...actual, reset: viewport === null };
267
+ }
238
268
  async execute(action, input) {
239
269
  input = (0, import_browser_input.normalizeBrowserInput)(action, input);
240
270
  if (action === "list_tabs") return { tabs: await this.listTabs() };
@@ -275,6 +305,8 @@ class BrowserRuntime {
275
305
  case "navigate":
276
306
  await this.gotoSettled(page, String(input.url));
277
307
  return { tab: await this.tabInfo(page) };
308
+ case "set_viewport":
309
+ return await this.setViewport(page, tabId, (0, import_browser_input.parseViewportInput)(input));
278
310
  case "read_page": {
279
311
  const maxChars = clampInt(input.maxChars, 1e3, 2e5, 5e4);
280
312
  const depth = input.depth === void 0 ? void 0 : clampInt(input.depth, 1, 50, 50);
@@ -314,13 +346,28 @@ class BrowserRuntime {
314
346
  case "screenshot": {
315
347
  if (input.fullPage === true) {
316
348
  const metrics = await page.evaluate(() => ({
317
- docWidth: Math.max(document.documentElement.scrollWidth, window.innerWidth),
318
- docHeight: Math.max(document.documentElement.scrollHeight, window.innerHeight)
349
+ // Match Playwright's full-page bounds, which exclude scrollbar gutters.
350
+ docWidth: Math.max(
351
+ document.documentElement.scrollWidth,
352
+ document.documentElement.offsetWidth,
353
+ document.documentElement.clientWidth,
354
+ document.body?.scrollWidth ?? 0,
355
+ document.body?.offsetWidth ?? 0,
356
+ document.body?.clientWidth ?? 0
357
+ ),
358
+ docHeight: Math.max(
359
+ document.documentElement.scrollHeight,
360
+ document.documentElement.offsetHeight,
361
+ document.documentElement.clientHeight,
362
+ document.body?.scrollHeight ?? 0,
363
+ document.body?.offsetHeight ?? 0,
364
+ document.body?.clientHeight ?? 0
365
+ )
319
366
  }));
320
367
  const width = Math.min(metrics.docWidth, MAX_FULL_PAGE_WIDTH);
321
368
  const height = Math.min(metrics.docHeight, MAX_FULL_PAGE_HEIGHT);
322
369
  const truncated = metrics.docHeight > MAX_FULL_PAGE_HEIGHT || metrics.docWidth > MAX_FULL_PAGE_WIDTH;
323
- 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 });
370
+ const bytes2 = truncated ? await page.screenshot({ type: "png", scale: "css", timeout: 15e3, fullPage: true, clip: { x: 0, y: 0, width, height } }) : await page.screenshot({ type: "png", scale: "css", timeout: 15e3, fullPage: true });
324
371
  return { base64: bytes2.toString("base64"), width, height, fullPage: true, pageHeight: metrics.docHeight, truncated };
325
372
  }
326
373
  const bytes = await page.screenshot({ type: "png", scale: "css" });
package/dist/cjs/main.cjs CHANGED
@@ -159,7 +159,8 @@ async function connectLoop(params) {
159
159
  waitFor: true,
160
160
  formInput: true,
161
161
  typedKeys: true,
162
- screenshotFullPage: true
162
+ screenshotFullPage: true,
163
+ viewportSizing: true
163
164
  }
164
165
  },
165
166
  tabs: await params.runtime.listTabs(),
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.114",
3
+ "version": "0.0.116",
4
4
  "type": "commonjs"
5
5
  }
@@ -5,6 +5,7 @@ const browserInputFields = {
5
5
  open_tab: { required: [], optional: ["url", "disposition"] },
6
6
  close_tab: { required: ["tabId"], optional: [] },
7
7
  navigate: { required: ["tabId", "url"], optional: [] },
8
+ set_viewport: { required: ["tabId"], optional: ["width", "height", "reset"] },
8
9
  read_page: { required: ["tabId"], optional: ["depth", "maxChars"] },
9
10
  screenshot: { required: ["tabId"], optional: ["fullPage"] },
10
11
  move_mouse: { required: ["tabId", "x", "y"], optional: [] },
@@ -19,6 +20,24 @@ const browserInputFields = {
19
20
  list_port_forwards: { required: [], optional: [] },
20
21
  stop_port_forward: { required: ["forwardId"], optional: [] }
21
22
  };
23
+ const MAX_VIEWPORT_DIMENSION = 4096;
24
+ function parseViewportInput(input) {
25
+ if (input.reset !== void 0 && typeof input.reset !== "boolean") throw new Error("set_viewport reset must be a boolean.");
26
+ if (input.reset === true) {
27
+ if (input.width !== void 0 || input.height !== void 0)
28
+ throw new Error("set_viewport accepts width/height or reset: true, not both.");
29
+ return null;
30
+ }
31
+ const { width, height } = input;
32
+ for (const [name, value] of Object.entries({ width, height })) {
33
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > MAX_VIEWPORT_DIMENSION) {
34
+ throw new Error(
35
+ `set_viewport requires ${name} to be an integer from 1 to ${MAX_VIEWPORT_DIMENSION} CSS pixels, or reset: true without dimensions.`
36
+ );
37
+ }
38
+ }
39
+ return { width, height };
40
+ }
22
41
  function normalizeBrowserInput(action, input) {
23
42
  const fields = browserInputFields[action];
24
43
  for (const key of fields.required) {
@@ -31,5 +50,7 @@ function normalizeBrowserInput(action, input) {
31
50
  return normalized;
32
51
  }
33
52
  export {
34
- normalizeBrowserInput
53
+ MAX_VIEWPORT_DIMENSION,
54
+ normalizeBrowserInput,
55
+ parseViewportInput
35
56
  };
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { normalizeBrowserInput } from "./browser-input.mjs";
3
+ import { normalizeBrowserInput, parseViewportInput } from "./browser-input.mjs";
4
4
  import { passwordSafeAriaSnapshot } from "./password-safe-snapshot.mjs";
5
5
  const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
6
6
  const MAX_RUN_JS_RESULT_BYTES = 1024 * 1024;
@@ -51,6 +51,7 @@ class BrowserRuntime {
51
51
  ids = /* @__PURE__ */ new WeakMap();
52
52
  pages = /* @__PURE__ */ new Map();
53
53
  cursor = /* @__PURE__ */ new Map();
54
+ viewportSessions = /* @__PURE__ */ new Map();
54
55
  downloadSaveQueue = Promise.resolve();
55
56
  track(page) {
56
57
  const existing = this.ids.get(page);
@@ -67,6 +68,7 @@ class BrowserRuntime {
67
68
  page.once("close", () => {
68
69
  this.pages.delete(id);
69
70
  this.cursor.delete(id);
71
+ this.viewportSessions.delete(id);
70
72
  });
71
73
  return id;
72
74
  }
@@ -202,6 +204,34 @@ class BrowserRuntime {
202
204
  async drainDownloads() {
203
205
  await this.downloadSaveQueue;
204
206
  }
207
+ async setViewport(page, tabId, viewport) {
208
+ const existing = this.viewportSessions.get(tabId);
209
+ if (viewport) {
210
+ const cdp = existing ?? await this.context.newCDPSession(page);
211
+ try {
212
+ await cdp.send("Emulation.setDeviceMetricsOverride", { ...viewport, deviceScaleFactor: 0, mobile: false });
213
+ this.viewportSessions.set(tabId, cdp);
214
+ } catch (error) {
215
+ if (!existing) await cdp.detach().catch(() => void 0);
216
+ throw error;
217
+ }
218
+ } else if (existing) {
219
+ await existing.send("Emulation.clearDeviceMetricsOverride");
220
+ this.viewportSessions.delete(tabId);
221
+ await existing.detach().catch(() => void 0);
222
+ }
223
+ this.cursor.delete(tabId);
224
+ if (viewport) {
225
+ const ready = await page.waitForFunction(
226
+ ({ width, height }) => window.innerWidth === width && window.innerHeight === height,
227
+ viewport,
228
+ { timeout: 5e3 }
229
+ );
230
+ await ready.dispose();
231
+ }
232
+ const actual = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }));
233
+ return { tabId, ...actual, reset: viewport === null };
234
+ }
205
235
  async execute(action, input) {
206
236
  input = normalizeBrowserInput(action, input);
207
237
  if (action === "list_tabs") return { tabs: await this.listTabs() };
@@ -242,6 +272,8 @@ class BrowserRuntime {
242
272
  case "navigate":
243
273
  await this.gotoSettled(page, String(input.url));
244
274
  return { tab: await this.tabInfo(page) };
275
+ case "set_viewport":
276
+ return await this.setViewport(page, tabId, parseViewportInput(input));
245
277
  case "read_page": {
246
278
  const maxChars = clampInt(input.maxChars, 1e3, 2e5, 5e4);
247
279
  const depth = input.depth === void 0 ? void 0 : clampInt(input.depth, 1, 50, 50);
@@ -281,13 +313,28 @@ class BrowserRuntime {
281
313
  case "screenshot": {
282
314
  if (input.fullPage === true) {
283
315
  const metrics = await page.evaluate(() => ({
284
- docWidth: Math.max(document.documentElement.scrollWidth, window.innerWidth),
285
- docHeight: Math.max(document.documentElement.scrollHeight, window.innerHeight)
316
+ // Match Playwright's full-page bounds, which exclude scrollbar gutters.
317
+ docWidth: Math.max(
318
+ document.documentElement.scrollWidth,
319
+ document.documentElement.offsetWidth,
320
+ document.documentElement.clientWidth,
321
+ document.body?.scrollWidth ?? 0,
322
+ document.body?.offsetWidth ?? 0,
323
+ document.body?.clientWidth ?? 0
324
+ ),
325
+ docHeight: Math.max(
326
+ document.documentElement.scrollHeight,
327
+ document.documentElement.offsetHeight,
328
+ document.documentElement.clientHeight,
329
+ document.body?.scrollHeight ?? 0,
330
+ document.body?.offsetHeight ?? 0,
331
+ document.body?.clientHeight ?? 0
332
+ )
286
333
  }));
287
334
  const width = Math.min(metrics.docWidth, MAX_FULL_PAGE_WIDTH);
288
335
  const height = Math.min(metrics.docHeight, MAX_FULL_PAGE_HEIGHT);
289
336
  const truncated = metrics.docHeight > MAX_FULL_PAGE_HEIGHT || metrics.docWidth > MAX_FULL_PAGE_WIDTH;
290
- 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 });
337
+ const bytes2 = truncated ? await page.screenshot({ type: "png", scale: "css", timeout: 15e3, fullPage: true, clip: { x: 0, y: 0, width, height } }) : await page.screenshot({ type: "png", scale: "css", timeout: 15e3, fullPage: true });
291
338
  return { base64: bytes2.toString("base64"), width, height, fullPage: true, pageHeight: metrics.docHeight, truncated };
292
339
  }
293
340
  const bytes = await page.screenshot({ type: "png", scale: "css" });
package/dist/mjs/main.mjs CHANGED
@@ -136,7 +136,8 @@ async function connectLoop(params) {
136
136
  waitFor: true,
137
137
  formInput: true,
138
138
  typedKeys: true,
139
- screenshotFullPage: true
139
+ screenshotFullPage: true,
140
+ viewportSizing: true
140
141
  }
141
142
  },
142
143
  tabs: await params.runtime.listTabs(),
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.114",
3
+ "version": "0.0.116",
4
4
  "type": "module"
5
5
  }
@@ -23,6 +23,10 @@ declare const browserInputFields: {
23
23
  required: string[];
24
24
  optional: never[];
25
25
  };
26
+ set_viewport: {
27
+ required: string[];
28
+ optional: string[];
29
+ };
26
30
  read_page: {
27
31
  required: string[];
28
32
  optional: string[];
@@ -77,5 +81,11 @@ declare const browserInputFields: {
77
81
  };
78
82
  };
79
83
  export type BrowserInputAction = keyof typeof browserInputFields;
84
+ export declare const MAX_VIEWPORT_DIMENSION = 4096;
85
+ export type BrowserViewport = {
86
+ width: number;
87
+ height: number;
88
+ };
89
+ export declare function parseViewportInput(input: Readonly<Record<string, unknown>>): BrowserViewport | null;
80
90
  export declare function normalizeBrowserInput(action: BrowserInputAction, input: Readonly<Record<string, unknown>>): Record<string, unknown>;
81
91
  export {};
@@ -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" | "read_page" | "screenshot" | "move_mouse" | "mouse_click" | "scroll" | "wait_for" | "form_input" | "keyboard" | "keyboard_type" | "run_js";
2
+ export type BrowserAction = "list_tabs" | "list_downloads" | "get_download" | "open_tab" | "close_tab" | "navigate" | "set_viewport" | "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;
@@ -18,6 +18,7 @@ export declare class BrowserRuntime {
18
18
  private readonly ids;
19
19
  private readonly pages;
20
20
  private readonly cursor;
21
+ private readonly viewportSessions;
21
22
  private downloadSaveQueue;
22
23
  constructor(context: BrowserContext, downloadsPath: string);
23
24
  private track;
@@ -34,5 +35,6 @@ export declare class BrowserRuntime {
34
35
  private tabInfo;
35
36
  listTabs(): Promise<TabInfo[]>;
36
37
  drainDownloads(): Promise<void>;
38
+ private setViewport;
37
39
  execute(action: BrowserAction, input: Record<string, unknown>): Promise<unknown>;
38
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.114",
3
+ "version": "0.0.116",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",