@monoes/monobrowse 1.0.13 → 1.0.14

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.
Files changed (107) hide show
  1. package/dist/src/__tests__/bridge-instruments.test.d.ts +2 -0
  2. package/dist/src/__tests__/bridge-instruments.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/bridge-instruments.test.js +132 -0
  4. package/dist/src/__tests__/bridge-instruments.test.js.map +1 -0
  5. package/dist/src/__tests__/bridge-transport.test.d.ts +2 -0
  6. package/dist/src/__tests__/bridge-transport.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/bridge-transport.test.js +287 -0
  8. package/dist/src/__tests__/bridge-transport.test.js.map +1 -0
  9. package/dist/src/__tests__/cdp-transport.test.d.ts +2 -0
  10. package/dist/src/__tests__/cdp-transport.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/cdp-transport.test.js +167 -0
  12. package/dist/src/__tests__/cdp-transport.test.js.map +1 -0
  13. package/dist/src/browser/bridge.d.ts +119 -0
  14. package/dist/src/browser/bridge.d.ts.map +1 -0
  15. package/dist/src/browser/bridge.js +264 -0
  16. package/dist/src/browser/bridge.js.map +1 -0
  17. package/dist/src/browser/cdp.d.ts +11 -1
  18. package/dist/src/browser/cdp.d.ts.map +1 -1
  19. package/dist/src/browser/cdp.js +60 -56
  20. package/dist/src/browser/cdp.js.map +1 -1
  21. package/dist/src/browser/index.d.ts +2 -0
  22. package/dist/src/browser/index.d.ts.map +1 -1
  23. package/dist/src/browser/index.js +2 -0
  24. package/dist/src/browser/index.js.map +1 -1
  25. package/dist/src/browser/transport.d.ts +62 -0
  26. package/dist/src/browser/transport.d.ts.map +1 -0
  27. package/dist/src/browser/transport.js +65 -0
  28. package/dist/src/browser/transport.js.map +1 -0
  29. package/dist/src/cli/commander-adapter.d.ts +16 -0
  30. package/dist/src/cli/commander-adapter.d.ts.map +1 -0
  31. package/dist/src/cli/commander-adapter.js +68 -0
  32. package/dist/src/cli/commander-adapter.js.map +1 -0
  33. package/dist/src/cli/commands-context.d.ts +14 -0
  34. package/dist/src/cli/commands-context.d.ts.map +1 -0
  35. package/dist/src/cli/commands-context.js +351 -0
  36. package/dist/src/cli/commands-context.js.map +1 -0
  37. package/dist/src/cli/commands-data.d.ts +12 -0
  38. package/dist/src/cli/commands-data.d.ts.map +1 -0
  39. package/dist/src/cli/commands-data.js +246 -0
  40. package/dist/src/cli/commands-data.js.map +1 -0
  41. package/dist/src/cli/commands-element.d.ts +15 -0
  42. package/dist/src/cli/commands-element.d.ts.map +1 -0
  43. package/dist/src/cli/commands-element.js +262 -0
  44. package/dist/src/cli/commands-element.js.map +1 -0
  45. package/dist/src/cli/commands-files.d.ts +9 -0
  46. package/dist/src/cli/commands-files.d.ts.map +1 -0
  47. package/dist/src/cli/commands-files.js +207 -0
  48. package/dist/src/cli/commands-files.js.map +1 -0
  49. package/dist/src/cli/commands-input.d.ts +26 -0
  50. package/dist/src/cli/commands-input.d.ts.map +1 -0
  51. package/dist/src/cli/commands-input.js +399 -0
  52. package/dist/src/cli/commands-input.js.map +1 -0
  53. package/dist/src/cli/commands-navigate.d.ts +12 -0
  54. package/dist/src/cli/commands-navigate.d.ts.map +1 -0
  55. package/dist/src/cli/commands-navigate.js +389 -0
  56. package/dist/src/cli/commands-navigate.js.map +1 -0
  57. package/dist/src/cli/commands-page.d.ts +25 -0
  58. package/dist/src/cli/commands-page.d.ts.map +1 -0
  59. package/dist/src/cli/commands-page.js +377 -0
  60. package/dist/src/cli/commands-page.js.map +1 -0
  61. package/dist/src/cli/commands-report.d.ts +8 -0
  62. package/dist/src/cli/commands-report.d.ts.map +1 -0
  63. package/dist/src/cli/commands-report.js +256 -0
  64. package/dist/src/cli/commands-report.js.map +1 -0
  65. package/dist/src/cli/commands-script.d.ts +43 -0
  66. package/dist/src/cli/commands-script.d.ts.map +1 -0
  67. package/dist/src/cli/commands-script.js +168 -0
  68. package/dist/src/cli/commands-script.js.map +1 -0
  69. package/dist/src/cli/commands-session.d.ts +15 -0
  70. package/dist/src/cli/commands-session.d.ts.map +1 -0
  71. package/dist/src/cli/commands-session.js +357 -0
  72. package/dist/src/cli/commands-session.js.map +1 -0
  73. package/dist/src/cli/commands-trace.d.ts +12 -0
  74. package/dist/src/cli/commands-trace.d.ts.map +1 -0
  75. package/dist/src/cli/commands-trace.js +347 -0
  76. package/dist/src/cli/commands-trace.js.map +1 -0
  77. package/dist/src/cli/commands.d.ts +22 -42
  78. package/dist/src/cli/commands.d.ts.map +1 -1
  79. package/dist/src/cli/commands.js +42 -3648
  80. package/dist/src/cli/commands.js.map +1 -1
  81. package/dist/src/cli/session.d.ts +47 -0
  82. package/dist/src/cli/session.d.ts.map +1 -0
  83. package/dist/src/cli/session.js +343 -0
  84. package/dist/src/cli/session.js.map +1 -0
  85. package/dist/tsconfig.tsbuildinfo +1 -1
  86. package/package.json +1 -1
  87. package/src/__tests__/bridge-instruments.test.ts +180 -0
  88. package/src/__tests__/bridge-transport.test.ts +365 -0
  89. package/src/__tests__/cdp-transport.test.ts +198 -0
  90. package/src/browser/bridge.ts +354 -0
  91. package/src/browser/cdp.ts +57 -52
  92. package/src/browser/index.ts +2 -0
  93. package/src/browser/transport.ts +111 -0
  94. package/src/cli/commander-adapter.ts +65 -0
  95. package/src/cli/commands-context.ts +367 -0
  96. package/src/cli/commands-data.ts +248 -0
  97. package/src/cli/commands-element.ts +305 -0
  98. package/src/cli/commands-files.ts +248 -0
  99. package/src/cli/commands-input.ts +434 -0
  100. package/src/cli/commands-navigate.ts +442 -0
  101. package/src/cli/commands-page.ts +420 -0
  102. package/src/cli/commands-report.ts +275 -0
  103. package/src/cli/commands-script.ts +183 -0
  104. package/src/cli/commands-session.ts +376 -0
  105. package/src/cli/commands-trace.ts +349 -0
  106. package/src/cli/commands.ts +95 -3943
  107. package/src/cli/session.ts +412 -0
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Moving around and configuring the viewport — history navigation, scrolling,
3
+ * SPA pushState, device/viewport/user-agent settings, and waiting for the page
4
+ * to reach a condition.
5
+ */
6
+
7
+ import type { ElementRef } from '../index.js';
8
+ import { output } from './output.js';
9
+ import { ensureConnected, getBrowser, session } from './session.js';
10
+ import type { Command, CommandContext, CommandResult } from './types.js';
11
+
12
+ export const waitCommand: Command = {
13
+ name: 'wait',
14
+ description: 'Wait for a condition to be met before proceeding',
15
+ options: [
16
+ { name: 'url', type: 'string', description: 'Wait for URL matching glob pattern' },
17
+ { name: 'text', type: 'string', description: 'Wait for text to appear in page' },
18
+ { name: 'not-text', type: 'string', description: 'Wait for text to disappear from page' },
19
+ { name: 'selector', type: 'string', description: 'Wait for CSS selector to appear' },
20
+ {
21
+ name: 'load',
22
+ type: 'string',
23
+ description: 'Wait for load event: load|networkidle|domcontentloaded',
24
+ },
25
+ { name: 'fn', type: 'string', description: 'Wait until JS expression returns truthy' },
26
+ { name: 'ms', type: 'number', description: 'Wait N milliseconds' },
27
+ { name: 'timeout', short: 't', type: 'number', description: 'Timeout in ms', default: 30000 },
28
+ {
29
+ name: 'download',
30
+ type: 'string',
31
+ description:
32
+ 'Wait for a file download to complete and save to path (monitors Browser.downloadProgress events)',
33
+ },
34
+ ],
35
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
36
+ const { client, sessionId } = await ensureConnected(session.port);
37
+ const browser = await getBrowser();
38
+
39
+ if (ctx.flags.ms) {
40
+ const rawMs = ctx.flags.ms as number;
41
+ const waitMs = Number.isFinite(rawMs) ? Math.max(0, Math.min(rawMs, 60_000)) : 0; // cap at 60s
42
+ await new Promise((r) => setTimeout(r, waitMs));
43
+ output.printSuccess(`Waited ${ctx.flags.ms}ms`);
44
+ return { success: true };
45
+ }
46
+
47
+ if (ctx.flags.fn) {
48
+ const expr = ctx.flags.fn as string;
49
+ const rawTimeout = (ctx.flags.timeout as number) ?? 30000;
50
+ const timeout = Number.isFinite(rawTimeout)
51
+ ? Math.max(100, Math.min(rawTimeout, 300_000))
52
+ : 30000; // cap at 5min
53
+ const interval = 200;
54
+ const deadline = Date.now() + timeout;
55
+ while (Date.now() < deadline) {
56
+ const result = await browser.evaluateJs(client, sessionId, expr);
57
+ if (result) {
58
+ output.printSuccess('Wait function returned truthy');
59
+ return { success: true };
60
+ }
61
+ await new Promise((r) => setTimeout(r, interval));
62
+ }
63
+ throw new Error(`Timeout waiting for --fn: ${expr}`);
64
+ }
65
+
66
+ if (ctx.flags['not-text']) {
67
+ const target = ctx.flags['not-text'] as string;
68
+ const timeout = (ctx.flags.timeout as number) ?? 30000;
69
+ const interval = 200;
70
+ const deadline = Date.now() + timeout;
71
+ while (Date.now() < deadline) {
72
+ const text = (await browser.evaluateJs(
73
+ client,
74
+ sessionId,
75
+ 'document.body?.innerText ?? ""',
76
+ )) as string;
77
+ if (!text.includes(target)) {
78
+ output.printSuccess('Text disappeared');
79
+ return { success: true };
80
+ }
81
+ await new Promise((r) => setTimeout(r, interval));
82
+ }
83
+ throw new Error(`Timeout waiting for text to disappear: "${target}"`);
84
+ }
85
+
86
+ if (ctx.flags.download) {
87
+ const savePath = ctx.flags.download as string;
88
+ const { mkdir } = await import('node:fs/promises');
89
+ const { dirname, join } = await import('node:path');
90
+ const { tmpdir } = await import('node:os');
91
+ const downloadDir = join(tmpdir(), `mm-dl-wait-${Date.now()}`);
92
+ await mkdir(downloadDir, { recursive: true });
93
+ await client
94
+ .send(
95
+ 'Browser.setDownloadBehavior',
96
+ {
97
+ behavior: 'allow',
98
+ downloadPath: downloadDir,
99
+ eventsEnabled: true,
100
+ },
101
+ undefined,
102
+ )
103
+ .catch(() =>
104
+ client
105
+ .send(
106
+ 'Page.setDownloadBehavior',
107
+ { behavior: 'allow', downloadPath: downloadDir },
108
+ sessionId,
109
+ )
110
+ .catch(() => {}),
111
+ );
112
+ const MAX_DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // I6: cap at 5 minutes
113
+ const rawTimeout = Math.min((ctx.flags.timeout as number) ?? 30000, MAX_DOWNLOAD_TIMEOUT);
114
+ const finalPath = await new Promise<string>((resolve, reject) => {
115
+ let guid = '';
116
+ let settled = false;
117
+ // C2: capture off() functions to avoid listener leaks
118
+ const offBegin = client.on(
119
+ 'Browser.downloadWillBegin',
120
+ (params: Record<string, unknown>) => {
121
+ guid = params.guid as string;
122
+ },
123
+ );
124
+ let offProgress: (() => void) | undefined;
125
+ // cleanup defined before setTimeout so the timeout callback can call it
126
+ let tid: ReturnType<typeof setTimeout>;
127
+ let pollTid: ReturnType<typeof setInterval> | undefined;
128
+ const cleanup = () => {
129
+ clearTimeout(tid);
130
+ clearInterval(pollTid);
131
+ offBegin?.();
132
+ offProgress?.();
133
+ };
134
+ const finish = (path: string) => {
135
+ if (settled) return;
136
+ settled = true;
137
+ cleanup();
138
+ resolve(path);
139
+ };
140
+ const fail = (err: Error) => {
141
+ if (settled) return;
142
+ settled = true;
143
+ cleanup();
144
+ reject(err);
145
+ };
146
+ tid = setTimeout(() => fail(new Error('Download timed out')), rawTimeout);
147
+ offProgress = client.on(
148
+ 'Browser.downloadProgress',
149
+ async (params: Record<string, unknown>) => {
150
+ if (params.guid === guid && params.state === 'completed') {
151
+ const { readdir, rename, rmdir } = await import('node:fs/promises');
152
+ const files = await readdir(downloadDir);
153
+ if (files.length > 0) {
154
+ const src = join(downloadDir, files[0]);
155
+ await mkdir(dirname(savePath), { recursive: true });
156
+ await rename(src, savePath);
157
+ await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
158
+ finish(savePath);
159
+ } else {
160
+ await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
161
+ fail(new Error('Download completed but no file found'));
162
+ }
163
+ } else if (params.guid === guid && params.state === 'canceled') {
164
+ fail(new Error('Download was canceled'));
165
+ }
166
+ },
167
+ );
168
+
169
+ // Fallback for the empty-guid race: this process's listener attaches
170
+ // AFTER a separate `click` process may have already started (and even
171
+ // finished) the download — so downloadWillBegin/downloadProgress for it
172
+ // were never observed here. While guid is still empty, poll the expected
173
+ // output path directly: if it already exists with a stable, non-zero
174
+ // size across two consecutive checks, treat the download as complete.
175
+ let lastSize = -1;
176
+ let stableReads = 0;
177
+ pollTid = setInterval(async () => {
178
+ if (settled || guid) return; // a real CDP event has taken over
179
+ try {
180
+ const { stat } = await import('node:fs/promises');
181
+ const st = await stat(savePath);
182
+ if (st.isFile() && st.size > 0 && st.size === lastSize) {
183
+ stableReads++;
184
+ if (stableReads >= 2) finish(savePath);
185
+ } else {
186
+ stableReads = 0;
187
+ }
188
+ lastSize = st.size;
189
+ } catch {
190
+ // savePath not present yet — keep polling until timeout
191
+ }
192
+ }, 300);
193
+ });
194
+ output.printSuccess(`Download saved: ${finalPath}`);
195
+ return { success: true, data: { path: finalPath } };
196
+ }
197
+
198
+ await browser.waitFor(client, sessionId, {
199
+ url: ctx.flags.url as string,
200
+ text: ctx.flags.text as string,
201
+ selector: ctx.flags.selector as string,
202
+ load: ctx.flags.load as 'load' | 'networkidle' | 'domcontentloaded',
203
+ timeout: ctx.flags.timeout as number,
204
+ });
205
+
206
+ output.printSuccess('Wait condition met');
207
+ return { success: true };
208
+ },
209
+ };
210
+
211
+ export const scrollCommand: Command = {
212
+ name: 'scroll',
213
+ description:
214
+ 'Scroll the page. Usage: monomind browse scroll up|down|left|right [amount] [--selector ".sidebar"]',
215
+ options: [
216
+ { name: 'amount', short: 'a', type: 'number', description: 'Pixels to scroll', default: 300 },
217
+ { name: 'ref', type: 'string', description: 'Element ref to scroll within' },
218
+ {
219
+ name: 'selector',
220
+ short: 's',
221
+ type: 'string',
222
+ description: 'CSS selector of element to scroll within',
223
+ },
224
+ ],
225
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
226
+ const { client, sessionId } = await ensureConnected(session.port);
227
+ const browser = await getBrowser();
228
+
229
+ const direction = ctx.args[0] as 'up' | 'down' | 'left' | 'right';
230
+ if (!direction) throw new Error('Usage: monomind browse scroll up|down|left|right [amount]');
231
+
232
+ // Support positional amount: scroll down 300
233
+ const positionalAmount =
234
+ ctx.args[1] !== undefined ? parseInt(ctx.args[1] as string, 10) : undefined;
235
+ const amount =
236
+ positionalAmount && Number.isFinite(positionalAmount)
237
+ ? positionalAmount
238
+ : ((ctx.flags.amount as number) ?? 300);
239
+
240
+ if (ctx.flags.selector) {
241
+ const sel = ctx.flags.selector as string;
242
+ const dx = direction === 'right' ? amount : direction === 'left' ? -amount : 0;
243
+ const dy = direction === 'down' ? amount : direction === 'up' ? -amount : 0;
244
+ const posJson = (await browser.evaluateJs(
245
+ client,
246
+ sessionId,
247
+ `(function(){var el=document.querySelector(${JSON.stringify(sel)});if(!el)return null;var r=el.getBoundingClientRect();return JSON.stringify({x:r.left+r.width/2,y:r.top+r.height/2});})()`,
248
+ )) as string | null;
249
+ if (!posJson) throw new Error(`Selector not found: ${sel}`);
250
+ const pos = JSON.parse(posJson) as { x: number; y: number };
251
+ await client.send(
252
+ 'Input.dispatchMouseEvent',
253
+ { type: 'mouseWheel', x: pos.x, y: pos.y, deltaX: dx, deltaY: dy },
254
+ sessionId,
255
+ );
256
+ output.printSuccess(`Scrolled ${direction} in ${sel}`);
257
+ return { success: true };
258
+ }
259
+
260
+ let ref: ElementRef | undefined;
261
+ if (ctx.flags.ref) {
262
+ const refKey = (ctx.flags.ref as string).startsWith('@')
263
+ ? (ctx.flags.ref as string).slice(1)
264
+ : (ctx.flags.ref as string);
265
+ ref = session.refs.get(refKey);
266
+ }
267
+
268
+ await browser.scrollElement(client, sessionId, direction, amount, ref);
269
+ output.printSuccess(`Scrolled ${direction}`);
270
+ return { success: true };
271
+ },
272
+ };
273
+
274
+ export const navigateCommand: Command = {
275
+ name: 'navigate',
276
+ description: 'Navigate browser history. Usage: monomind browse navigate back|forward|reload',
277
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
278
+ const { client, sessionId } = await ensureConnected(session.port);
279
+ const browser = await getBrowser();
280
+
281
+ const direction = ctx.args[0] as string;
282
+ if (!direction) throw new Error('Usage: monomind browse navigate back|forward|reload');
283
+
284
+ if (direction === 'back' || direction === 'forward') {
285
+ // Pre-register frame-start listener BEFORE JS navigation to avoid the race
286
+ // where history.back/forward() returns before the browser issues any requests
287
+ let offFrameStarted: () => void = () => {};
288
+ const frameStartedPromise = new Promise<void>((resolve) => {
289
+ offFrameStarted = client.on('Page.frameStartedLoading', (_params, sid) => {
290
+ if (sid === sessionId) {
291
+ const off = offFrameStarted;
292
+ offFrameStarted = () => {};
293
+ off();
294
+ resolve();
295
+ }
296
+ });
297
+ });
298
+ try {
299
+ await client.send(
300
+ 'Runtime.evaluate',
301
+ {
302
+ expression: direction === 'back' ? 'history.back()' : 'history.forward()',
303
+ },
304
+ sessionId,
305
+ );
306
+ let fallbackHandle: ReturnType<typeof setTimeout> | undefined;
307
+ const fallbackPromise = new Promise<void>((r) => {
308
+ fallbackHandle = setTimeout(r, 2000);
309
+ });
310
+ await Promise.race([frameStartedPromise, fallbackPromise]);
311
+ if (fallbackHandle !== undefined) clearTimeout(fallbackHandle);
312
+ } finally {
313
+ offFrameStarted();
314
+ }
315
+ await browser.waitForLoad(client, sessionId, 'networkidle');
316
+ } else if (direction === 'reload') {
317
+ await client.send('Page.reload', {}, sessionId);
318
+ await browser.waitForLoad(client, sessionId, 'load');
319
+ } else {
320
+ throw new Error(`Unknown direction: ${direction}. Use: back|forward|reload`);
321
+ }
322
+
323
+ // Refs captured before this navigation may now point at different content.
324
+ session.refs = new Map();
325
+ await browser.clearRefCache();
326
+
327
+ output.printSuccess(`Navigated: ${direction}`);
328
+ return { success: true };
329
+ },
330
+ };
331
+
332
+ export const setCommand: Command = {
333
+ name: 'set',
334
+ description:
335
+ 'Configure browser settings. Usage: monomind browse set viewport|device|geo|offline|media|credentials|useragent <args>',
336
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
337
+ const { client, sessionId } = await ensureConnected(session.port);
338
+ const browser = await getBrowser();
339
+
340
+ const setting = ctx.args[0] as string;
341
+ if (!setting)
342
+ throw new Error(
343
+ 'Usage: monomind browse set viewport|device|geo|offline|media|credentials|useragent <args>',
344
+ );
345
+
346
+ switch (setting) {
347
+ case 'viewport': {
348
+ const width = parseInt(ctx.args[1] as string, 10);
349
+ const height = parseInt(ctx.args[2] as string, 10);
350
+ const dpr = parseFloat(ctx.args[3] as string) || undefined;
351
+ if (Number.isNaN(width) || Number.isNaN(height))
352
+ throw new Error('Usage: set viewport <width> <height> [dpr]');
353
+ await client.send(
354
+ 'Emulation.setDeviceMetricsOverride',
355
+ {
356
+ width,
357
+ height,
358
+ deviceScaleFactor: dpr ?? 1,
359
+ mobile: false,
360
+ },
361
+ sessionId,
362
+ );
363
+ output.printSuccess(`Viewport set to ${width}x${height}${dpr ? ` @${dpr}x` : ''}`);
364
+ break;
365
+ }
366
+ case 'device': {
367
+ const deviceName = ctx.args[1] as string;
368
+ if (!deviceName)
369
+ throw new Error(
370
+ `Usage: set device <name>. Available: ${browser.listDevices().join(', ')}`,
371
+ );
372
+ await browser.emulateDevice(client, sessionId, deviceName);
373
+ output.printSuccess(`Emulating device: ${deviceName}`);
374
+ break;
375
+ }
376
+ case 'geo': {
377
+ const lat = parseFloat(ctx.args[1] as string);
378
+ const lon = parseFloat(ctx.args[2] as string);
379
+ const acc = parseFloat(ctx.args[3] as string) || 100;
380
+ if (Number.isNaN(lat) || Number.isNaN(lon))
381
+ throw new Error('Usage: set geo <latitude> <longitude> [accuracy]');
382
+ await browser.setGeolocation(client, sessionId, lat, lon, acc);
383
+ output.printSuccess(`Geolocation set: ${lat}, ${lon}`);
384
+ break;
385
+ }
386
+ case 'offline': {
387
+ const offlineArg = ctx.args[1];
388
+ if (offlineArg === undefined) throw new Error('Usage: set offline <true|false>');
389
+ const enabled = offlineArg === 'true';
390
+ await browser.setOfflineMode(client, sessionId, enabled);
391
+ output.printSuccess(`Offline mode: ${enabled}`);
392
+ break;
393
+ }
394
+ case 'media': {
395
+ const scheme = ctx.args[1] as 'dark' | 'light' | 'no-preference';
396
+ if (!scheme) throw new Error('Usage: set media dark|light|no-preference');
397
+ await browser.setColorScheme(client, sessionId, scheme);
398
+ output.printSuccess(`Color scheme: ${scheme}`);
399
+ break;
400
+ }
401
+ case 'credentials': {
402
+ const username = ctx.args[1] as string;
403
+ const password = ctx.args[2] as string;
404
+ if (!username || !password) throw new Error('Usage: set credentials <username> <password>');
405
+ await browser.setBasicAuth(client, sessionId, username, password);
406
+ output.printSuccess('Basic auth credentials set');
407
+ break;
408
+ }
409
+ case 'useragent': {
410
+ const ua = ctx.args[1] as string;
411
+ if (!ua) throw new Error('Usage: set useragent "<user-agent-string>"');
412
+ await browser.setUserAgent(client, sessionId, ua);
413
+ output.printSuccess('User agent set');
414
+ break;
415
+ }
416
+ default:
417
+ throw new Error(
418
+ `Unknown setting: ${setting}. Use: viewport|device|geo|offline|media|credentials|useragent`,
419
+ );
420
+ }
421
+
422
+ return { success: true };
423
+ },
424
+ };
425
+
426
+ export const pushstateCommand: Command = {
427
+ name: 'pushstate',
428
+ description: 'SPA navigation via pushState. Usage: monomind browse pushstate /path',
429
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
430
+ const { client, sessionId } = await ensureConnected(session.port);
431
+ const browser = await getBrowser();
432
+ const url = ctx.args[0] as string;
433
+ if (!url) throw new Error('Usage: monomind browse pushstate <url>');
434
+ await browser.pushState(client, sessionId, url);
435
+ // SPA navigation changes what's on the page without a full page load —
436
+ // refs captured before this pushState call may now resolve to different content.
437
+ session.refs = new Map();
438
+ await browser.clearRefCache();
439
+ output.printSuccess(`pushState: ${url}`);
440
+ return { success: true };
441
+ },
442
+ };