@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.
- package/dist/src/__tests__/bridge-instruments.test.d.ts +2 -0
- package/dist/src/__tests__/bridge-instruments.test.d.ts.map +1 -0
- package/dist/src/__tests__/bridge-instruments.test.js +132 -0
- package/dist/src/__tests__/bridge-instruments.test.js.map +1 -0
- package/dist/src/__tests__/bridge-transport.test.d.ts +2 -0
- package/dist/src/__tests__/bridge-transport.test.d.ts.map +1 -0
- package/dist/src/__tests__/bridge-transport.test.js +287 -0
- package/dist/src/__tests__/bridge-transport.test.js.map +1 -0
- package/dist/src/__tests__/cdp-transport.test.d.ts +2 -0
- package/dist/src/__tests__/cdp-transport.test.d.ts.map +1 -0
- package/dist/src/__tests__/cdp-transport.test.js +167 -0
- package/dist/src/__tests__/cdp-transport.test.js.map +1 -0
- package/dist/src/browser/bridge.d.ts +119 -0
- package/dist/src/browser/bridge.d.ts.map +1 -0
- package/dist/src/browser/bridge.js +264 -0
- package/dist/src/browser/bridge.js.map +1 -0
- package/dist/src/browser/cdp.d.ts +11 -1
- package/dist/src/browser/cdp.d.ts.map +1 -1
- package/dist/src/browser/cdp.js +60 -56
- package/dist/src/browser/cdp.js.map +1 -1
- package/dist/src/browser/index.d.ts +2 -0
- package/dist/src/browser/index.d.ts.map +1 -1
- package/dist/src/browser/index.js +2 -0
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/browser/transport.d.ts +62 -0
- package/dist/src/browser/transport.d.ts.map +1 -0
- package/dist/src/browser/transport.js +65 -0
- package/dist/src/browser/transport.js.map +1 -0
- package/dist/src/cli/commander-adapter.d.ts +16 -0
- package/dist/src/cli/commander-adapter.d.ts.map +1 -0
- package/dist/src/cli/commander-adapter.js +68 -0
- package/dist/src/cli/commander-adapter.js.map +1 -0
- package/dist/src/cli/commands-context.d.ts +14 -0
- package/dist/src/cli/commands-context.d.ts.map +1 -0
- package/dist/src/cli/commands-context.js +351 -0
- package/dist/src/cli/commands-context.js.map +1 -0
- package/dist/src/cli/commands-data.d.ts +12 -0
- package/dist/src/cli/commands-data.d.ts.map +1 -0
- package/dist/src/cli/commands-data.js +246 -0
- package/dist/src/cli/commands-data.js.map +1 -0
- package/dist/src/cli/commands-element.d.ts +15 -0
- package/dist/src/cli/commands-element.d.ts.map +1 -0
- package/dist/src/cli/commands-element.js +262 -0
- package/dist/src/cli/commands-element.js.map +1 -0
- package/dist/src/cli/commands-files.d.ts +9 -0
- package/dist/src/cli/commands-files.d.ts.map +1 -0
- package/dist/src/cli/commands-files.js +207 -0
- package/dist/src/cli/commands-files.js.map +1 -0
- package/dist/src/cli/commands-input.d.ts +26 -0
- package/dist/src/cli/commands-input.d.ts.map +1 -0
- package/dist/src/cli/commands-input.js +399 -0
- package/dist/src/cli/commands-input.js.map +1 -0
- package/dist/src/cli/commands-navigate.d.ts +12 -0
- package/dist/src/cli/commands-navigate.d.ts.map +1 -0
- package/dist/src/cli/commands-navigate.js +389 -0
- package/dist/src/cli/commands-navigate.js.map +1 -0
- package/dist/src/cli/commands-page.d.ts +25 -0
- package/dist/src/cli/commands-page.d.ts.map +1 -0
- package/dist/src/cli/commands-page.js +377 -0
- package/dist/src/cli/commands-page.js.map +1 -0
- package/dist/src/cli/commands-report.d.ts +8 -0
- package/dist/src/cli/commands-report.d.ts.map +1 -0
- package/dist/src/cli/commands-report.js +256 -0
- package/dist/src/cli/commands-report.js.map +1 -0
- package/dist/src/cli/commands-script.d.ts +43 -0
- package/dist/src/cli/commands-script.d.ts.map +1 -0
- package/dist/src/cli/commands-script.js +168 -0
- package/dist/src/cli/commands-script.js.map +1 -0
- package/dist/src/cli/commands-session.d.ts +15 -0
- package/dist/src/cli/commands-session.d.ts.map +1 -0
- package/dist/src/cli/commands-session.js +357 -0
- package/dist/src/cli/commands-session.js.map +1 -0
- package/dist/src/cli/commands-trace.d.ts +12 -0
- package/dist/src/cli/commands-trace.d.ts.map +1 -0
- package/dist/src/cli/commands-trace.js +347 -0
- package/dist/src/cli/commands-trace.js.map +1 -0
- package/dist/src/cli/commands.d.ts +22 -42
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +42 -3648
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/session.d.ts +47 -0
- package/dist/src/cli/session.d.ts.map +1 -0
- package/dist/src/cli/session.js +343 -0
- package/dist/src/cli/session.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/bridge-instruments.test.ts +180 -0
- package/src/__tests__/bridge-transport.test.ts +365 -0
- package/src/__tests__/cdp-transport.test.ts +198 -0
- package/src/browser/bridge.ts +354 -0
- package/src/browser/cdp.ts +57 -52
- package/src/browser/index.ts +2 -0
- package/src/browser/transport.ts +111 -0
- package/src/cli/commander-adapter.ts +65 -0
- package/src/cli/commands-context.ts +367 -0
- package/src/cli/commands-data.ts +248 -0
- package/src/cli/commands-element.ts +305 -0
- package/src/cli/commands-files.ts +248 -0
- package/src/cli/commands-input.ts +434 -0
- package/src/cli/commands-navigate.ts +442 -0
- package/src/cli/commands-page.ts +420 -0
- package/src/cli/commands-report.ts +275 -0
- package/src/cli/commands-script.ts +183 -0
- package/src/cli/commands-session.ts +376 -0
- package/src/cli/commands-trace.ts +349 -0
- package/src/cli/commands.ts +95 -3943
- package/src/cli/session.ts +412 -0
package/dist/src/cli/commands.js
CHANGED
|
@@ -1,3267 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Browse Command — Native browser automation via Chrome DevTools Protocol
|
|
3
3
|
* Provides ref-based element model and token-efficient accessibility snapshots
|
|
4
|
-
*/
|
|
5
|
-
import { createActionCommand } from './action.js';
|
|
6
|
-
import { output } from './output.js';
|
|
7
|
-
import { createPlatformCommand } from './platform.js';
|
|
8
|
-
// Runtime state (single session per CLI process)
|
|
9
|
-
const DEFAULT_PORT = 9222;
|
|
10
|
-
let _client = null;
|
|
11
|
-
let _sessionId = '';
|
|
12
|
-
let _targetId = '';
|
|
13
|
-
let _port = DEFAULT_PORT;
|
|
14
|
-
let _refs = new Map();
|
|
15
|
-
// Saved parent sessionId when inside an iframe — restored by `frame main`.
|
|
16
|
-
let _parentSessionId = '';
|
|
17
|
-
async function getBrowser() {
|
|
18
|
-
return import('../index.js');
|
|
19
|
-
}
|
|
20
|
-
// Best-effort cleanup on Ctrl-C / kill so a launched Chrome doesn't linger as
|
|
21
|
-
// an orphan process when a command is interrupted mid-flight (e.g. during a
|
|
22
|
-
// long-running wait/eval that CdpClient.send()'s own timeout hasn't tripped
|
|
23
|
-
// yet).
|
|
24
|
-
//
|
|
25
|
-
// This module is imported for EVERY `monomind` CLI invocation (commands/
|
|
26
|
-
// index.ts statically imports browse.ts, which imports this file) — not just
|
|
27
|
-
// browse commands — so the handler is only *registered* once a browser
|
|
28
|
-
// session is actually launched here (see ensureSignalCleanupHandlers(),
|
|
29
|
-
// called from the `open` action below), not at module load. Registering
|
|
30
|
-
// unconditionally at import time would call process.exit() on Ctrl-C for
|
|
31
|
-
// every unrelated monomind command (e.g. a long-running `org run` daemon),
|
|
32
|
-
// pre-empting whatever other SIGINT/SIGTERM handling that process needs.
|
|
33
|
-
const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143 };
|
|
34
|
-
let _signalHandlersRegistered = false;
|
|
35
|
-
function ensureSignalCleanupHandlers() {
|
|
36
|
-
if (_signalHandlersRegistered)
|
|
37
|
-
return;
|
|
38
|
-
_signalHandlersRegistered = true;
|
|
39
|
-
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
40
|
-
process.on(sig, () => {
|
|
41
|
-
void (async () => {
|
|
42
|
-
try {
|
|
43
|
-
const browser = await getBrowser();
|
|
44
|
-
if (_client) {
|
|
45
|
-
await browser.closeBrowser(_client, _port);
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
const pid = browser.getLaunchedPid(_port);
|
|
49
|
-
if (pid !== undefined) {
|
|
50
|
-
try {
|
|
51
|
-
process.kill(pid, 'SIGKILL');
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
/* already exited */
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
// best-effort — never block process exit on cleanup failure
|
|
61
|
-
}
|
|
62
|
-
})().finally(() => process.exit(SIGNAL_EXIT_CODES[sig]));
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
// Each CLI invocation is a fresh process, so this module's `_port` variable
|
|
67
|
-
// is always freshly initialized to DEFAULT_PORT — a `--port 9333` passed to
|
|
68
|
-
// an earlier `open` command has no effect on this process's own default.
|
|
69
|
-
// Resolve the persisted "active port" (written by `open --port N`, read via
|
|
70
|
-
// ref-cache.ts's saveActivePort/loadActivePort) so subsequent commands
|
|
71
|
-
// attach to the browser the user actually opened instead of silently
|
|
72
|
-
// launching/attaching to a second, unrelated Chrome instance on 9222.
|
|
73
|
-
async function resolveDefaultPort(browser) {
|
|
74
|
-
const persisted = await browser.loadActivePortInfo();
|
|
75
|
-
if (!persisted)
|
|
76
|
-
return DEFAULT_PORT;
|
|
77
|
-
if (!persisted.launched) {
|
|
78
|
-
// connect-origin port: the browser belongs to someone else. If it died,
|
|
79
|
-
// relaunching our own headless Chrome on that port would squat the
|
|
80
|
-
// user's debug port and silently swap which browser commands act on —
|
|
81
|
-
// fail loudly instead.
|
|
82
|
-
try {
|
|
83
|
-
await fetch(`http://127.0.0.1:${persisted.port}/json/version`, {
|
|
84
|
-
signal: AbortSignal.timeout(1500),
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
await browser.clearActivePort();
|
|
89
|
-
throw new Error(`Connected browser on port ${persisted.port} is gone — re-run \`connect\` (or \`open\` to launch a fresh one).`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return persisted.port;
|
|
93
|
-
}
|
|
94
|
-
async function ensureConnected(port, targetId) {
|
|
95
|
-
const browser = await getBrowser();
|
|
96
|
-
if (!_client?.isConnected()) {
|
|
97
|
-
if (_client && _sessionId) {
|
|
98
|
-
browser.teardownRouteInterception(_sessionId);
|
|
99
|
-
browser.stopRequestCapture(_sessionId);
|
|
100
|
-
browser.teardownDialogHandling(_sessionId);
|
|
101
|
-
browser.teardownConsoleCapture(_sessionId);
|
|
102
|
-
_client.close();
|
|
103
|
-
}
|
|
104
|
-
const effectivePort = port === DEFAULT_PORT ? await resolveDefaultPort(browser) : port;
|
|
105
|
-
_port = await browser.launchBrowser({ port: effectivePort, headless: true });
|
|
106
|
-
const conn = await browser.connectToTarget(_port, targetId);
|
|
107
|
-
_client = conn.client;
|
|
108
|
-
_sessionId = conn.sessionId;
|
|
109
|
-
_targetId = conn.target.id;
|
|
110
|
-
_parentSessionId = '';
|
|
111
|
-
_refs = new Map();
|
|
112
|
-
await hydrateRefsFromCache(browser, _targetId, conn.target.url);
|
|
113
|
-
}
|
|
114
|
-
return { client: _client, sessionId: _sessionId, targetId: _targetId };
|
|
115
|
-
}
|
|
116
|
-
// Each CLI invocation is a fresh process, so the in-memory _refs Map built by
|
|
117
|
-
// a prior `snapshot` command is gone by the time a later `find`/`click`/etc.
|
|
118
|
-
// command runs. Rehydrate it from the on-disk ref cache (written by
|
|
119
|
-
// captureSnapshot call sites below) so refs resolved by a previous process
|
|
120
|
-
// remain usable. Falls back silently to an empty Map if no cache exists.
|
|
121
|
-
//
|
|
122
|
-
// The cached `url` field (captured at snapshot time) is compared against the
|
|
123
|
-
// browser's CURRENT url (`currentUrl`, from the just-fetched target info).
|
|
124
|
-
// A stale `backendDOMNodeId` can still successfully resolve via
|
|
125
|
-
// DOM.getBoxModel even after a same-tab SPA navigation or DOM mutation
|
|
126
|
-
// changed what's actually at those coordinates — so a mismatch here means
|
|
127
|
-
// EVERY ref in the cache is potentially pointing at the wrong element. We
|
|
128
|
-
// hard-invalidate (skip hydration entirely, so any @eN lookup fails loudly
|
|
129
|
-
// via resolveRef's "not found" error) rather than the weaker 30s time-based
|
|
130
|
-
// staleness check below, which only warns.
|
|
131
|
-
async function hydrateRefsFromCache(browser, targetId, currentUrl) {
|
|
132
|
-
const cached = await browser.loadRefCache(targetId);
|
|
133
|
-
if (!cached)
|
|
134
|
-
return;
|
|
135
|
-
if (currentUrl && cached.url && currentUrl !== cached.url) {
|
|
136
|
-
output.printError(`Stale references — page has navigated (cache: ${cached.url} → current: ${currentUrl}). Re-run snapshot before using @eN refs.`);
|
|
137
|
-
return; // leave _refs empty — do not attempt to resolve refs against a different page
|
|
138
|
-
}
|
|
139
|
-
_refs = cached.refs;
|
|
140
|
-
if (cached.stale) {
|
|
141
|
-
output.printWarning(`AX ref cache is ${Math.round(cached.ageMs / 1000)}s old — page may have changed since the last snapshot; re-run snapshot if refs don't resolve as expected`);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
// URL patterns that signal an unambiguous login/auth wall.
|
|
145
|
-
// Excludes 'auth', 'oauth', 'sso', 'saml' — their callback/ACS/token paths
|
|
146
|
-
// (/auth/callback, /sso/callback, /saml/acs) are completion endpoints, not walls.
|
|
147
|
-
// DOM detection (password field, CAPTCHA widgets) handles SSO/SAML login pages.
|
|
148
|
-
const ATTENTION_URL_RE = /\/(login|log-in|signin|sign-in|captcha|mfa|2fa|account\/login|accounts\/login|session\/new|users\/sign_in)(?:[/?#]|$)/i;
|
|
149
|
-
async function detectAttentionNeeded(client, sessionId, url) {
|
|
150
|
-
if (ATTENTION_URL_RE.test(url))
|
|
151
|
-
return 'login';
|
|
152
|
-
try {
|
|
153
|
-
const hasPassword = await client
|
|
154
|
-
.send('Runtime.evaluate', {
|
|
155
|
-
expression: '!!document.querySelector("input[type=password]")',
|
|
156
|
-
returnByValue: true,
|
|
157
|
-
}, sessionId)
|
|
158
|
-
.then((r) => r.result?.value === true)
|
|
159
|
-
.catch(() => false);
|
|
160
|
-
if (hasPassword)
|
|
161
|
-
return 'login';
|
|
162
|
-
const hasCaptcha = await client
|
|
163
|
-
.send('Runtime.evaluate', {
|
|
164
|
-
expression: '!!(document.querySelector("iframe[src*=recaptcha]") || document.querySelector("iframe[src*=hcaptcha]") || document.querySelector(".g-recaptcha") || document.querySelector(".h-captcha") || document.querySelector("[data-sitekey]"))',
|
|
165
|
-
returnByValue: true,
|
|
166
|
-
}, sessionId)
|
|
167
|
-
.then((r) => r.result?.value === true)
|
|
168
|
-
.catch(() => false);
|
|
169
|
-
if (hasCaptcha)
|
|
170
|
-
return 'captcha';
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
/* ignore CDP errors */
|
|
174
|
-
}
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
function waitForEnter() {
|
|
178
|
-
if (!process.stdin.isTTY) {
|
|
179
|
-
// Non-interactive context (MCP server, piped input, CI) — cannot safely read stdin.
|
|
180
|
-
// Auto-continue after a short grace period so automation isn't blocked.
|
|
181
|
-
output.printWarning('Non-interactive mode: auto-continuing in 30s. Switch to headed manually if needed.');
|
|
182
|
-
return new Promise((resolve) => setTimeout(resolve, 30_000));
|
|
183
|
-
}
|
|
184
|
-
return new Promise((resolve) => {
|
|
185
|
-
process.stdin.resume();
|
|
186
|
-
process.stdin.setEncoding('utf8');
|
|
187
|
-
const onData = () => {
|
|
188
|
-
process.stdin.pause();
|
|
189
|
-
process.stdin.off('data', onData);
|
|
190
|
-
resolve();
|
|
191
|
-
};
|
|
192
|
-
process.stdin.once('data', onData);
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
async function switchToHeaded(url, port) {
|
|
196
|
-
const browser = await getBrowser();
|
|
197
|
-
// Snapshot cookies from current headless session before closing
|
|
198
|
-
let savedCookies = [];
|
|
199
|
-
if (_client && _sessionId) {
|
|
200
|
-
try {
|
|
201
|
-
savedCookies = await browser.getCookies(_client, _sessionId);
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
/* ignore */
|
|
205
|
-
}
|
|
206
|
-
browser.teardownRouteInterception(_sessionId);
|
|
207
|
-
browser.stopRequestCapture(_sessionId);
|
|
208
|
-
browser.teardownDialogHandling(_sessionId);
|
|
209
|
-
browser.teardownConsoleCapture(_sessionId);
|
|
210
|
-
_client.close();
|
|
211
|
-
_client = null;
|
|
212
|
-
_sessionId = '';
|
|
213
|
-
_parentSessionId = '';
|
|
214
|
-
_targetId = '';
|
|
215
|
-
}
|
|
216
|
-
// Launch headed on a neighbouring port to avoid collision
|
|
217
|
-
const headedPort = port + 10;
|
|
218
|
-
try {
|
|
219
|
-
await browser.launchBrowser({ port: headedPort, headless: false });
|
|
220
|
-
}
|
|
221
|
-
catch (err) {
|
|
222
|
-
// Headed launch failed (no display, locked down env). Restore headless and rethrow.
|
|
223
|
-
_port = await browser.launchBrowser({ port, headless: true });
|
|
224
|
-
const fallback = await browser.connectToTarget(_port);
|
|
225
|
-
_client = fallback.client;
|
|
226
|
-
_sessionId = fallback.sessionId;
|
|
227
|
-
_targetId = fallback.target.id;
|
|
228
|
-
_refs = new Map();
|
|
229
|
-
throw new Error(`Cannot open headed browser: ${err instanceof Error ? err.message : String(err)}`);
|
|
230
|
-
}
|
|
231
|
-
const conn = await browser.connectToTarget(headedPort);
|
|
232
|
-
_client = conn.client;
|
|
233
|
-
_sessionId = conn.sessionId;
|
|
234
|
-
_targetId = conn.target.id;
|
|
235
|
-
_refs = new Map();
|
|
236
|
-
if (savedCookies.length) {
|
|
237
|
-
try {
|
|
238
|
-
await browser.setCookies(_client, _sessionId, savedCookies);
|
|
239
|
-
}
|
|
240
|
-
catch {
|
|
241
|
-
/* ignore */
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
await browser.openUrl(_client, _sessionId, url);
|
|
245
|
-
output.printInfo('Browser window opened. Complete the required action (login / CAPTCHA), then press Enter to continue in headless mode...');
|
|
246
|
-
await waitForEnter();
|
|
247
|
-
// Capture post-auth cookies
|
|
248
|
-
const authCookies = await browser.getCookies(_client, _sessionId).catch(() => []);
|
|
249
|
-
const authLocalStorage = await browser
|
|
250
|
-
.getLocalStorage(_client, _sessionId)
|
|
251
|
-
.catch(() => ({}));
|
|
252
|
-
// Close headed session — actually terminate the underlying Chrome process
|
|
253
|
-
// (Browser.close CDP command, PID-kill fallback), not just our CDP client
|
|
254
|
-
// connection, so the visible authenticated window doesn't linger forever.
|
|
255
|
-
browser.teardownRouteInterception(_sessionId);
|
|
256
|
-
browser.stopRequestCapture(_sessionId);
|
|
257
|
-
browser.teardownDialogHandling(_sessionId);
|
|
258
|
-
browser.teardownConsoleCapture(_sessionId);
|
|
259
|
-
await browser.closeBrowser(_client, headedPort);
|
|
260
|
-
_client.close();
|
|
261
|
-
_client = null;
|
|
262
|
-
_sessionId = '';
|
|
263
|
-
_parentSessionId = '';
|
|
264
|
-
_targetId = '';
|
|
265
|
-
// Relaunch headless and restore session
|
|
266
|
-
_port = await browser.launchBrowser({ port, headless: true });
|
|
267
|
-
const headlessConn = await browser.connectToTarget(_port);
|
|
268
|
-
_client = headlessConn.client;
|
|
269
|
-
_sessionId = headlessConn.sessionId;
|
|
270
|
-
_targetId = headlessConn.target.id;
|
|
271
|
-
_refs = new Map();
|
|
272
|
-
if (authCookies.length) {
|
|
273
|
-
try {
|
|
274
|
-
await browser.setCookies(_client, _sessionId, authCookies);
|
|
275
|
-
}
|
|
276
|
-
catch {
|
|
277
|
-
/* ignore */
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
if (authLocalStorage && Object.keys(authLocalStorage).length) {
|
|
281
|
-
try {
|
|
282
|
-
await browser.setLocalStorage(_client, _sessionId, authLocalStorage);
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
/* ignore */
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* Sanitize an image-format flag. The host CLI (monomind) defines a GLOBAL
|
|
291
|
-
* --format flag for output shaping (text|json|table, default 'text') that
|
|
292
|
-
* leaks into subcommand flags — so ctx.flags.format arrives as 'text' even
|
|
293
|
-
* when the user never passed it, and Chrome rejects it with
|
|
294
|
-
* "CDP error -32602: Invalid image format". Only trust values that are real
|
|
295
|
-
* image formats; otherwise fall back to the subcommand's own default.
|
|
296
|
-
*/
|
|
297
|
-
function imageFormat(value, allowed, fallback) {
|
|
298
|
-
return allowed.includes(value) ? value : fallback;
|
|
299
|
-
}
|
|
300
|
-
function print(msg) {
|
|
301
|
-
process.stdout.write(`${msg}\n`);
|
|
302
|
-
}
|
|
303
|
-
// ---------------------------------------------------------------------------
|
|
304
|
-
// Sub-commands
|
|
305
|
-
// ---------------------------------------------------------------------------
|
|
306
|
-
const openCommand = {
|
|
307
|
-
name: 'open',
|
|
308
|
-
description: 'Open a URL in the browser. Usage: monomind browse open <url>',
|
|
309
|
-
options: [
|
|
310
|
-
{ name: 'port', short: 'p', type: 'number', description: 'CDP port', default: 9222 },
|
|
311
|
-
{
|
|
312
|
-
name: 'headed',
|
|
313
|
-
type: 'boolean',
|
|
314
|
-
description: 'Force visible browser window',
|
|
315
|
-
default: false,
|
|
316
|
-
},
|
|
317
|
-
{ name: 'session', short: 's', type: 'string', description: 'Session name to restore' },
|
|
318
|
-
{ name: 'state', type: 'string', description: 'State file to load' },
|
|
319
|
-
],
|
|
320
|
-
action: async (ctx) => {
|
|
321
|
-
const url = ctx.args[0];
|
|
322
|
-
if (!url)
|
|
323
|
-
throw new Error('URL required. Usage: monomind browse open <url>');
|
|
324
|
-
const port = ctx.flags.port ?? 9222;
|
|
325
|
-
const forceHeaded = ctx.flags.headed;
|
|
326
|
-
const browser = await getBrowser();
|
|
327
|
-
if (_client) {
|
|
328
|
-
const prevSid = _sessionId;
|
|
329
|
-
const prevClient = _client;
|
|
330
|
-
if (browser.getHarStatus(prevSid).recording) {
|
|
331
|
-
try {
|
|
332
|
-
await browser.stopHarRecording(prevClient, prevSid);
|
|
333
|
-
}
|
|
334
|
-
catch {
|
|
335
|
-
/* ignore */
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
if (browser.getTraceStatus(prevSid)) {
|
|
339
|
-
try {
|
|
340
|
-
await browser.stopTrace(prevClient, prevSid);
|
|
341
|
-
}
|
|
342
|
-
catch {
|
|
343
|
-
/* ignore */
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
if (browser.isProfilingActive(prevSid)) {
|
|
347
|
-
try {
|
|
348
|
-
await browser.stopCpuProfile(prevClient, prevSid);
|
|
349
|
-
}
|
|
350
|
-
catch {
|
|
351
|
-
/* ignore */
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
browser.teardownRouteInterception(prevSid);
|
|
355
|
-
browser.stopRequestCapture(prevSid);
|
|
356
|
-
browser.teardownDialogHandling(prevSid);
|
|
357
|
-
browser.teardownConsoleCapture(prevSid);
|
|
358
|
-
prevClient.close();
|
|
359
|
-
_client = null;
|
|
360
|
-
_sessionId = '';
|
|
361
|
-
_parentSessionId = '';
|
|
362
|
-
_targetId = '';
|
|
363
|
-
_refs = new Map();
|
|
364
|
-
}
|
|
365
|
-
_port = await browser.launchBrowser({ port, headless: !forceHeaded });
|
|
366
|
-
// Persist the active port so subsequent CLI invocations (each a fresh
|
|
367
|
-
// process) default to attaching here instead of hardcoded 9222. Also
|
|
368
|
-
// persist the launched PID/userDataDir so a later process's closeBrowser
|
|
369
|
-
// can still kill this Chrome even though launchedPids (browser.ts) is
|
|
370
|
-
// per-process and empty there.
|
|
371
|
-
//
|
|
372
|
-
// launchBrowser() can either LAUNCH a fresh Chrome or ATTACH to one
|
|
373
|
-
// already listening on the requested port (see its own "attach if
|
|
374
|
-
// already Chrome" comment) — it returns only a port number, with no
|
|
375
|
-
// signal telling this caller which happened. getLaunchedPid(_port) is
|
|
376
|
-
// undefined on the attach path (this process never spawned anything).
|
|
377
|
-
// Bug fixed here: unconditionally saving {pid: undefined, ...} on
|
|
378
|
-
// attach used to CLOBBER a real PID a previous `open` had already
|
|
379
|
-
// persisted for this exact port, destroying the only way a later
|
|
380
|
-
// process's closeBrowser() PID-kill fallback could ever find it.
|
|
381
|
-
const freshPid = browser.getLaunchedPid(_port);
|
|
382
|
-
const freshUserDataDir = browser.getLaunchedUserDataDir(_port);
|
|
383
|
-
if (freshPid !== undefined) {
|
|
384
|
-
await browser.saveActivePort(_port, { pid: freshPid, userDataDir: freshUserDataDir });
|
|
385
|
-
}
|
|
386
|
-
else {
|
|
387
|
-
// Attach path: preserve whatever PID/userDataDir was already on file
|
|
388
|
-
// for this port (most likely from the process that originally
|
|
389
|
-
// launched it) instead of overwriting with undefined.
|
|
390
|
-
const existing = await browser.loadActivePortInfo();
|
|
391
|
-
const preserved = existing && existing.port === _port ? existing : undefined;
|
|
392
|
-
await browser.saveActivePort(_port, {
|
|
393
|
-
pid: preserved?.pid,
|
|
394
|
-
userDataDir: preserved?.userDataDir,
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
ensureSignalCleanupHandlers();
|
|
398
|
-
const conn = await browser.connectToTarget(_port);
|
|
399
|
-
_client = conn.client;
|
|
400
|
-
_sessionId = conn.sessionId;
|
|
401
|
-
_targetId = conn.target.id;
|
|
402
|
-
_refs = new Map();
|
|
403
|
-
// A snapshot taken before this navigation is no longer valid for the new
|
|
404
|
-
// page — drop the persisted ref cache so a stale process's weak
|
|
405
|
-
// time-based check can't resurrect it before the next explicit snapshot.
|
|
406
|
-
await browser.clearRefCache();
|
|
407
|
-
if (ctx.flags.state && ctx.flags.session) {
|
|
408
|
-
output.printWarning('Both --state and --session provided; --state takes precedence');
|
|
409
|
-
}
|
|
410
|
-
if (ctx.flags.state) {
|
|
411
|
-
await browser.loadStateFile(_client, _sessionId, ctx.flags.state);
|
|
412
|
-
}
|
|
413
|
-
else if (ctx.flags.session) {
|
|
414
|
-
await browser.loadSession(_client, _sessionId, ctx.flags.session);
|
|
415
|
-
}
|
|
416
|
-
await browser.openUrl(_client, _sessionId, url);
|
|
417
|
-
const currentUrl = await browser.getCurrentUrl(_client, _sessionId);
|
|
418
|
-
// Auto-detect login/CAPTCHA walls and switch to headed if needed
|
|
419
|
-
if (!forceHeaded) {
|
|
420
|
-
const attentionType = await detectAttentionNeeded(_client, _sessionId, currentUrl);
|
|
421
|
-
if (attentionType) {
|
|
422
|
-
output.printWarning(`${attentionType === 'captcha' ? 'CAPTCHA' : 'Login'} detected — switching to headed mode`);
|
|
423
|
-
await switchToHeaded(currentUrl, port);
|
|
424
|
-
await browser.openUrl(_client, _sessionId, currentUrl);
|
|
425
|
-
output.printSuccess(`Resumed headless after ${attentionType === 'captcha' ? 'CAPTCHA' : 'login'}`);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
const finalUrl = await browser.getCurrentUrl(_client, _sessionId);
|
|
429
|
-
const title = await browser.getCurrentTitle(_client, _sessionId);
|
|
430
|
-
output.printSuccess(`Opened: ${title} (${finalUrl})`);
|
|
431
|
-
return { success: true, data: { url: finalUrl, title } };
|
|
432
|
-
},
|
|
433
|
-
};
|
|
434
|
-
const snapshotCommand = {
|
|
435
|
-
name: 'snapshot',
|
|
436
|
-
description: 'Capture accessibility snapshot with ref-based element handles (@e1, @e2, ...)',
|
|
437
|
-
options: [
|
|
438
|
-
{
|
|
439
|
-
name: 'interactive',
|
|
440
|
-
short: 'i',
|
|
441
|
-
type: 'boolean',
|
|
442
|
-
description: 'Interactive elements only (93% token reduction)',
|
|
443
|
-
default: false,
|
|
444
|
-
},
|
|
445
|
-
{
|
|
446
|
-
name: 'compact',
|
|
447
|
-
short: 'c',
|
|
448
|
-
type: 'boolean',
|
|
449
|
-
description: 'Compact output format',
|
|
450
|
-
default: false,
|
|
451
|
-
},
|
|
452
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
453
|
-
{ name: 'depth', short: 'd', type: 'number', description: 'Max depth of AX tree to show' },
|
|
454
|
-
{
|
|
455
|
-
name: 'selector',
|
|
456
|
-
short: 's',
|
|
457
|
-
type: 'string',
|
|
458
|
-
description: 'Scope snapshot to a CSS selector',
|
|
459
|
-
},
|
|
460
|
-
{
|
|
461
|
-
name: 'save',
|
|
462
|
-
type: 'string',
|
|
463
|
-
description: 'Save snapshot text to file (baseline for --diff)',
|
|
464
|
-
},
|
|
465
|
-
{
|
|
466
|
-
name: 'diff',
|
|
467
|
-
type: 'string',
|
|
468
|
-
description: 'Compare current snapshot against a saved baseline file',
|
|
469
|
-
},
|
|
470
|
-
{
|
|
471
|
-
name: 'content-boundaries',
|
|
472
|
-
type: 'boolean',
|
|
473
|
-
description: 'Wrap output in sentinel markers to prevent page-content injection attacks',
|
|
474
|
-
default: false,
|
|
475
|
-
},
|
|
476
|
-
{
|
|
477
|
-
name: 'max-output',
|
|
478
|
-
type: 'number',
|
|
479
|
-
description: 'Truncate output to N characters (prevents context window blowout on large pages)',
|
|
480
|
-
},
|
|
481
|
-
],
|
|
482
|
-
action: async (ctx) => {
|
|
483
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
484
|
-
const browser = await getBrowser();
|
|
485
|
-
const result = await browser.captureSnapshot(client, sessionId, {
|
|
486
|
-
interactiveOnly: ctx.flags.interactive,
|
|
487
|
-
compact: ctx.flags.compact,
|
|
488
|
-
maxDepth: ctx.flags.depth,
|
|
489
|
-
selector: ctx.flags.selector,
|
|
490
|
-
});
|
|
491
|
-
_refs = result.refs;
|
|
492
|
-
await browser.saveRefCache(_targetId, result.url, _refs);
|
|
493
|
-
const applyOutputLimits = (text) => {
|
|
494
|
-
const maxOutput = ctx.flags['max-output'];
|
|
495
|
-
let out = maxOutput ? truncateForOutput(text, maxOutput) : text;
|
|
496
|
-
if (ctx.flags['content-boundaries']) {
|
|
497
|
-
const nonce = Math.random().toString(36).slice(2, 10);
|
|
498
|
-
out = `MONOMIND_PAGE_CONTENT nonce=${nonce} origin=${result.url}\n${out}\nEND_MONOMIND_PAGE_CONTENT nonce=${nonce}`;
|
|
499
|
-
}
|
|
500
|
-
return out;
|
|
501
|
-
};
|
|
502
|
-
// --save: write snapshot text to baseline file
|
|
503
|
-
if (ctx.flags.save) {
|
|
504
|
-
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
505
|
-
const { dirname } = await import('node:path');
|
|
506
|
-
const savePath = ctx.flags.save;
|
|
507
|
-
await mkdir(dirname(savePath), { recursive: true }).catch(() => { });
|
|
508
|
-
await writeFile(savePath, result.text, 'utf8');
|
|
509
|
-
output.printSuccess(`Snapshot saved to: ${savePath}`);
|
|
510
|
-
return { success: true, data: { path: savePath } };
|
|
511
|
-
}
|
|
512
|
-
// --diff: compare against baseline file
|
|
513
|
-
if (ctx.flags.diff) {
|
|
514
|
-
const { readFile } = await import('node:fs/promises');
|
|
515
|
-
const baselinePath = ctx.flags.diff;
|
|
516
|
-
let baseline;
|
|
517
|
-
try {
|
|
518
|
-
baseline = await readFile(baselinePath, 'utf8');
|
|
519
|
-
}
|
|
520
|
-
catch {
|
|
521
|
-
throw new Error(`Baseline not found: ${baselinePath}. Run snapshot --save first.`);
|
|
522
|
-
}
|
|
523
|
-
const currentLines = result.text.split('\n');
|
|
524
|
-
const baselineLines = baseline.split('\n');
|
|
525
|
-
const added = [], removed = [];
|
|
526
|
-
const baseSet = new Set(baselineLines);
|
|
527
|
-
const curSet = new Set(currentLines);
|
|
528
|
-
for (const l of currentLines)
|
|
529
|
-
if (!baseSet.has(l))
|
|
530
|
-
added.push(l);
|
|
531
|
-
for (const l of baselineLines)
|
|
532
|
-
if (!curSet.has(l))
|
|
533
|
-
removed.push(l);
|
|
534
|
-
const changed = added.length > 0 || removed.length > 0;
|
|
535
|
-
if (ctx.flags.json) {
|
|
536
|
-
print(JSON.stringify({
|
|
537
|
-
changed,
|
|
538
|
-
additions: added.length,
|
|
539
|
-
removals: removed.length,
|
|
540
|
-
added,
|
|
541
|
-
removed,
|
|
542
|
-
}));
|
|
543
|
-
}
|
|
544
|
-
else {
|
|
545
|
-
if (!changed) {
|
|
546
|
-
output.printSuccess('No snapshot changes detected');
|
|
547
|
-
}
|
|
548
|
-
else {
|
|
549
|
-
output.printWarning(`Snapshot changed: +${added.length} lines, -${removed.length} lines`);
|
|
550
|
-
for (const l of added)
|
|
551
|
-
print(`\x1b[32m+ ${l}\x1b[0m`);
|
|
552
|
-
for (const l of removed)
|
|
553
|
-
print(`\x1b[31m- ${l}\x1b[0m`);
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
return {
|
|
557
|
-
success: true,
|
|
558
|
-
data: { changed, additions: added.length, removals: removed.length },
|
|
559
|
-
};
|
|
560
|
-
}
|
|
561
|
-
if (ctx.flags.json) {
|
|
562
|
-
const refsObj = Object.fromEntries([...result.refs.entries()].map(([k, v]) => [k, v]));
|
|
563
|
-
print(JSON.stringify({
|
|
564
|
-
url: result.url,
|
|
565
|
-
title: result.title,
|
|
566
|
-
refs: refsObj,
|
|
567
|
-
snapshot: result.text,
|
|
568
|
-
}));
|
|
569
|
-
}
|
|
570
|
-
else {
|
|
571
|
-
print(`[${result.title}] ${result.url}\n`);
|
|
572
|
-
print(applyOutputLimits(result.text));
|
|
573
|
-
}
|
|
574
|
-
return { success: true, data: result };
|
|
575
|
-
},
|
|
576
|
-
};
|
|
577
|
-
const clickCommand = {
|
|
578
|
-
name: 'click',
|
|
579
|
-
description: 'Click an element by ref (@e1) or coordinates. Usage: monomind browse click @e1',
|
|
580
|
-
options: [
|
|
581
|
-
{ name: 'right', type: 'boolean', description: 'Right-click', default: false },
|
|
582
|
-
{ name: 'double', type: 'boolean', description: 'Double-click', default: false },
|
|
583
|
-
{ name: 'x', type: 'number', description: 'X coordinate (for point click)' },
|
|
584
|
-
{ name: 'y', type: 'number', description: 'Y coordinate (for point click)' },
|
|
585
|
-
],
|
|
586
|
-
action: async (ctx) => {
|
|
587
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
588
|
-
const browser = await getBrowser();
|
|
589
|
-
const refArg = ctx.args[0];
|
|
590
|
-
if (!refArg && ctx.flags.x === undefined)
|
|
591
|
-
throw new Error('Ref (@e1) or --x/--y required');
|
|
592
|
-
if (ctx.flags.x !== undefined && ctx.flags.y !== undefined) {
|
|
593
|
-
await browser.clickPoint(client, sessionId, ctx.flags.x, ctx.flags.y);
|
|
594
|
-
output.printSuccess(`Clicked at (${ctx.flags.x}, ${ctx.flags.y})`);
|
|
595
|
-
return { success: true };
|
|
596
|
-
}
|
|
597
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
598
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
599
|
-
await browser.clickElement(client, sessionId, ref, {
|
|
600
|
-
button: ctx.flags.right ? 'right' : 'left',
|
|
601
|
-
clickCount: ctx.flags.double ? 2 : 1,
|
|
602
|
-
});
|
|
603
|
-
output.printSuccess(`Clicked: ${ref.role} "${ref.name}"`);
|
|
604
|
-
return { success: true };
|
|
605
|
-
},
|
|
606
|
-
};
|
|
607
|
-
const fillCommand = {
|
|
608
|
-
name: 'fill',
|
|
609
|
-
description: 'Fill an input element. Usage: monomind browse fill @e1 "text value"',
|
|
610
|
-
action: async (ctx) => {
|
|
611
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
612
|
-
const browser = await getBrowser();
|
|
613
|
-
const refArg = ctx.args[0];
|
|
614
|
-
const value = ctx.args[1];
|
|
615
|
-
if (!refArg || value === undefined)
|
|
616
|
-
throw new Error('Usage: monomind browse fill @e1 "value"');
|
|
617
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
618
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
619
|
-
await browser.fillElement(client, sessionId, ref, value);
|
|
620
|
-
output.printSuccess(`Filled: ${ref.role} "${ref.name}"`);
|
|
621
|
-
return { success: true };
|
|
622
|
-
},
|
|
623
|
-
};
|
|
624
|
-
const pressCommand = {
|
|
625
|
-
name: 'press',
|
|
626
|
-
description: 'Press a keyboard key. Usage: monomind browse press Enter',
|
|
627
|
-
action: async (ctx) => {
|
|
628
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
629
|
-
const browser = await getBrowser();
|
|
630
|
-
const key = ctx.args[0];
|
|
631
|
-
if (!key)
|
|
632
|
-
throw new Error('Key required. E.g.: monomind browse press Enter');
|
|
633
|
-
await browser.pressKey(client, sessionId, key);
|
|
634
|
-
output.printSuccess(`Pressed: ${key}`);
|
|
635
|
-
return { success: true };
|
|
636
|
-
},
|
|
637
|
-
};
|
|
638
|
-
const waitCommand = {
|
|
639
|
-
name: 'wait',
|
|
640
|
-
description: 'Wait for a condition to be met before proceeding',
|
|
641
|
-
options: [
|
|
642
|
-
{ name: 'url', type: 'string', description: 'Wait for URL matching glob pattern' },
|
|
643
|
-
{ name: 'text', type: 'string', description: 'Wait for text to appear in page' },
|
|
644
|
-
{ name: 'not-text', type: 'string', description: 'Wait for text to disappear from page' },
|
|
645
|
-
{ name: 'selector', type: 'string', description: 'Wait for CSS selector to appear' },
|
|
646
|
-
{
|
|
647
|
-
name: 'load',
|
|
648
|
-
type: 'string',
|
|
649
|
-
description: 'Wait for load event: load|networkidle|domcontentloaded',
|
|
650
|
-
},
|
|
651
|
-
{ name: 'fn', type: 'string', description: 'Wait until JS expression returns truthy' },
|
|
652
|
-
{ name: 'ms', type: 'number', description: 'Wait N milliseconds' },
|
|
653
|
-
{ name: 'timeout', short: 't', type: 'number', description: 'Timeout in ms', default: 30000 },
|
|
654
|
-
{
|
|
655
|
-
name: 'download',
|
|
656
|
-
type: 'string',
|
|
657
|
-
description: 'Wait for a file download to complete and save to path (monitors Browser.downloadProgress events)',
|
|
658
|
-
},
|
|
659
|
-
],
|
|
660
|
-
action: async (ctx) => {
|
|
661
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
662
|
-
const browser = await getBrowser();
|
|
663
|
-
if (ctx.flags.ms) {
|
|
664
|
-
const rawMs = ctx.flags.ms;
|
|
665
|
-
const waitMs = Number.isFinite(rawMs) ? Math.max(0, Math.min(rawMs, 60_000)) : 0; // cap at 60s
|
|
666
|
-
await new Promise((r) => setTimeout(r, waitMs));
|
|
667
|
-
output.printSuccess(`Waited ${ctx.flags.ms}ms`);
|
|
668
|
-
return { success: true };
|
|
669
|
-
}
|
|
670
|
-
if (ctx.flags.fn) {
|
|
671
|
-
const expr = ctx.flags.fn;
|
|
672
|
-
const rawTimeout = ctx.flags.timeout ?? 30000;
|
|
673
|
-
const timeout = Number.isFinite(rawTimeout)
|
|
674
|
-
? Math.max(100, Math.min(rawTimeout, 300_000))
|
|
675
|
-
: 30000; // cap at 5min
|
|
676
|
-
const interval = 200;
|
|
677
|
-
const deadline = Date.now() + timeout;
|
|
678
|
-
while (Date.now() < deadline) {
|
|
679
|
-
const result = await browser.evaluateJs(client, sessionId, expr);
|
|
680
|
-
if (result) {
|
|
681
|
-
output.printSuccess('Wait function returned truthy');
|
|
682
|
-
return { success: true };
|
|
683
|
-
}
|
|
684
|
-
await new Promise((r) => setTimeout(r, interval));
|
|
685
|
-
}
|
|
686
|
-
throw new Error(`Timeout waiting for --fn: ${expr}`);
|
|
687
|
-
}
|
|
688
|
-
if (ctx.flags['not-text']) {
|
|
689
|
-
const target = ctx.flags['not-text'];
|
|
690
|
-
const timeout = ctx.flags.timeout ?? 30000;
|
|
691
|
-
const interval = 200;
|
|
692
|
-
const deadline = Date.now() + timeout;
|
|
693
|
-
while (Date.now() < deadline) {
|
|
694
|
-
const text = (await browser.evaluateJs(client, sessionId, 'document.body?.innerText ?? ""'));
|
|
695
|
-
if (!text.includes(target)) {
|
|
696
|
-
output.printSuccess('Text disappeared');
|
|
697
|
-
return { success: true };
|
|
698
|
-
}
|
|
699
|
-
await new Promise((r) => setTimeout(r, interval));
|
|
700
|
-
}
|
|
701
|
-
throw new Error(`Timeout waiting for text to disappear: "${target}"`);
|
|
702
|
-
}
|
|
703
|
-
if (ctx.flags.download) {
|
|
704
|
-
const savePath = ctx.flags.download;
|
|
705
|
-
const { mkdir } = await import('node:fs/promises');
|
|
706
|
-
const { dirname, join } = await import('node:path');
|
|
707
|
-
const { tmpdir } = await import('node:os');
|
|
708
|
-
const downloadDir = join(tmpdir(), `mm-dl-wait-${Date.now()}`);
|
|
709
|
-
await mkdir(downloadDir, { recursive: true });
|
|
710
|
-
await client
|
|
711
|
-
.send('Browser.setDownloadBehavior', {
|
|
712
|
-
behavior: 'allow',
|
|
713
|
-
downloadPath: downloadDir,
|
|
714
|
-
eventsEnabled: true,
|
|
715
|
-
}, undefined)
|
|
716
|
-
.catch(() => client
|
|
717
|
-
.send('Page.setDownloadBehavior', { behavior: 'allow', downloadPath: downloadDir }, sessionId)
|
|
718
|
-
.catch(() => { }));
|
|
719
|
-
const MAX_DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // I6: cap at 5 minutes
|
|
720
|
-
const rawTimeout = Math.min(ctx.flags.timeout ?? 30000, MAX_DOWNLOAD_TIMEOUT);
|
|
721
|
-
const finalPath = await new Promise((resolve, reject) => {
|
|
722
|
-
let guid = '';
|
|
723
|
-
let settled = false;
|
|
724
|
-
// C2: capture off() functions to avoid listener leaks
|
|
725
|
-
const offBegin = client.on('Browser.downloadWillBegin', (params) => {
|
|
726
|
-
guid = params.guid;
|
|
727
|
-
});
|
|
728
|
-
let offProgress;
|
|
729
|
-
// cleanup defined before setTimeout so the timeout callback can call it
|
|
730
|
-
let tid;
|
|
731
|
-
let pollTid;
|
|
732
|
-
const cleanup = () => {
|
|
733
|
-
clearTimeout(tid);
|
|
734
|
-
clearInterval(pollTid);
|
|
735
|
-
offBegin?.();
|
|
736
|
-
offProgress?.();
|
|
737
|
-
};
|
|
738
|
-
const finish = (path) => {
|
|
739
|
-
if (settled)
|
|
740
|
-
return;
|
|
741
|
-
settled = true;
|
|
742
|
-
cleanup();
|
|
743
|
-
resolve(path);
|
|
744
|
-
};
|
|
745
|
-
const fail = (err) => {
|
|
746
|
-
if (settled)
|
|
747
|
-
return;
|
|
748
|
-
settled = true;
|
|
749
|
-
cleanup();
|
|
750
|
-
reject(err);
|
|
751
|
-
};
|
|
752
|
-
tid = setTimeout(() => fail(new Error('Download timed out')), rawTimeout);
|
|
753
|
-
offProgress = client.on('Browser.downloadProgress', async (params) => {
|
|
754
|
-
if (params.guid === guid && params.state === 'completed') {
|
|
755
|
-
const { readdir, rename, rmdir } = await import('node:fs/promises');
|
|
756
|
-
const files = await readdir(downloadDir);
|
|
757
|
-
if (files.length > 0) {
|
|
758
|
-
const src = join(downloadDir, files[0]);
|
|
759
|
-
await mkdir(dirname(savePath), { recursive: true });
|
|
760
|
-
await rename(src, savePath);
|
|
761
|
-
await rmdir(downloadDir).catch(() => { }); // I1: cleanup temp dir
|
|
762
|
-
finish(savePath);
|
|
763
|
-
}
|
|
764
|
-
else {
|
|
765
|
-
await rmdir(downloadDir).catch(() => { }); // I1: cleanup temp dir
|
|
766
|
-
fail(new Error('Download completed but no file found'));
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
else if (params.guid === guid && params.state === 'canceled') {
|
|
770
|
-
fail(new Error('Download was canceled'));
|
|
771
|
-
}
|
|
772
|
-
});
|
|
773
|
-
// Fallback for the empty-guid race: this process's listener attaches
|
|
774
|
-
// AFTER a separate `click` process may have already started (and even
|
|
775
|
-
// finished) the download — so downloadWillBegin/downloadProgress for it
|
|
776
|
-
// were never observed here. While guid is still empty, poll the expected
|
|
777
|
-
// output path directly: if it already exists with a stable, non-zero
|
|
778
|
-
// size across two consecutive checks, treat the download as complete.
|
|
779
|
-
let lastSize = -1;
|
|
780
|
-
let stableReads = 0;
|
|
781
|
-
pollTid = setInterval(async () => {
|
|
782
|
-
if (settled || guid)
|
|
783
|
-
return; // a real CDP event has taken over
|
|
784
|
-
try {
|
|
785
|
-
const { stat } = await import('node:fs/promises');
|
|
786
|
-
const st = await stat(savePath);
|
|
787
|
-
if (st.isFile() && st.size > 0 && st.size === lastSize) {
|
|
788
|
-
stableReads++;
|
|
789
|
-
if (stableReads >= 2)
|
|
790
|
-
finish(savePath);
|
|
791
|
-
}
|
|
792
|
-
else {
|
|
793
|
-
stableReads = 0;
|
|
794
|
-
}
|
|
795
|
-
lastSize = st.size;
|
|
796
|
-
}
|
|
797
|
-
catch {
|
|
798
|
-
// savePath not present yet — keep polling until timeout
|
|
799
|
-
}
|
|
800
|
-
}, 300);
|
|
801
|
-
});
|
|
802
|
-
output.printSuccess(`Download saved: ${finalPath}`);
|
|
803
|
-
return { success: true, data: { path: finalPath } };
|
|
804
|
-
}
|
|
805
|
-
await browser.waitFor(client, sessionId, {
|
|
806
|
-
url: ctx.flags.url,
|
|
807
|
-
text: ctx.flags.text,
|
|
808
|
-
selector: ctx.flags.selector,
|
|
809
|
-
load: ctx.flags.load,
|
|
810
|
-
timeout: ctx.flags.timeout,
|
|
811
|
-
});
|
|
812
|
-
output.printSuccess('Wait condition met');
|
|
813
|
-
return { success: true };
|
|
814
|
-
},
|
|
815
|
-
};
|
|
816
|
-
const screenshotCommand = {
|
|
817
|
-
name: 'screenshot',
|
|
818
|
-
description: 'Capture a screenshot. Usage: monomind browse screenshot [path] [--annotate] [--hide-scrollbars]',
|
|
819
|
-
options: [
|
|
820
|
-
{ name: 'full', type: 'boolean', description: 'Full page screenshot', default: false },
|
|
821
|
-
{ name: 'format', type: 'string', description: 'Format: png|jpeg|webp', default: 'png' },
|
|
822
|
-
{ name: 'quality', type: 'number', description: 'Quality 0-100 for jpeg/webp', default: 80 },
|
|
823
|
-
{
|
|
824
|
-
name: 'annotate',
|
|
825
|
-
type: 'boolean',
|
|
826
|
-
description: 'Overlay numbered labels keyed to @eN refs from last snapshot (viewport-only; do not combine with --full)',
|
|
827
|
-
default: false,
|
|
828
|
-
},
|
|
829
|
-
{
|
|
830
|
-
name: 'hide-scrollbars',
|
|
831
|
-
type: 'boolean',
|
|
832
|
-
description: 'Hide native scrollbars via CSS injection before capture',
|
|
833
|
-
default: false,
|
|
834
|
-
},
|
|
835
|
-
{ name: 'json', type: 'boolean', description: 'Output JSON with path', default: false },
|
|
836
|
-
],
|
|
837
|
-
action: async (ctx) => {
|
|
838
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
839
|
-
const browser = await getBrowser();
|
|
840
|
-
const hideScrollbars = ctx.flags['hide-scrollbars'];
|
|
841
|
-
if (hideScrollbars) {
|
|
842
|
-
await client
|
|
843
|
-
.send('Runtime.evaluate', {
|
|
844
|
-
expression: `(function(){var s=document.getElementById('__mm_noscroll__');if(s)return;var el=document.createElement('style');el.id='__mm_noscroll__';el.textContent='*::-webkit-scrollbar{display:none!important}*{scrollbar-width:none!important;-ms-overflow-style:none!important}';document.head.appendChild(el);})()`,
|
|
845
|
-
returnByValue: false,
|
|
846
|
-
}, sessionId)
|
|
847
|
-
.catch(() => { });
|
|
848
|
-
}
|
|
849
|
-
const annotate = ctx.flags.annotate;
|
|
850
|
-
// eslint-disable-next-line prefer-const
|
|
851
|
-
let result;
|
|
852
|
-
try {
|
|
853
|
-
result = await browser.captureScreenshot(client, sessionId, {
|
|
854
|
-
path: ctx.args[0],
|
|
855
|
-
fullPage: ctx.flags.full,
|
|
856
|
-
format: imageFormat(ctx.flags.format, ['png', 'jpeg', 'webp'], 'png'),
|
|
857
|
-
quality: ctx.flags.quality,
|
|
858
|
-
annotate,
|
|
859
|
-
refs: annotate ? _refs : undefined,
|
|
860
|
-
});
|
|
861
|
-
}
|
|
862
|
-
finally {
|
|
863
|
-
if (hideScrollbars) {
|
|
864
|
-
await client
|
|
865
|
-
.send('Runtime.evaluate', {
|
|
866
|
-
expression: `(function(){var s=document.getElementById('__mm_noscroll__');if(s)s.remove();})()`,
|
|
867
|
-
returnByValue: false,
|
|
868
|
-
}, sessionId)
|
|
869
|
-
.catch(() => { });
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
if (ctx.flags.json) {
|
|
873
|
-
print(JSON.stringify({ data: { path: result.path } }));
|
|
874
|
-
}
|
|
875
|
-
else {
|
|
876
|
-
output.printSuccess(`Screenshot saved: ${result.path}`);
|
|
877
|
-
}
|
|
878
|
-
return { success: true, data: result };
|
|
879
|
-
},
|
|
880
|
-
};
|
|
881
|
-
const getCommand = {
|
|
882
|
-
name: 'get',
|
|
883
|
-
description: 'Get page info. Usage: monomind browse get url|title|text|html|value|attr|count|box|styles [@ref] [attrName]',
|
|
884
|
-
options: [{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false }],
|
|
885
|
-
action: async (ctx) => {
|
|
886
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
887
|
-
const browser = await getBrowser();
|
|
888
|
-
const what = ctx.args[0];
|
|
889
|
-
if (!what)
|
|
890
|
-
throw new Error('Usage: monomind browse get url|title|text|html|value|attr|count|box|styles');
|
|
891
|
-
let value;
|
|
892
|
-
switch (what) {
|
|
893
|
-
case 'url':
|
|
894
|
-
value = await browser.getCurrentUrl(client, sessionId);
|
|
895
|
-
break;
|
|
896
|
-
case 'title':
|
|
897
|
-
value = await browser.getCurrentTitle(client, sessionId);
|
|
898
|
-
break;
|
|
899
|
-
case 'text': {
|
|
900
|
-
const refArg = ctx.args[1];
|
|
901
|
-
if (refArg) {
|
|
902
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
903
|
-
const ref = _refs.get(refKey);
|
|
904
|
-
if (!ref)
|
|
905
|
-
throw new Error(`Ref @${refKey} not found`);
|
|
906
|
-
const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
|
|
907
|
-
if (!objectId)
|
|
908
|
-
throw new Error('Element not in DOM');
|
|
909
|
-
const result = await client.send('Runtime.callFunctionOn', {
|
|
910
|
-
functionDeclaration: 'function() { return this.innerText || this.textContent || ""; }',
|
|
911
|
-
objectId,
|
|
912
|
-
returnByValue: true,
|
|
913
|
-
}, sessionId);
|
|
914
|
-
value = result.result?.value ?? '';
|
|
915
|
-
}
|
|
916
|
-
else {
|
|
917
|
-
value = (await browser.evaluateJs(client, sessionId, 'document.body?.innerText ?? ""'));
|
|
918
|
-
}
|
|
919
|
-
break;
|
|
920
|
-
}
|
|
921
|
-
case 'html':
|
|
922
|
-
value = (await browser.evaluateJs(client, sessionId, 'document.documentElement.outerHTML'));
|
|
923
|
-
break;
|
|
924
|
-
case 'value': {
|
|
925
|
-
const refArg = ctx.args[1];
|
|
926
|
-
if (!refArg)
|
|
927
|
-
throw new Error('Usage: monomind browse get value @ref');
|
|
928
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
929
|
-
const ref = _refs.get(refKey);
|
|
930
|
-
if (!ref)
|
|
931
|
-
throw new Error(`Ref @${refKey} not found`);
|
|
932
|
-
const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
|
|
933
|
-
if (!objectId)
|
|
934
|
-
throw new Error('Element not in DOM');
|
|
935
|
-
const r = await client.send('Runtime.callFunctionOn', {
|
|
936
|
-
functionDeclaration: 'function() { return this.value ?? null; }',
|
|
937
|
-
objectId,
|
|
938
|
-
returnByValue: true,
|
|
939
|
-
}, sessionId);
|
|
940
|
-
value = r.result?.value ?? null;
|
|
941
|
-
break;
|
|
942
|
-
}
|
|
943
|
-
case 'attr': {
|
|
944
|
-
const refArg = ctx.args[1];
|
|
945
|
-
const attrName = ctx.args[2];
|
|
946
|
-
if (!refArg || !attrName)
|
|
947
|
-
throw new Error('Usage: monomind browse get attr @ref <attrName>');
|
|
948
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
949
|
-
const ref = _refs.get(refKey);
|
|
950
|
-
if (!ref)
|
|
951
|
-
throw new Error(`Ref @${refKey} not found`);
|
|
952
|
-
const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
|
|
953
|
-
if (!objectId)
|
|
954
|
-
throw new Error('Element not in DOM');
|
|
955
|
-
const r = await client.send('Runtime.callFunctionOn', {
|
|
956
|
-
functionDeclaration: `function() { return this.getAttribute(${JSON.stringify(attrName)}); }`,
|
|
957
|
-
objectId,
|
|
958
|
-
returnByValue: true,
|
|
959
|
-
}, sessionId);
|
|
960
|
-
value = r.result?.value ?? null;
|
|
961
|
-
break;
|
|
962
|
-
}
|
|
963
|
-
case 'count': {
|
|
964
|
-
const selector = ctx.args[1];
|
|
965
|
-
if (!selector)
|
|
966
|
-
throw new Error('Usage: monomind browse get count <cssSelector>');
|
|
967
|
-
value = await browser.evaluateJs(client, sessionId, `document.querySelectorAll(${JSON.stringify(selector)}).length`);
|
|
968
|
-
break;
|
|
969
|
-
}
|
|
970
|
-
case 'box': {
|
|
971
|
-
const refArg = ctx.args[1];
|
|
972
|
-
if (!refArg)
|
|
973
|
-
throw new Error('Usage: monomind browse get box @ref');
|
|
974
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
975
|
-
const ref = _refs.get(refKey);
|
|
976
|
-
if (!ref)
|
|
977
|
-
throw new Error(`Ref @${refKey} not found`);
|
|
978
|
-
const center = await browser.getElementBox(client, sessionId, ref);
|
|
979
|
-
value = deriveBoxOutput(center);
|
|
980
|
-
break;
|
|
981
|
-
}
|
|
982
|
-
case 'styles': {
|
|
983
|
-
const refArg = ctx.args[1];
|
|
984
|
-
if (!refArg)
|
|
985
|
-
throw new Error('Usage: monomind browse get styles @ref');
|
|
986
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
987
|
-
const ref = _refs.get(refKey);
|
|
988
|
-
if (!ref)
|
|
989
|
-
throw new Error(`Ref @${refKey} not found`);
|
|
990
|
-
const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
|
|
991
|
-
if (!objectId)
|
|
992
|
-
throw new Error('Element not in DOM');
|
|
993
|
-
const r = await client.send('Runtime.callFunctionOn', {
|
|
994
|
-
functionDeclaration: 'function() { const s = window.getComputedStyle(this); return JSON.stringify(Object.fromEntries([...s].map(k => [k, s.getPropertyValue(k)]))); }',
|
|
995
|
-
objectId,
|
|
996
|
-
returnByValue: true,
|
|
997
|
-
}, sessionId);
|
|
998
|
-
try {
|
|
999
|
-
value = JSON.parse(r.result?.value ?? '{}');
|
|
1000
|
-
}
|
|
1001
|
-
catch {
|
|
1002
|
-
value = {};
|
|
1003
|
-
}
|
|
1004
|
-
break;
|
|
1005
|
-
}
|
|
1006
|
-
default:
|
|
1007
|
-
throw new Error(`Unknown: ${what}. Use: url|title|text|html|value|attr|count|box|styles`);
|
|
1008
|
-
}
|
|
1009
|
-
if (ctx.flags.json) {
|
|
1010
|
-
print(JSON.stringify({ data: { [what]: value } }));
|
|
1011
|
-
}
|
|
1012
|
-
else {
|
|
1013
|
-
print(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value ?? ''));
|
|
1014
|
-
}
|
|
1015
|
-
return { success: true, data: { [what]: value } };
|
|
1016
|
-
},
|
|
1017
|
-
};
|
|
1018
|
-
const scrollCommand = {
|
|
1019
|
-
name: 'scroll',
|
|
1020
|
-
description: 'Scroll the page. Usage: monomind browse scroll up|down|left|right [amount] [--selector ".sidebar"]',
|
|
1021
|
-
options: [
|
|
1022
|
-
{ name: 'amount', short: 'a', type: 'number', description: 'Pixels to scroll', default: 300 },
|
|
1023
|
-
{ name: 'ref', type: 'string', description: 'Element ref to scroll within' },
|
|
1024
|
-
{
|
|
1025
|
-
name: 'selector',
|
|
1026
|
-
short: 's',
|
|
1027
|
-
type: 'string',
|
|
1028
|
-
description: 'CSS selector of element to scroll within',
|
|
1029
|
-
},
|
|
1030
|
-
],
|
|
1031
|
-
action: async (ctx) => {
|
|
1032
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1033
|
-
const browser = await getBrowser();
|
|
1034
|
-
const direction = ctx.args[0];
|
|
1035
|
-
if (!direction)
|
|
1036
|
-
throw new Error('Usage: monomind browse scroll up|down|left|right [amount]');
|
|
1037
|
-
// Support positional amount: scroll down 300
|
|
1038
|
-
const positionalAmount = ctx.args[1] !== undefined ? parseInt(ctx.args[1], 10) : undefined;
|
|
1039
|
-
const amount = positionalAmount && Number.isFinite(positionalAmount)
|
|
1040
|
-
? positionalAmount
|
|
1041
|
-
: (ctx.flags.amount ?? 300);
|
|
1042
|
-
if (ctx.flags.selector) {
|
|
1043
|
-
const sel = ctx.flags.selector;
|
|
1044
|
-
const dx = direction === 'right' ? amount : direction === 'left' ? -amount : 0;
|
|
1045
|
-
const dy = direction === 'down' ? amount : direction === 'up' ? -amount : 0;
|
|
1046
|
-
const posJson = (await browser.evaluateJs(client, sessionId, `(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});})()`));
|
|
1047
|
-
if (!posJson)
|
|
1048
|
-
throw new Error(`Selector not found: ${sel}`);
|
|
1049
|
-
const pos = JSON.parse(posJson);
|
|
1050
|
-
await client.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: pos.x, y: pos.y, deltaX: dx, deltaY: dy }, sessionId);
|
|
1051
|
-
output.printSuccess(`Scrolled ${direction} in ${sel}`);
|
|
1052
|
-
return { success: true };
|
|
1053
|
-
}
|
|
1054
|
-
let ref;
|
|
1055
|
-
if (ctx.flags.ref) {
|
|
1056
|
-
const refKey = ctx.flags.ref.startsWith('@')
|
|
1057
|
-
? ctx.flags.ref.slice(1)
|
|
1058
|
-
: ctx.flags.ref;
|
|
1059
|
-
ref = _refs.get(refKey);
|
|
1060
|
-
}
|
|
1061
|
-
await browser.scrollElement(client, sessionId, direction, amount, ref);
|
|
1062
|
-
output.printSuccess(`Scrolled ${direction}`);
|
|
1063
|
-
return { success: true };
|
|
1064
|
-
},
|
|
1065
|
-
};
|
|
1066
|
-
const navigateCommand = {
|
|
1067
|
-
name: 'navigate',
|
|
1068
|
-
description: 'Navigate browser history. Usage: monomind browse navigate back|forward|reload',
|
|
1069
|
-
action: async (ctx) => {
|
|
1070
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1071
|
-
const browser = await getBrowser();
|
|
1072
|
-
const direction = ctx.args[0];
|
|
1073
|
-
if (!direction)
|
|
1074
|
-
throw new Error('Usage: monomind browse navigate back|forward|reload');
|
|
1075
|
-
if (direction === 'back' || direction === 'forward') {
|
|
1076
|
-
// Pre-register frame-start listener BEFORE JS navigation to avoid the race
|
|
1077
|
-
// where history.back/forward() returns before the browser issues any requests
|
|
1078
|
-
let offFrameStarted = () => { };
|
|
1079
|
-
const frameStartedPromise = new Promise((resolve) => {
|
|
1080
|
-
offFrameStarted = client.on('Page.frameStartedLoading', (_params, sid) => {
|
|
1081
|
-
if (sid === sessionId) {
|
|
1082
|
-
const off = offFrameStarted;
|
|
1083
|
-
offFrameStarted = () => { };
|
|
1084
|
-
off();
|
|
1085
|
-
resolve();
|
|
1086
|
-
}
|
|
1087
|
-
});
|
|
1088
|
-
});
|
|
1089
|
-
try {
|
|
1090
|
-
await client.send('Runtime.evaluate', {
|
|
1091
|
-
expression: direction === 'back' ? 'history.back()' : 'history.forward()',
|
|
1092
|
-
}, sessionId);
|
|
1093
|
-
let fallbackHandle;
|
|
1094
|
-
const fallbackPromise = new Promise((r) => {
|
|
1095
|
-
fallbackHandle = setTimeout(r, 2000);
|
|
1096
|
-
});
|
|
1097
|
-
await Promise.race([frameStartedPromise, fallbackPromise]);
|
|
1098
|
-
if (fallbackHandle !== undefined)
|
|
1099
|
-
clearTimeout(fallbackHandle);
|
|
1100
|
-
}
|
|
1101
|
-
finally {
|
|
1102
|
-
offFrameStarted();
|
|
1103
|
-
}
|
|
1104
|
-
await browser.waitForLoad(client, sessionId, 'networkidle');
|
|
1105
|
-
}
|
|
1106
|
-
else if (direction === 'reload') {
|
|
1107
|
-
await client.send('Page.reload', {}, sessionId);
|
|
1108
|
-
await browser.waitForLoad(client, sessionId, 'load');
|
|
1109
|
-
}
|
|
1110
|
-
else {
|
|
1111
|
-
throw new Error(`Unknown direction: ${direction}. Use: back|forward|reload`);
|
|
1112
|
-
}
|
|
1113
|
-
// Refs captured before this navigation may now point at different content.
|
|
1114
|
-
_refs = new Map();
|
|
1115
|
-
await browser.clearRefCache();
|
|
1116
|
-
output.printSuccess(`Navigated: ${direction}`);
|
|
1117
|
-
return { success: true };
|
|
1118
|
-
},
|
|
1119
|
-
};
|
|
1120
|
-
const setCommand = {
|
|
1121
|
-
name: 'set',
|
|
1122
|
-
description: 'Configure browser settings. Usage: monomind browse set viewport|device|geo|offline|media|credentials|useragent <args>',
|
|
1123
|
-
action: async (ctx) => {
|
|
1124
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1125
|
-
const browser = await getBrowser();
|
|
1126
|
-
const setting = ctx.args[0];
|
|
1127
|
-
if (!setting)
|
|
1128
|
-
throw new Error('Usage: monomind browse set viewport|device|geo|offline|media|credentials|useragent <args>');
|
|
1129
|
-
switch (setting) {
|
|
1130
|
-
case 'viewport': {
|
|
1131
|
-
const width = parseInt(ctx.args[1], 10);
|
|
1132
|
-
const height = parseInt(ctx.args[2], 10);
|
|
1133
|
-
const dpr = parseFloat(ctx.args[3]) || undefined;
|
|
1134
|
-
if (Number.isNaN(width) || Number.isNaN(height))
|
|
1135
|
-
throw new Error('Usage: set viewport <width> <height> [dpr]');
|
|
1136
|
-
await client.send('Emulation.setDeviceMetricsOverride', {
|
|
1137
|
-
width,
|
|
1138
|
-
height,
|
|
1139
|
-
deviceScaleFactor: dpr ?? 1,
|
|
1140
|
-
mobile: false,
|
|
1141
|
-
}, sessionId);
|
|
1142
|
-
output.printSuccess(`Viewport set to ${width}x${height}${dpr ? ` @${dpr}x` : ''}`);
|
|
1143
|
-
break;
|
|
1144
|
-
}
|
|
1145
|
-
case 'device': {
|
|
1146
|
-
const deviceName = ctx.args[1];
|
|
1147
|
-
if (!deviceName)
|
|
1148
|
-
throw new Error(`Usage: set device <name>. Available: ${browser.listDevices().join(', ')}`);
|
|
1149
|
-
await browser.emulateDevice(client, sessionId, deviceName);
|
|
1150
|
-
output.printSuccess(`Emulating device: ${deviceName}`);
|
|
1151
|
-
break;
|
|
1152
|
-
}
|
|
1153
|
-
case 'geo': {
|
|
1154
|
-
const lat = parseFloat(ctx.args[1]);
|
|
1155
|
-
const lon = parseFloat(ctx.args[2]);
|
|
1156
|
-
const acc = parseFloat(ctx.args[3]) || 100;
|
|
1157
|
-
if (Number.isNaN(lat) || Number.isNaN(lon))
|
|
1158
|
-
throw new Error('Usage: set geo <latitude> <longitude> [accuracy]');
|
|
1159
|
-
await browser.setGeolocation(client, sessionId, lat, lon, acc);
|
|
1160
|
-
output.printSuccess(`Geolocation set: ${lat}, ${lon}`);
|
|
1161
|
-
break;
|
|
1162
|
-
}
|
|
1163
|
-
case 'offline': {
|
|
1164
|
-
const offlineArg = ctx.args[1];
|
|
1165
|
-
if (offlineArg === undefined)
|
|
1166
|
-
throw new Error('Usage: set offline <true|false>');
|
|
1167
|
-
const enabled = offlineArg === 'true';
|
|
1168
|
-
await browser.setOfflineMode(client, sessionId, enabled);
|
|
1169
|
-
output.printSuccess(`Offline mode: ${enabled}`);
|
|
1170
|
-
break;
|
|
1171
|
-
}
|
|
1172
|
-
case 'media': {
|
|
1173
|
-
const scheme = ctx.args[1];
|
|
1174
|
-
if (!scheme)
|
|
1175
|
-
throw new Error('Usage: set media dark|light|no-preference');
|
|
1176
|
-
await browser.setColorScheme(client, sessionId, scheme);
|
|
1177
|
-
output.printSuccess(`Color scheme: ${scheme}`);
|
|
1178
|
-
break;
|
|
1179
|
-
}
|
|
1180
|
-
case 'credentials': {
|
|
1181
|
-
const username = ctx.args[1];
|
|
1182
|
-
const password = ctx.args[2];
|
|
1183
|
-
if (!username || !password)
|
|
1184
|
-
throw new Error('Usage: set credentials <username> <password>');
|
|
1185
|
-
await browser.setBasicAuth(client, sessionId, username, password);
|
|
1186
|
-
output.printSuccess('Basic auth credentials set');
|
|
1187
|
-
break;
|
|
1188
|
-
}
|
|
1189
|
-
case 'useragent': {
|
|
1190
|
-
const ua = ctx.args[1];
|
|
1191
|
-
if (!ua)
|
|
1192
|
-
throw new Error('Usage: set useragent "<user-agent-string>"');
|
|
1193
|
-
await browser.setUserAgent(client, sessionId, ua);
|
|
1194
|
-
output.printSuccess('User agent set');
|
|
1195
|
-
break;
|
|
1196
|
-
}
|
|
1197
|
-
default:
|
|
1198
|
-
throw new Error(`Unknown setting: ${setting}. Use: viewport|device|geo|offline|media|credentials|useragent`);
|
|
1199
|
-
}
|
|
1200
|
-
return { success: true };
|
|
1201
|
-
},
|
|
1202
|
-
};
|
|
1203
|
-
const stateCommand = {
|
|
1204
|
-
name: 'state',
|
|
1205
|
-
description: 'Manage browser session state. Usage: monomind browse state save|load|list|rename|clean [name]',
|
|
1206
|
-
options: [
|
|
1207
|
-
{
|
|
1208
|
-
name: 'older-than',
|
|
1209
|
-
type: 'number',
|
|
1210
|
-
description: 'For state clean: remove sessions older than N days',
|
|
1211
|
-
},
|
|
1212
|
-
],
|
|
1213
|
-
action: async (ctx) => {
|
|
1214
|
-
const browser = await getBrowser();
|
|
1215
|
-
const action = ctx.args[0];
|
|
1216
|
-
if (!action)
|
|
1217
|
-
throw new Error('Usage: monomind browse state save|load|list [name]');
|
|
1218
|
-
switch (action) {
|
|
1219
|
-
case 'list': {
|
|
1220
|
-
const sessions = await browser.listSessions();
|
|
1221
|
-
if (sessions.length === 0) {
|
|
1222
|
-
output.printInfo('No saved sessions');
|
|
1223
|
-
}
|
|
1224
|
-
else {
|
|
1225
|
-
output.printInfo('Saved sessions:');
|
|
1226
|
-
for (const s of sessions)
|
|
1227
|
-
print(` ${s}`);
|
|
1228
|
-
}
|
|
1229
|
-
return { success: true, data: { sessions } };
|
|
1230
|
-
}
|
|
1231
|
-
case 'save': {
|
|
1232
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1233
|
-
const target = ctx.args[1];
|
|
1234
|
-
if (!target)
|
|
1235
|
-
throw new Error('Usage: monomind browse state save <name-or-file>');
|
|
1236
|
-
const url = await browser.getCurrentUrl(client, sessionId);
|
|
1237
|
-
const title = await browser.getCurrentTitle(client, sessionId);
|
|
1238
|
-
if (target.endsWith('.json')) {
|
|
1239
|
-
await browser.saveStateFile(client, sessionId, _targetId, target, url, title);
|
|
1240
|
-
output.printSuccess(`State saved to ${target}`);
|
|
1241
|
-
}
|
|
1242
|
-
else {
|
|
1243
|
-
const path = await browser.saveSession(client, sessionId, _targetId, target, url, title);
|
|
1244
|
-
output.printSuccess(`Session "${target}" saved to ${path}`);
|
|
1245
|
-
}
|
|
1246
|
-
return { success: true };
|
|
1247
|
-
}
|
|
1248
|
-
case 'load': {
|
|
1249
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1250
|
-
const target = ctx.args[1];
|
|
1251
|
-
if (!target)
|
|
1252
|
-
throw new Error('Usage: monomind browse state load <name-or-file>');
|
|
1253
|
-
if (target.endsWith('.json')) {
|
|
1254
|
-
await browser.loadStateFile(client, sessionId, target);
|
|
1255
|
-
}
|
|
1256
|
-
else {
|
|
1257
|
-
await browser.loadSession(client, sessionId, target);
|
|
1258
|
-
}
|
|
1259
|
-
output.printSuccess(`State loaded from ${target}`);
|
|
1260
|
-
return { success: true };
|
|
1261
|
-
}
|
|
1262
|
-
case 'show': {
|
|
1263
|
-
const { client: c, sessionId: sid } = await ensureConnected(_port);
|
|
1264
|
-
const url = await browser.getCurrentUrl(c, sid);
|
|
1265
|
-
const title = await browser.getCurrentTitle(c, sid);
|
|
1266
|
-
const cookies = await browser.getCookies(c, sid);
|
|
1267
|
-
const ls = await browser.getAllLocalStorage(c, sid);
|
|
1268
|
-
const info = {
|
|
1269
|
-
url,
|
|
1270
|
-
title,
|
|
1271
|
-
cookies: cookies.length,
|
|
1272
|
-
localStorage: Object.keys(ls).length,
|
|
1273
|
-
refs: _refs.size,
|
|
1274
|
-
};
|
|
1275
|
-
print(JSON.stringify(info, null, 2));
|
|
1276
|
-
return { success: true, data: info };
|
|
1277
|
-
}
|
|
1278
|
-
case 'clear': {
|
|
1279
|
-
const { client: c, sessionId: sid } = await ensureConnected(_port);
|
|
1280
|
-
await browser.clearCookies(c, sid);
|
|
1281
|
-
await browser.clearLocalStorage(c, sid);
|
|
1282
|
-
await browser.clearSessionStorage(c, sid);
|
|
1283
|
-
_refs = new Map();
|
|
1284
|
-
await browser.clearRefCache();
|
|
1285
|
-
output.printSuccess('Browser state cleared (cookies, localStorage, sessionStorage, refs)');
|
|
1286
|
-
return { success: true };
|
|
1287
|
-
}
|
|
1288
|
-
case 'rename': {
|
|
1289
|
-
const oldName = ctx.args[1];
|
|
1290
|
-
const newName = ctx.args[2];
|
|
1291
|
-
if (!oldName || !newName)
|
|
1292
|
-
throw new Error('Usage: monomind browse state rename <old-name> <new-name>');
|
|
1293
|
-
const sessions = await browser.listSessions();
|
|
1294
|
-
if (!sessions.includes(oldName))
|
|
1295
|
-
throw new Error(`Session not found: ${oldName}`);
|
|
1296
|
-
// W1: validate newName to prevent path traversal
|
|
1297
|
-
const { basename: basenameFn } = await import('node:path');
|
|
1298
|
-
const safeName = basenameFn(newName);
|
|
1299
|
-
if (safeName !== newName || safeName.startsWith('.') || safeName.includes('/')) {
|
|
1300
|
-
throw new Error('Invalid session name — must not contain path separators or start with "."');
|
|
1301
|
-
}
|
|
1302
|
-
const { unlink: unlinkRename, readFile, writeFile, mkdir: mkdirRename, } = await import('node:fs/promises');
|
|
1303
|
-
const { join: joinR } = await import('node:path');
|
|
1304
|
-
const { homedir } = await import('node:os');
|
|
1305
|
-
const sessionDir = joinR(homedir(), '.monomind', 'browser-sessions');
|
|
1306
|
-
const oldPath = joinR(sessionDir, `${oldName}.json`);
|
|
1307
|
-
const newPath = joinR(sessionDir, `${newName}.json`);
|
|
1308
|
-
const data = JSON.parse(await readFile(oldPath, 'utf8'));
|
|
1309
|
-
data.name = newName;
|
|
1310
|
-
await mkdirRename(sessionDir, { recursive: true });
|
|
1311
|
-
await writeFile(newPath, JSON.stringify(data, null, 2), 'utf8');
|
|
1312
|
-
await unlinkRename(oldPath).catch(() => { }); // C1: delete old file (not rename to /dev/null)
|
|
1313
|
-
output.printSuccess(`Session renamed: ${oldName} → ${newName}`);
|
|
1314
|
-
return { success: true };
|
|
1315
|
-
}
|
|
1316
|
-
case 'clean': {
|
|
1317
|
-
const days = ctx.flags['older-than'] ?? 7;
|
|
1318
|
-
const { unlink, stat } = await import('node:fs/promises');
|
|
1319
|
-
const { join: joinC } = await import('node:path');
|
|
1320
|
-
const { homedir: homedirC } = await import('node:os');
|
|
1321
|
-
const sessionDir = joinC(homedirC(), '.monomind', 'browser-sessions');
|
|
1322
|
-
const sessions = await browser.listSessions();
|
|
1323
|
-
const cutoff = Date.now() - days * 86400 * 1000;
|
|
1324
|
-
let removed = 0;
|
|
1325
|
-
for (const name of sessions) {
|
|
1326
|
-
const p = joinC(sessionDir, `${name}.json`);
|
|
1327
|
-
const s = await stat(p).catch(() => null);
|
|
1328
|
-
if (s && s.mtimeMs < cutoff) {
|
|
1329
|
-
await unlink(p).catch(() => { });
|
|
1330
|
-
removed++;
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
output.printSuccess(`Cleaned ${removed} session(s) older than ${days} days`);
|
|
1334
|
-
return { success: true, data: { removed } };
|
|
1335
|
-
}
|
|
1336
|
-
default:
|
|
1337
|
-
throw new Error(`Unknown action: ${action}. Use: save|load|list|show|clear|rename|clean`);
|
|
1338
|
-
}
|
|
1339
|
-
},
|
|
1340
|
-
};
|
|
1341
|
-
const networkCommand = {
|
|
1342
|
-
name: 'network',
|
|
1343
|
-
description: 'Network interception and cookie management',
|
|
1344
|
-
options: [
|
|
1345
|
-
{ name: 'pattern', type: 'string', description: 'URL pattern for route (glob)' },
|
|
1346
|
-
{ name: 'abort', type: 'boolean', description: 'Abort matching requests' },
|
|
1347
|
-
{ name: 'fulfill', type: 'string', description: 'JSON response body' },
|
|
1348
|
-
{ name: 'status', type: 'number', description: 'HTTP status for fulfill', default: 200 },
|
|
1349
|
-
{ name: 'headers', type: 'string', description: 'JSON headers object' },
|
|
1350
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
1351
|
-
{
|
|
1352
|
-
name: 'filter',
|
|
1353
|
-
type: 'string',
|
|
1354
|
-
description: 'Filter requests by URL substring (for network requests)',
|
|
1355
|
-
},
|
|
1356
|
-
{
|
|
1357
|
-
name: 'method',
|
|
1358
|
-
type: 'string',
|
|
1359
|
-
description: 'Filter by HTTP method, e.g. GET, POST (for network requests)',
|
|
1360
|
-
},
|
|
1361
|
-
{
|
|
1362
|
-
name: 'status-code',
|
|
1363
|
-
type: 'number',
|
|
1364
|
-
description: 'Filter by HTTP status code (for network requests)',
|
|
1365
|
-
},
|
|
1366
|
-
{
|
|
1367
|
-
name: 'type',
|
|
1368
|
-
type: 'string',
|
|
1369
|
-
description: 'Filter by resource type: xhr|fetch|document|script|stylesheet|image (for network requests)',
|
|
1370
|
-
},
|
|
1371
|
-
],
|
|
1372
|
-
action: async (ctx) => {
|
|
1373
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1374
|
-
const browser = await getBrowser();
|
|
1375
|
-
const action = ctx.args[0];
|
|
1376
|
-
if (!action)
|
|
1377
|
-
throw new Error('Usage: monomind browse network route|unroute|cookies|headers|requests');
|
|
1378
|
-
switch (action) {
|
|
1379
|
-
case 'route': {
|
|
1380
|
-
const pattern = ctx.flags.pattern;
|
|
1381
|
-
if (!pattern)
|
|
1382
|
-
throw new Error('--pattern required for network route');
|
|
1383
|
-
const routes = [
|
|
1384
|
-
{
|
|
1385
|
-
pattern,
|
|
1386
|
-
action: ctx.flags.abort ? 'abort' : ctx.flags.fulfill ? 'fulfill' : 'continue',
|
|
1387
|
-
response: ctx.flags.fulfill
|
|
1388
|
-
? {
|
|
1389
|
-
status: ctx.flags.status,
|
|
1390
|
-
body: ctx.flags.fulfill,
|
|
1391
|
-
headers: ctx.flags.headers ? JSON.parse(ctx.flags.headers) : {},
|
|
1392
|
-
}
|
|
1393
|
-
: undefined,
|
|
1394
|
-
},
|
|
1395
|
-
];
|
|
1396
|
-
await browser.setupRoutes(client, sessionId, routes);
|
|
1397
|
-
output.printSuccess(`Network route set: ${pattern}`);
|
|
1398
|
-
break;
|
|
1399
|
-
}
|
|
1400
|
-
case 'unroute':
|
|
1401
|
-
await browser.disableInterception(client, sessionId);
|
|
1402
|
-
output.printSuccess('Network interception disabled');
|
|
1403
|
-
break;
|
|
1404
|
-
case 'cookies': {
|
|
1405
|
-
const cookies = await browser.getCookies(client, sessionId);
|
|
1406
|
-
print(JSON.stringify(cookies, null, 2));
|
|
1407
|
-
return { success: true, data: { cookies } };
|
|
1408
|
-
}
|
|
1409
|
-
case 'headers': {
|
|
1410
|
-
const headers = ctx.flags.headers;
|
|
1411
|
-
if (!headers)
|
|
1412
|
-
throw new Error('--headers required (JSON string)');
|
|
1413
|
-
await browser.setExtraHeaders(client, sessionId, JSON.parse(headers));
|
|
1414
|
-
output.printSuccess('Extra headers set');
|
|
1415
|
-
break;
|
|
1416
|
-
}
|
|
1417
|
-
case 'capture': {
|
|
1418
|
-
const subAction = ctx.args[1] ?? 'start';
|
|
1419
|
-
if (subAction === 'start') {
|
|
1420
|
-
browser.startRequestCapture(client, sessionId);
|
|
1421
|
-
output.printSuccess('Request capture started');
|
|
1422
|
-
}
|
|
1423
|
-
else if (subAction === 'stop') {
|
|
1424
|
-
browser.stopRequestCapture(sessionId);
|
|
1425
|
-
output.printSuccess('Request capture stopped');
|
|
1426
|
-
}
|
|
1427
|
-
else if (subAction === 'clear') {
|
|
1428
|
-
browser.clearCapturedRequests(sessionId);
|
|
1429
|
-
output.printSuccess('Captured requests cleared');
|
|
1430
|
-
}
|
|
1431
|
-
break;
|
|
1432
|
-
}
|
|
1433
|
-
case 'requests': {
|
|
1434
|
-
let reqs = browser.getCapturedRequests(sessionId);
|
|
1435
|
-
const filterUrl = ctx.flags.filter;
|
|
1436
|
-
const filterMethod = ctx.flags.method;
|
|
1437
|
-
const filterStatus = ctx.flags['status-code'];
|
|
1438
|
-
const filterType = ctx.flags.type;
|
|
1439
|
-
if (filterUrl)
|
|
1440
|
-
reqs = reqs.filter((r) => r.url.includes(filterUrl));
|
|
1441
|
-
if (filterMethod)
|
|
1442
|
-
reqs = reqs.filter((r) => (r.method ?? 'GET').toUpperCase() === filterMethod.toUpperCase());
|
|
1443
|
-
if (filterStatus)
|
|
1444
|
-
reqs = reqs.filter((r) => r.status === filterStatus);
|
|
1445
|
-
if (filterType)
|
|
1446
|
-
reqs = reqs.filter((r) => r.resourceType === filterType ||
|
|
1447
|
-
r.type === filterType);
|
|
1448
|
-
if (ctx.flags.json)
|
|
1449
|
-
print(JSON.stringify({ data: reqs }));
|
|
1450
|
-
else {
|
|
1451
|
-
if (reqs.length === 0) {
|
|
1452
|
-
output.printInfo('No captured requests. Run: network capture start');
|
|
1453
|
-
}
|
|
1454
|
-
else
|
|
1455
|
-
for (const r of reqs)
|
|
1456
|
-
print(` ${r.method ?? 'GET'} ${r.status ?? '-'} ${r.url}`);
|
|
1457
|
-
}
|
|
1458
|
-
return { success: true, data: { requests: reqs } };
|
|
1459
|
-
}
|
|
1460
|
-
case 'request': {
|
|
1461
|
-
const reqId = ctx.args[1];
|
|
1462
|
-
if (!reqId)
|
|
1463
|
-
throw new Error('Usage: monomind browse network request <requestId>');
|
|
1464
|
-
const reqs = browser.getCapturedRequests(sessionId);
|
|
1465
|
-
const req = reqs.find((r) => r.requestId === reqId ||
|
|
1466
|
-
r.id === reqId);
|
|
1467
|
-
if (!req) {
|
|
1468
|
-
output.printWarning(`Request not found: ${reqId}`);
|
|
1469
|
-
return { success: false };
|
|
1470
|
-
}
|
|
1471
|
-
if (ctx.flags.json)
|
|
1472
|
-
print(JSON.stringify({ data: req }));
|
|
1473
|
-
else
|
|
1474
|
-
print(JSON.stringify(req, null, 2));
|
|
1475
|
-
return { success: true, data: { request: req } };
|
|
1476
|
-
}
|
|
1477
|
-
default:
|
|
1478
|
-
throw new Error(`Unknown: ${action}. Use: route|unroute|cookies|headers|capture|requests|request`);
|
|
1479
|
-
}
|
|
1480
|
-
return { success: true };
|
|
1481
|
-
},
|
|
1482
|
-
};
|
|
1483
|
-
// Default output cap for eval when --max-output isn't passed explicitly —
|
|
1484
|
-
// print(String(result)) had NO limit at all (unlike snapshot's --max-output),
|
|
1485
|
-
// so e.g. `eval "document.documentElement.outerHTML"` could dump megabytes
|
|
1486
|
-
// straight into an agent's context. An explicit --max-output still overrides
|
|
1487
|
-
// this default.
|
|
1488
|
-
const DEFAULT_EVAL_MAX_OUTPUT = 50_000;
|
|
1489
|
-
function truncateForOutput(text, maxOutput) {
|
|
1490
|
-
if (!(maxOutput > 0) || text.length <= maxOutput)
|
|
1491
|
-
return text;
|
|
1492
|
-
return `${text.slice(0, maxOutput)}\n[... truncated at ${maxOutput} chars]`;
|
|
1493
|
-
}
|
|
1494
|
-
const evalCommand = {
|
|
1495
|
-
name: 'eval',
|
|
1496
|
-
description: 'Evaluate JavaScript in page context. Usage: monomind browse eval "document.title"',
|
|
1497
|
-
options: [
|
|
1498
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
1499
|
-
{
|
|
1500
|
-
name: 'stdin',
|
|
1501
|
-
type: 'boolean',
|
|
1502
|
-
description: 'Read JS expression from stdin (heredoc-friendly for multiline scripts)',
|
|
1503
|
-
default: false,
|
|
1504
|
-
},
|
|
1505
|
-
{
|
|
1506
|
-
name: 'max-output',
|
|
1507
|
-
type: 'number',
|
|
1508
|
-
description: `Truncate printed output to N characters (default ${DEFAULT_EVAL_MAX_OUTPUT}; 0 disables truncation)`,
|
|
1509
|
-
},
|
|
1510
|
-
{
|
|
1511
|
-
name: 'timeout',
|
|
1512
|
-
type: 'number',
|
|
1513
|
-
description: 'Max ms to wait for evaluation to settle (default 30000)',
|
|
1514
|
-
},
|
|
1515
|
-
],
|
|
1516
|
-
action: async (ctx) => {
|
|
1517
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1518
|
-
const browser = await getBrowser();
|
|
1519
|
-
let expr = ctx.args[0];
|
|
1520
|
-
if (ctx.flags.stdin) {
|
|
1521
|
-
const chunks = [];
|
|
1522
|
-
for await (const chunk of process.stdin)
|
|
1523
|
-
chunks.push(chunk);
|
|
1524
|
-
expr = Buffer.concat(chunks).toString('utf8').trim();
|
|
1525
|
-
}
|
|
1526
|
-
if (!expr)
|
|
1527
|
-
throw new Error('Usage: monomind browse eval "<expression>" (or pipe with --stdin)');
|
|
1528
|
-
const timeoutMs = ctx.flags.timeout;
|
|
1529
|
-
const result = await browser.evaluateJs(client, sessionId, expr, timeoutMs);
|
|
1530
|
-
const maxOutput = ctx.flags['max-output'] ?? DEFAULT_EVAL_MAX_OUTPUT;
|
|
1531
|
-
if (ctx.flags.json) {
|
|
1532
|
-
print(truncateForOutput(JSON.stringify({ data: result }), maxOutput));
|
|
1533
|
-
}
|
|
1534
|
-
else {
|
|
1535
|
-
print(truncateForOutput(String(result ?? ''), maxOutput));
|
|
1536
|
-
}
|
|
1537
|
-
return { success: true, data: { result } };
|
|
1538
|
-
},
|
|
1539
|
-
};
|
|
1540
|
-
const reportCommand = {
|
|
1541
|
-
name: 'report',
|
|
1542
|
-
description: 'Test a page and write one self-contained HTML report + sibling JSON. Usage: monomind browse report <url>',
|
|
1543
|
-
options: [
|
|
1544
|
-
{ name: 'out', short: 'o', type: 'string', description: 'Output .html path, or a directory' },
|
|
1545
|
-
{
|
|
1546
|
-
name: 'budget',
|
|
1547
|
-
short: 'b',
|
|
1548
|
-
type: 'string',
|
|
1549
|
-
description: 'Budget JSON file path, or inline JSON',
|
|
1550
|
-
},
|
|
1551
|
-
{
|
|
1552
|
-
name: 'devices',
|
|
1553
|
-
short: 'd',
|
|
1554
|
-
type: 'string',
|
|
1555
|
-
description: 'Comma-separated device names for the screenshot matrix',
|
|
1556
|
-
},
|
|
1557
|
-
{
|
|
1558
|
-
name: 'wait',
|
|
1559
|
-
short: 'w',
|
|
1560
|
-
type: 'string',
|
|
1561
|
-
description: 'Extra settle step: a CSS selector to wait for, or milliseconds',
|
|
1562
|
-
},
|
|
1563
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
1564
|
-
{
|
|
1565
|
-
name: 'full-page',
|
|
1566
|
-
type: 'boolean',
|
|
1567
|
-
description: 'Full-page screenshot (--no-full-page for viewport only)',
|
|
1568
|
-
default: true,
|
|
1569
|
-
},
|
|
1570
|
-
{
|
|
1571
|
-
name: 'vitals-wait',
|
|
1572
|
-
type: 'number',
|
|
1573
|
-
description: 'Milliseconds to let the web-vitals observers run',
|
|
1574
|
-
default: 2500,
|
|
1575
|
-
},
|
|
1576
|
-
{
|
|
1577
|
-
name: 'timeout',
|
|
1578
|
-
type: 'number',
|
|
1579
|
-
description: 'Milliseconds to wait for the page to go network-idle',
|
|
1580
|
-
default: 20000,
|
|
1581
|
-
},
|
|
1582
|
-
{
|
|
1583
|
-
name: 'keep-open',
|
|
1584
|
-
type: 'boolean',
|
|
1585
|
-
description: 'Leave the browser running after the report',
|
|
1586
|
-
default: false,
|
|
1587
|
-
},
|
|
1588
|
-
{
|
|
1589
|
-
name: 'repeat',
|
|
1590
|
-
type: 'number',
|
|
1591
|
-
description: 'Run the URL N times and report per-check flake rates (RIG-14)',
|
|
1592
|
-
},
|
|
1593
|
-
{
|
|
1594
|
-
name: 'record',
|
|
1595
|
-
type: 'boolean',
|
|
1596
|
-
description: 'Record frames for the evidence timeline even when the run passes',
|
|
1597
|
-
default: false,
|
|
1598
|
-
},
|
|
1599
|
-
{
|
|
1600
|
-
name: 'history',
|
|
1601
|
-
type: 'boolean',
|
|
1602
|
-
description: 'Print the stored run history for this URL instead of running it',
|
|
1603
|
-
default: false,
|
|
1604
|
-
},
|
|
1605
|
-
{
|
|
1606
|
-
name: 'save',
|
|
1607
|
-
type: 'boolean',
|
|
1608
|
-
description: 'Save this run to the history store (--no-save to skip)',
|
|
1609
|
-
default: true,
|
|
1610
|
-
},
|
|
1611
|
-
{
|
|
1612
|
-
name: 'history-max',
|
|
1613
|
-
type: 'number',
|
|
1614
|
-
description: 'Runs kept per URL before the oldest are pruned (default 20)',
|
|
1615
|
-
},
|
|
1616
|
-
{
|
|
1617
|
-
name: 'trend-window',
|
|
1618
|
-
type: 'number',
|
|
1619
|
-
description: 'Prior runs charted in the trend section (default 10)',
|
|
1620
|
-
},
|
|
1621
|
-
],
|
|
1622
|
-
examples: [
|
|
1623
|
-
{ command: 'monomind browse report https://example.com', description: 'Report with defaults' },
|
|
1624
|
-
{
|
|
1625
|
-
command: 'monomind browse report https://example.com --repeat 5',
|
|
1626
|
-
description: 'Run five times and report flake rates',
|
|
1627
|
-
},
|
|
1628
|
-
{
|
|
1629
|
-
command: 'monomind browse report https://example.com --history',
|
|
1630
|
-
description: 'Show the stored run history for a URL',
|
|
1631
|
-
},
|
|
1632
|
-
{
|
|
1633
|
-
command: 'monomind browse report https://example.com --record',
|
|
1634
|
-
description: 'Attach a frame-by-frame evidence timeline',
|
|
1635
|
-
},
|
|
1636
|
-
{
|
|
1637
|
-
command: 'monomind browse report https://example.com --out ./reports/home.html',
|
|
1638
|
-
description: 'Choose the output path',
|
|
1639
|
-
},
|
|
1640
|
-
{
|
|
1641
|
-
command: 'monomind browse report https://example.com --budget \'{"lcp":4000}\'',
|
|
1642
|
-
description: 'Relax a budget inline',
|
|
1643
|
-
},
|
|
1644
|
-
{
|
|
1645
|
-
command: 'monomind browse report https://example.com --devices "iPhone 14,iPad"',
|
|
1646
|
-
description: 'Add a device screenshot matrix',
|
|
1647
|
-
},
|
|
1648
|
-
],
|
|
1649
|
-
action: async (ctx) => {
|
|
1650
|
-
const url = ctx.args[0];
|
|
1651
|
-
if (!url)
|
|
1652
|
-
throw new Error('URL required. Usage: monomind browse report <url>');
|
|
1653
|
-
// Imported lazily for the same reason getBrowser() is: this module loads
|
|
1654
|
-
// on every CLI invocation, and nothing but `report` needs the renderer.
|
|
1655
|
-
const { runReport, runReportRepeated, readHistory } = await import('../report/index.js');
|
|
1656
|
-
// --history is a read of the store, so it deliberately never launches a
|
|
1657
|
-
// browser — `report --history` must work offline and on a dead site.
|
|
1658
|
-
if (ctx.flags.history) {
|
|
1659
|
-
const { dir, runs } = await readHistory(url);
|
|
1660
|
-
if (ctx.flags.json) {
|
|
1661
|
-
print(JSON.stringify({ data: { dir, runs } }, null, 2));
|
|
1662
|
-
}
|
|
1663
|
-
else if (!runs.length) {
|
|
1664
|
-
output.printWarning(`No stored runs for ${url}`);
|
|
1665
|
-
print(`Looked in: ${dir}`);
|
|
1666
|
-
}
|
|
1667
|
-
else {
|
|
1668
|
-
print(`${runs.length} run(s) for ${url}`);
|
|
1669
|
-
print(`Stored in: ${dir}`);
|
|
1670
|
-
for (const run of runs) {
|
|
1671
|
-
const lcp = run.vitals.lcp === undefined ? '—' : `${Math.round(run.vitals.lcp)}ms`;
|
|
1672
|
-
print(` ${run.capturedAt} ${run.verdict.toUpperCase().padEnd(4)} ` +
|
|
1673
|
-
`LCP ${lcp.padStart(7)} ${run.counts.consoleErrors} console errors ` +
|
|
1674
|
-
`${run.counts.failedRequests} failed requests ${run.counts.a11yErrors} a11y errors`);
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
return { success: true, data: { dir, runs } };
|
|
1678
|
-
}
|
|
1679
|
-
const repeatRaw = ctx.flags.repeat;
|
|
1680
|
-
const repeat = typeof repeatRaw === 'number' && Number.isFinite(repeatRaw) && repeatRaw > 1
|
|
1681
|
-
? Math.floor(repeatRaw)
|
|
1682
|
-
: undefined;
|
|
1683
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1684
|
-
const browser = await getBrowser();
|
|
1685
|
-
const rawDevices = ctx.flags.devices;
|
|
1686
|
-
const devices = typeof rawDevices === 'string'
|
|
1687
|
-
? rawDevices
|
|
1688
|
-
.split(',')
|
|
1689
|
-
.map((d) => d.trim())
|
|
1690
|
-
.filter(Boolean)
|
|
1691
|
-
: undefined;
|
|
1692
|
-
const runOptions = {
|
|
1693
|
-
url,
|
|
1694
|
-
out: ctx.flags.out,
|
|
1695
|
-
budget: ctx.flags.budget,
|
|
1696
|
-
devices,
|
|
1697
|
-
wait: ctx.flags.wait,
|
|
1698
|
-
vitalsWaitMs: ctx.flags['vitals-wait'],
|
|
1699
|
-
loadTimeoutMs: ctx.flags.timeout,
|
|
1700
|
-
fullPage: ctx.flags['full-page'] !== false,
|
|
1701
|
-
cwd: ctx.cwd,
|
|
1702
|
-
// A failing run records its own evidence without being asked; --record
|
|
1703
|
-
// is for when you want the timeline from a run that passes.
|
|
1704
|
-
record: ctx.flags.record === true,
|
|
1705
|
-
history: ctx.flags.save !== false,
|
|
1706
|
-
historyMax: ctx.flags['history-max'],
|
|
1707
|
-
trendWindow: ctx.flags['trend-window'],
|
|
1708
|
-
};
|
|
1709
|
-
let result;
|
|
1710
|
-
try {
|
|
1711
|
-
result = repeat
|
|
1712
|
-
? await runReportRepeated(client, sessionId, { ...runOptions, repeat })
|
|
1713
|
-
: await runReport(client, sessionId, runOptions);
|
|
1714
|
-
}
|
|
1715
|
-
finally {
|
|
1716
|
-
// A report is a one-shot command — CI should not be left with an
|
|
1717
|
-
// orphan Chrome. Only close a browser THIS process launched: an
|
|
1718
|
-
// attached one belongs to the user's own `open`/`connect` session.
|
|
1719
|
-
if (!ctx.flags['keep-open'] && browser.getLaunchedPid(_port) !== undefined) {
|
|
1720
|
-
browser.stopRequestCapture(sessionId);
|
|
1721
|
-
browser.teardownConsoleCapture(sessionId);
|
|
1722
|
-
try {
|
|
1723
|
-
await browser.closeBrowser(client, _port);
|
|
1724
|
-
}
|
|
1725
|
-
catch {
|
|
1726
|
-
/* best-effort */
|
|
1727
|
-
}
|
|
1728
|
-
_client = null;
|
|
1729
|
-
_sessionId = '';
|
|
1730
|
-
_targetId = '';
|
|
1731
|
-
_refs = new Map();
|
|
1732
|
-
await browser.clearActivePort();
|
|
1733
|
-
await browser.clearRefCache();
|
|
1734
|
-
}
|
|
1735
|
-
}
|
|
1736
|
-
const { report, htmlPath, jsonPath, summary, historyDir } = result;
|
|
1737
|
-
const { flake } = result;
|
|
1738
|
-
// With --repeat the flake verdict governs: a check that failed 2 of 5
|
|
1739
|
-
// runs must not exit 0 just because the last run happened to be green.
|
|
1740
|
-
const passed = flake ? flake.verdict === 'pass' : report.verdict === 'pass';
|
|
1741
|
-
if (ctx.flags.json) {
|
|
1742
|
-
const { toJsonReport } = await import('../report/index.js');
|
|
1743
|
-
print(JSON.stringify({ data: { ...toJsonReport(report), flake, htmlPath, jsonPath, historyDir } }, null, 2));
|
|
1744
|
-
}
|
|
1745
|
-
else {
|
|
1746
|
-
if (passed)
|
|
1747
|
-
output.printSuccess(summary);
|
|
1748
|
-
else
|
|
1749
|
-
output.printError(summary);
|
|
1750
|
-
if (flake) {
|
|
1751
|
-
print(` ${flake.confidenceNote}`);
|
|
1752
|
-
for (const check of flake.checks.filter((c) => !c.stable)) {
|
|
1753
|
-
print(` ${check.label}: failed ${check.failed} of ${check.runs} runs`);
|
|
1754
|
-
}
|
|
1755
|
-
for (const signal of flake.signals.slice(0, 5)) {
|
|
1756
|
-
print(` ${signal.kind} "${signal.signature}" in ${signal.runs} of ${signal.total} runs`);
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
for (const failure of report.failures) {
|
|
1760
|
-
print(` ${failure.budget}: expected ${failure.expected}, got ${failure.actual}`);
|
|
1761
|
-
if (failure.detail)
|
|
1762
|
-
print(` ${failure.detail}`);
|
|
1763
|
-
}
|
|
1764
|
-
for (const headline of report.trend?.headlines ?? [])
|
|
1765
|
-
output.printWarning(headline);
|
|
1766
|
-
if (report.diff?.structure) {
|
|
1767
|
-
const d = report.diff.structure;
|
|
1768
|
-
print(` vs previous run: ${d.gained.length} gained, ${d.lost.length} lost, ${d.renamed.length} renamed`);
|
|
1769
|
-
}
|
|
1770
|
-
if (report.diff?.pixels?.comparable) {
|
|
1771
|
-
print(` vs previous run: ${report.diff.pixels.changedPercent.toFixed(2)}% of pixels changed`);
|
|
1772
|
-
}
|
|
1773
|
-
for (const note of report.notes)
|
|
1774
|
-
output.printWarning(note);
|
|
1775
|
-
print(`Report: ${htmlPath}`);
|
|
1776
|
-
print(`JSON: ${jsonPath}`);
|
|
1777
|
-
if (historyDir)
|
|
1778
|
-
print(`History: ${historyDir}`);
|
|
1779
|
-
}
|
|
1780
|
-
// Non-zero exit is the point of RIG-06 — CI and agents gate on it.
|
|
1781
|
-
return {
|
|
1782
|
-
success: passed,
|
|
1783
|
-
exitCode: passed ? 0 : 1,
|
|
1784
|
-
data: { htmlPath, jsonPath, report, flake },
|
|
1785
|
-
};
|
|
1786
|
-
},
|
|
1787
|
-
};
|
|
1788
|
-
const closeCommand = {
|
|
1789
|
-
name: 'close',
|
|
1790
|
-
description: 'Close the active browser session',
|
|
1791
|
-
action: async (_ctx) => {
|
|
1792
|
-
if (_client) {
|
|
1793
|
-
const browser = await getBrowser();
|
|
1794
|
-
const sid = _sessionId;
|
|
1795
|
-
const client = _client;
|
|
1796
|
-
// Tear down per-session Maps and listeners before closing
|
|
1797
|
-
if (browser.getHarStatus(sid).recording) {
|
|
1798
|
-
try {
|
|
1799
|
-
await browser.stopHarRecording(client, sid);
|
|
1800
|
-
}
|
|
1801
|
-
catch {
|
|
1802
|
-
/* ignore */
|
|
1803
|
-
}
|
|
1804
|
-
}
|
|
1805
|
-
if (browser.getTraceStatus(sid)) {
|
|
1806
|
-
try {
|
|
1807
|
-
await browser.stopTrace(client, sid);
|
|
1808
|
-
}
|
|
1809
|
-
catch {
|
|
1810
|
-
/* ignore */
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
if (browser.isProfilingActive(sid)) {
|
|
1814
|
-
try {
|
|
1815
|
-
await browser.stopCpuProfile(client, sid);
|
|
1816
|
-
}
|
|
1817
|
-
catch {
|
|
1818
|
-
/* ignore */
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
browser.teardownRouteInterception(sid);
|
|
1822
|
-
browser.stopRequestCapture(sid);
|
|
1823
|
-
browser.teardownDialogHandling(sid);
|
|
1824
|
-
browser.teardownConsoleCapture(sid);
|
|
1825
|
-
client.close();
|
|
1826
|
-
_client = null;
|
|
1827
|
-
_sessionId = '';
|
|
1828
|
-
_parentSessionId = '';
|
|
1829
|
-
_targetId = '';
|
|
1830
|
-
_refs = new Map();
|
|
1831
|
-
// Session is gone — forget the persisted port and stale refs so later
|
|
1832
|
-
// invocations don't chase a dead endpoint (or the wrong elements).
|
|
1833
|
-
await browser.clearActivePort();
|
|
1834
|
-
await browser.clearRefCache();
|
|
1835
|
-
output.printSuccess('Browser session closed');
|
|
1836
|
-
}
|
|
1837
|
-
else {
|
|
1838
|
-
// Each CLI invocation is a fresh process, so `close` almost always
|
|
1839
|
-
// lands here. The persisted port file is the real session handle:
|
|
1840
|
-
// if monobrowse LAUNCHED that browser (open), gracefully Browser.close
|
|
1841
|
-
// it — otherwise every open→close cycle leaks a headless Chrome. If we
|
|
1842
|
-
// merely ATTACHED to it (connect, launched:false), never kill it: it's
|
|
1843
|
-
// the user's own browser. Either way, forget the port and refs.
|
|
1844
|
-
const browser = await getBrowser();
|
|
1845
|
-
const persisted = await browser.loadActivePortInfo();
|
|
1846
|
-
if (persisted?.launched) {
|
|
1847
|
-
try {
|
|
1848
|
-
const conn = await browser.connectToTarget(persisted.port);
|
|
1849
|
-
try {
|
|
1850
|
-
await browser.closeBrowser(conn.client, persisted.port);
|
|
1851
|
-
}
|
|
1852
|
-
finally {
|
|
1853
|
-
// Always drop our websocket — a hung Browser.close must not keep
|
|
1854
|
-
// this CLI process's event loop alive.
|
|
1855
|
-
try {
|
|
1856
|
-
conn.client.close();
|
|
1857
|
-
}
|
|
1858
|
-
catch {
|
|
1859
|
-
/* already gone */
|
|
1860
|
-
}
|
|
1861
|
-
}
|
|
1862
|
-
// closeBrowser has no PID fallback in a fresh process (launchedPids
|
|
1863
|
-
// is per-process) — re-probe so we report what actually happened.
|
|
1864
|
-
// Poll briefly: Browser.close is acknowledged before the process
|
|
1865
|
-
// actually exits, so a single immediate probe false-alarms.
|
|
1866
|
-
let stillUp = true;
|
|
1867
|
-
const probeDeadline = Date.now() + 3000;
|
|
1868
|
-
while (stillUp && Date.now() < probeDeadline) {
|
|
1869
|
-
try {
|
|
1870
|
-
await fetch(`http://127.0.0.1:${persisted.port}/json/version`, {
|
|
1871
|
-
signal: AbortSignal.timeout(800),
|
|
1872
|
-
});
|
|
1873
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
1874
|
-
}
|
|
1875
|
-
catch {
|
|
1876
|
-
stillUp = false;
|
|
1877
|
-
}
|
|
1878
|
-
}
|
|
1879
|
-
if (stillUp)
|
|
1880
|
-
output.printWarning(`Browser on port ${persisted.port} did not exit — kill it manually if needed`);
|
|
1881
|
-
else
|
|
1882
|
-
output.printSuccess(`Closed browser on port ${persisted.port}`);
|
|
1883
|
-
}
|
|
1884
|
-
catch {
|
|
1885
|
-
output.printInfo(`No browser answering on port ${persisted.port} — nothing to close`);
|
|
1886
|
-
}
|
|
1887
|
-
}
|
|
1888
|
-
else {
|
|
1889
|
-
output.printInfo(persisted
|
|
1890
|
-
? `Detached from browser on port ${persisted.port} (attached via connect — left running)`
|
|
1891
|
-
: 'No active browser session');
|
|
1892
|
-
}
|
|
1893
|
-
await browser.clearActivePort();
|
|
1894
|
-
await browser.clearRefCache();
|
|
1895
|
-
}
|
|
1896
|
-
return { success: true };
|
|
1897
|
-
},
|
|
1898
|
-
};
|
|
1899
|
-
// ---------------------------------------------------------------------------
|
|
1900
|
-
// Additional subcommands
|
|
1901
|
-
// ---------------------------------------------------------------------------
|
|
1902
|
-
const dblclickCommand = {
|
|
1903
|
-
name: 'dblclick',
|
|
1904
|
-
description: 'Double-click an element. Usage: monomind browse dblclick @e1',
|
|
1905
|
-
action: async (ctx) => {
|
|
1906
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1907
|
-
const browser = await getBrowser();
|
|
1908
|
-
const refArg = ctx.args[0];
|
|
1909
|
-
if (!refArg)
|
|
1910
|
-
throw new Error('Usage: monomind browse dblclick @e1');
|
|
1911
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
1912
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
1913
|
-
await browser.clickElement(client, sessionId, ref, { clickCount: 2 });
|
|
1914
|
-
output.printSuccess(`Double-clicked: ${ref.role} "${ref.name}"`);
|
|
1915
|
-
return { success: true };
|
|
1916
|
-
},
|
|
1917
|
-
};
|
|
1918
|
-
const focusCommand = {
|
|
1919
|
-
name: 'focus',
|
|
1920
|
-
description: 'Focus an element. Usage: monomind browse focus @e1',
|
|
1921
|
-
action: async (ctx) => {
|
|
1922
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1923
|
-
const browser = await getBrowser();
|
|
1924
|
-
const refArg = ctx.args[0];
|
|
1925
|
-
if (!refArg)
|
|
1926
|
-
throw new Error('Usage: monomind browse focus @e1');
|
|
1927
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
1928
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
1929
|
-
await browser.focusElement(client, sessionId, ref);
|
|
1930
|
-
output.printSuccess(`Focused: ${ref.role} "${ref.name}"`);
|
|
1931
|
-
return { success: true };
|
|
1932
|
-
},
|
|
1933
|
-
};
|
|
1934
|
-
const typeCommand = {
|
|
1935
|
-
name: 'type',
|
|
1936
|
-
description: 'Type text into element (appends, does not clear). Usage: monomind browse type @e1 "text"',
|
|
1937
|
-
action: async (ctx) => {
|
|
1938
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1939
|
-
const browser = await getBrowser();
|
|
1940
|
-
const refArg = ctx.args[0];
|
|
1941
|
-
const value = ctx.args[1];
|
|
1942
|
-
if (!refArg || value === undefined)
|
|
1943
|
-
throw new Error('Usage: monomind browse type @e1 "value"');
|
|
1944
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
1945
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
1946
|
-
await browser.typeIntoElement(client, sessionId, ref, value);
|
|
1947
|
-
output.printSuccess(`Typed into: ${ref.role} "${ref.name}"`);
|
|
1948
|
-
return { success: true };
|
|
1949
|
-
},
|
|
1950
|
-
};
|
|
1951
|
-
const keyboardCommand = {
|
|
1952
|
-
name: 'keyboard',
|
|
1953
|
-
description: 'Keyboard commands. Usage: monomind browse keyboard type "text" | inserttext "text"',
|
|
1954
|
-
action: async (ctx) => {
|
|
1955
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1956
|
-
const browser = await getBrowser();
|
|
1957
|
-
const action = ctx.args[0];
|
|
1958
|
-
const text = ctx.args[1];
|
|
1959
|
-
if (!action || !text)
|
|
1960
|
-
throw new Error('Usage: monomind browse keyboard type|inserttext "text"');
|
|
1961
|
-
await browser.typeText(client, sessionId, text);
|
|
1962
|
-
output.printSuccess(`Keyboard ${action}: ${text.length} chars`);
|
|
1963
|
-
return { success: true };
|
|
1964
|
-
},
|
|
1965
|
-
};
|
|
1966
|
-
const keydownCommand = {
|
|
1967
|
-
name: 'keydown',
|
|
1968
|
-
description: 'Hold key down. Usage: monomind browse keydown Shift',
|
|
1969
|
-
action: async (ctx) => {
|
|
1970
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1971
|
-
const browser = await getBrowser();
|
|
1972
|
-
const key = ctx.args[0];
|
|
1973
|
-
if (!key)
|
|
1974
|
-
throw new Error('Usage: monomind browse keydown <key>');
|
|
1975
|
-
await browser.keyDown(client, sessionId, key);
|
|
1976
|
-
output.printSuccess(`Key down: ${key}`);
|
|
1977
|
-
return { success: true };
|
|
1978
|
-
},
|
|
1979
|
-
};
|
|
1980
|
-
const keyupCommand = {
|
|
1981
|
-
name: 'keyup',
|
|
1982
|
-
description: 'Release held key. Usage: monomind browse keyup Shift',
|
|
1983
|
-
action: async (ctx) => {
|
|
1984
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1985
|
-
const browser = await getBrowser();
|
|
1986
|
-
const key = ctx.args[0];
|
|
1987
|
-
if (!key)
|
|
1988
|
-
throw new Error('Usage: monomind browse keyup <key>');
|
|
1989
|
-
await browser.keyUp(client, sessionId, key);
|
|
1990
|
-
output.printSuccess(`Key up: ${key}`);
|
|
1991
|
-
return { success: true };
|
|
1992
|
-
},
|
|
1993
|
-
};
|
|
1994
|
-
const hoverCommand = {
|
|
1995
|
-
name: 'hover',
|
|
1996
|
-
description: 'Hover over an element. Usage: monomind browse hover @e1',
|
|
1997
|
-
action: async (ctx) => {
|
|
1998
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
1999
|
-
const browser = await getBrowser();
|
|
2000
|
-
const refArg = ctx.args[0];
|
|
2001
|
-
if (!refArg)
|
|
2002
|
-
throw new Error('Usage: monomind browse hover @e1');
|
|
2003
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
2004
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
2005
|
-
await browser.hoverElement(client, sessionId, ref);
|
|
2006
|
-
output.printSuccess(`Hovered: ${ref.role} "${ref.name}"`);
|
|
2007
|
-
return { success: true };
|
|
2008
|
-
},
|
|
2009
|
-
};
|
|
2010
|
-
const selectCommand = {
|
|
2011
|
-
name: 'select',
|
|
2012
|
-
description: 'Select a dropdown option. Usage: monomind browse select @e1 "Option text"',
|
|
2013
|
-
action: async (ctx) => {
|
|
2014
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2015
|
-
const browser = await getBrowser();
|
|
2016
|
-
const refArg = ctx.args[0];
|
|
2017
|
-
const value = ctx.args[1];
|
|
2018
|
-
if (!refArg || !value)
|
|
2019
|
-
throw new Error('Usage: monomind browse select @e1 "value"');
|
|
2020
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
2021
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
2022
|
-
await browser.selectOption(client, sessionId, ref, value);
|
|
2023
|
-
output.printSuccess(`Selected: "${value}"`);
|
|
2024
|
-
return { success: true };
|
|
2025
|
-
},
|
|
2026
|
-
};
|
|
2027
|
-
const checkCommand = {
|
|
2028
|
-
name: 'check',
|
|
2029
|
-
description: 'Check a checkbox. Usage: monomind browse check @e1',
|
|
2030
|
-
action: async (ctx) => {
|
|
2031
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2032
|
-
const browser = await getBrowser();
|
|
2033
|
-
const refArg = ctx.args[0];
|
|
2034
|
-
if (!refArg)
|
|
2035
|
-
throw new Error('Usage: monomind browse check @e1');
|
|
2036
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
2037
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
2038
|
-
await browser.checkElement(client, sessionId, ref, true);
|
|
2039
|
-
output.printSuccess(`Checked: ${ref.role} "${ref.name}"`);
|
|
2040
|
-
return { success: true };
|
|
2041
|
-
},
|
|
2042
|
-
};
|
|
2043
|
-
const uncheckCommand = {
|
|
2044
|
-
name: 'uncheck',
|
|
2045
|
-
description: 'Uncheck a checkbox. Usage: monomind browse uncheck @e1',
|
|
2046
|
-
action: async (ctx) => {
|
|
2047
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2048
|
-
const browser = await getBrowser();
|
|
2049
|
-
const refArg = ctx.args[0];
|
|
2050
|
-
if (!refArg)
|
|
2051
|
-
throw new Error('Usage: monomind browse uncheck @e1');
|
|
2052
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
2053
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
2054
|
-
await browser.checkElement(client, sessionId, ref, false);
|
|
2055
|
-
output.printSuccess(`Unchecked: ${ref.role} "${ref.name}"`);
|
|
2056
|
-
return { success: true };
|
|
2057
|
-
},
|
|
2058
|
-
};
|
|
2059
|
-
async function resolveElementObjectId(client, sessionId, refs, refOrSelector) {
|
|
2060
|
-
const browser = await getBrowser();
|
|
2061
|
-
if (refOrSelector.startsWith('@') || /^e\d+$/.test(refOrSelector)) {
|
|
2062
|
-
const key = refOrSelector.startsWith('@') ? refOrSelector.slice(1) : refOrSelector;
|
|
2063
|
-
const ref = await browser.resolveRef(client, sessionId, refs, key);
|
|
2064
|
-
const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
|
|
2065
|
-
if (!objectId)
|
|
2066
|
-
throw new Error(`Element @${key} not found in DOM`);
|
|
2067
|
-
return objectId;
|
|
2068
|
-
}
|
|
2069
|
-
// CSS selector path
|
|
2070
|
-
const res = await client.send('Runtime.evaluate', {
|
|
2071
|
-
expression: `document.querySelector(${JSON.stringify(refOrSelector)})`,
|
|
2072
|
-
returnByValue: false,
|
|
2073
|
-
}, sessionId);
|
|
2074
|
-
if (!res.result?.objectId || res.result?.subtype === 'null')
|
|
2075
|
-
throw new Error(`Selector not found: ${refOrSelector}`);
|
|
2076
|
-
return res.result.objectId;
|
|
2077
|
-
}
|
|
2078
|
-
const isvisibleCommand = {
|
|
2079
|
-
name: 'isvisible',
|
|
2080
|
-
description: 'Check if element is visible. Usage: monomind browse isvisible @e1|"selector"',
|
|
2081
|
-
options: [{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false }],
|
|
2082
|
-
action: async (ctx) => {
|
|
2083
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2084
|
-
const arg = ctx.args[0];
|
|
2085
|
-
if (!arg)
|
|
2086
|
-
throw new Error('Usage: monomind browse isvisible @e1|".selector"');
|
|
2087
|
-
const objectId = await resolveElementObjectId(client, sessionId, _refs, arg);
|
|
2088
|
-
const r = await client.send('Runtime.callFunctionOn', {
|
|
2089
|
-
functionDeclaration: `function(){var r=this.getBoundingClientRect(),s=window.getComputedStyle(this);return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none'&&parseFloat(s.opacity)>0;}`,
|
|
2090
|
-
objectId,
|
|
2091
|
-
returnByValue: true,
|
|
2092
|
-
}, sessionId);
|
|
2093
|
-
const visible = r.result?.value ?? false;
|
|
2094
|
-
if (ctx.flags.json) {
|
|
2095
|
-
print(JSON.stringify({ visible }));
|
|
2096
|
-
}
|
|
2097
|
-
else {
|
|
2098
|
-
output.printSuccess(`isvisible: ${visible}`);
|
|
2099
|
-
}
|
|
2100
|
-
return { success: true, data: { visible } };
|
|
2101
|
-
},
|
|
2102
|
-
};
|
|
2103
|
-
const isenabledCommand = {
|
|
2104
|
-
name: 'isenabled',
|
|
2105
|
-
description: 'Check if element is enabled (not disabled). Usage: monomind browse isenabled @e1|"selector"',
|
|
2106
|
-
options: [{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false }],
|
|
2107
|
-
action: async (ctx) => {
|
|
2108
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2109
|
-
const arg = ctx.args[0];
|
|
2110
|
-
if (!arg)
|
|
2111
|
-
throw new Error('Usage: monomind browse isenabled @e1|".selector"');
|
|
2112
|
-
const objectId = await resolveElementObjectId(client, sessionId, _refs, arg);
|
|
2113
|
-
const r = await client.send('Runtime.callFunctionOn', {
|
|
2114
|
-
functionDeclaration: `function(){return !this.disabled;}`,
|
|
2115
|
-
objectId,
|
|
2116
|
-
returnByValue: true,
|
|
2117
|
-
}, sessionId);
|
|
2118
|
-
const enabled = r.result?.value ?? true;
|
|
2119
|
-
if (ctx.flags.json) {
|
|
2120
|
-
print(JSON.stringify({ enabled }));
|
|
2121
|
-
}
|
|
2122
|
-
else {
|
|
2123
|
-
output.printSuccess(`isenabled: ${enabled}`);
|
|
2124
|
-
}
|
|
2125
|
-
return { success: true, data: { enabled } };
|
|
2126
|
-
},
|
|
2127
|
-
};
|
|
2128
|
-
const ischeckedCommand = {
|
|
2129
|
-
name: 'ischecked',
|
|
2130
|
-
description: 'Check if checkbox/radio is checked. Usage: monomind browse ischecked @e1|"selector"',
|
|
2131
|
-
options: [{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false }],
|
|
2132
|
-
action: async (ctx) => {
|
|
2133
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2134
|
-
const arg = ctx.args[0];
|
|
2135
|
-
if (!arg)
|
|
2136
|
-
throw new Error('Usage: monomind browse ischecked @e1|".selector"');
|
|
2137
|
-
const objectId = await resolveElementObjectId(client, sessionId, _refs, arg);
|
|
2138
|
-
const r = await client.send('Runtime.callFunctionOn', {
|
|
2139
|
-
functionDeclaration: `function(){var el=this,tag=el.tagName&&el.tagName.toUpperCase();if(tag==='INPUT'&&(el.type==='checkbox'||el.type==='radio'))return el.checked;var role=el.getAttribute&&el.getAttribute('role');if(role&&['checkbox','radio','switch','menuitemcheckbox','menuitemradio','option','treeitem'].indexOf(role)!==-1)return el.getAttribute('aria-checked')==='true';var label=tag!=='LABEL'?el.closest&&el.closest('label'):el;if(label&&label.control&&(label.control.type==='checkbox'||label.control.type==='radio'))return label.control.checked;var inp=el.querySelector&&el.querySelector('input[type="checkbox"],input[type="radio"]');return inp?inp.checked:false;}`,
|
|
2140
|
-
objectId,
|
|
2141
|
-
returnByValue: true,
|
|
2142
|
-
}, sessionId);
|
|
2143
|
-
const checked = r.result?.value ?? false;
|
|
2144
|
-
if (ctx.flags.json) {
|
|
2145
|
-
print(JSON.stringify({ checked }));
|
|
2146
|
-
}
|
|
2147
|
-
else {
|
|
2148
|
-
output.printSuccess(`ischecked: ${checked}`);
|
|
2149
|
-
}
|
|
2150
|
-
return { success: true, data: { checked } };
|
|
2151
|
-
},
|
|
2152
|
-
};
|
|
2153
|
-
const tapCommand = {
|
|
2154
|
-
name: 'tap',
|
|
2155
|
-
description: 'Tap element with a touch event (mobile testing). Usage: monomind browse tap @e1|"selector"',
|
|
2156
|
-
action: async (ctx) => {
|
|
2157
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2158
|
-
const browser = await getBrowser();
|
|
2159
|
-
const arg = ctx.args[0];
|
|
2160
|
-
if (!arg)
|
|
2161
|
-
throw new Error('Usage: monomind browse tap @e1|".selector"');
|
|
2162
|
-
// Get element center position
|
|
2163
|
-
let x, y;
|
|
2164
|
-
if (arg.startsWith('@') || /^e\d+$/.test(arg)) {
|
|
2165
|
-
const key = arg.startsWith('@') ? arg.slice(1) : arg;
|
|
2166
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, key);
|
|
2167
|
-
const box = await browser.getElementBox(client, sessionId, ref);
|
|
2168
|
-
if (!box)
|
|
2169
|
-
throw new Error(`Cannot get bounds for @${key}`);
|
|
2170
|
-
// box.x/box.y from getElementBox() ARE already the center point (see
|
|
2171
|
-
// its own doc comment) — adding width/2 here double-offset the tap
|
|
2172
|
-
// target away from the element (issue #15).
|
|
2173
|
-
x = Math.round(box.x);
|
|
2174
|
-
y = Math.round(box.y);
|
|
2175
|
-
}
|
|
2176
|
-
else {
|
|
2177
|
-
const posJson = (await browser.evaluateJs(client, sessionId, `(function(){var el=document.querySelector(${JSON.stringify(arg)});if(!el)return null;var r=el.getBoundingClientRect();return JSON.stringify({x:r.left+r.width/2,y:r.top+r.height/2});})()`));
|
|
2178
|
-
if (!posJson)
|
|
2179
|
-
throw new Error(`Selector not found: ${arg}`);
|
|
2180
|
-
const pos = JSON.parse(posJson);
|
|
2181
|
-
x = Math.round(pos.x);
|
|
2182
|
-
y = Math.round(pos.y);
|
|
2183
|
-
}
|
|
2184
|
-
await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] }, sessionId);
|
|
2185
|
-
await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }, sessionId);
|
|
2186
|
-
output.printSuccess(`Tapped at (${x}, ${y})`);
|
|
2187
|
-
return { success: true, data: { x, y } };
|
|
2188
|
-
},
|
|
2189
|
-
};
|
|
2190
|
-
const swipeCommand = {
|
|
2191
|
-
name: 'swipe',
|
|
2192
|
-
description: 'Swipe gesture (mobile). Usage: monomind browse swipe up|down|left|right [distance] [--x N] [--y N]',
|
|
2193
|
-
options: [
|
|
2194
|
-
{
|
|
2195
|
-
name: 'x',
|
|
2196
|
-
type: 'number',
|
|
2197
|
-
description: 'Start X coordinate (default: center)',
|
|
2198
|
-
default: 200,
|
|
2199
|
-
},
|
|
2200
|
-
{
|
|
2201
|
-
name: 'y',
|
|
2202
|
-
type: 'number',
|
|
2203
|
-
description: 'Start Y coordinate (default: center)',
|
|
2204
|
-
default: 400,
|
|
2205
|
-
},
|
|
2206
|
-
{
|
|
2207
|
-
name: 'distance',
|
|
2208
|
-
short: 'd',
|
|
2209
|
-
type: 'number',
|
|
2210
|
-
description: 'Swipe distance in pixels',
|
|
2211
|
-
default: 300,
|
|
2212
|
-
},
|
|
2213
|
-
],
|
|
2214
|
-
action: async (ctx) => {
|
|
2215
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2216
|
-
const direction = ctx.args[0];
|
|
2217
|
-
if (!['up', 'down', 'left', 'right'].includes(direction)) {
|
|
2218
|
-
throw new Error('Usage: monomind browse swipe up|down|left|right [--x N] [--y N] [--distance N]');
|
|
2219
|
-
}
|
|
2220
|
-
const startX = ctx.flags.x ?? 200;
|
|
2221
|
-
const startY = ctx.flags.y ?? 400;
|
|
2222
|
-
const positionalDistance = ctx.args[1] !== undefined ? parseInt(ctx.args[1], 10) : undefined;
|
|
2223
|
-
const distance = positionalDistance && Number.isFinite(positionalDistance)
|
|
2224
|
-
? positionalDistance
|
|
2225
|
-
: (ctx.flags.distance ?? 300);
|
|
2226
|
-
const dx = direction === 'right' ? distance : direction === 'left' ? -distance : 0;
|
|
2227
|
-
const dy = direction === 'down' ? distance : direction === 'up' ? -distance : 0;
|
|
2228
|
-
await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: startX, y: startY }] }, sessionId);
|
|
2229
|
-
const steps = 10;
|
|
2230
|
-
for (let i = 1; i <= steps; i++) {
|
|
2231
|
-
const x = Math.round(startX + (dx * i) / steps);
|
|
2232
|
-
const y = Math.round(startY + (dy * i) / steps);
|
|
2233
|
-
await client.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y }] }, sessionId);
|
|
2234
|
-
await new Promise((r) => setTimeout(r, 16));
|
|
2235
|
-
}
|
|
2236
|
-
await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }, sessionId);
|
|
2237
|
-
output.printSuccess(`Swiped ${direction} ${distance}px from (${startX},${startY})`);
|
|
2238
|
-
return { success: true, data: { direction, distance, startX, startY } };
|
|
2239
|
-
},
|
|
2240
|
-
};
|
|
2241
|
-
const scrollIntoViewCommand = {
|
|
2242
|
-
name: 'scrollintoview',
|
|
2243
|
-
description: 'Scroll element into view. Usage: monomind browse scrollintoview @e1',
|
|
2244
|
-
action: async (ctx) => {
|
|
2245
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2246
|
-
const browser = await getBrowser();
|
|
2247
|
-
const refArg = ctx.args[0];
|
|
2248
|
-
if (!refArg)
|
|
2249
|
-
throw new Error('Usage: monomind browse scrollintoview @e1');
|
|
2250
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
2251
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
2252
|
-
await browser.scrollIntoView(client, sessionId, ref);
|
|
2253
|
-
output.printSuccess(`Scrolled into view: ${ref.role} "${ref.name}"`);
|
|
2254
|
-
return { success: true };
|
|
2255
|
-
},
|
|
2256
|
-
};
|
|
2257
|
-
const dragCommand = {
|
|
2258
|
-
name: 'drag',
|
|
2259
|
-
description: 'Drag element to another element. Usage: monomind browse drag @e1 @e2',
|
|
2260
|
-
action: async (ctx) => {
|
|
2261
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2262
|
-
const browser = await getBrowser();
|
|
2263
|
-
const srcArg = ctx.args[0];
|
|
2264
|
-
const tgtArg = ctx.args[1];
|
|
2265
|
-
if (!srcArg || !tgtArg)
|
|
2266
|
-
throw new Error('Usage: monomind browse drag @e1 @e2');
|
|
2267
|
-
const srcKey = srcArg.startsWith('@') ? srcArg.slice(1) : srcArg;
|
|
2268
|
-
const tgtKey = tgtArg.startsWith('@') ? tgtArg.slice(1) : tgtArg;
|
|
2269
|
-
const src = await browser.resolveRef(client, sessionId, _refs, srcKey);
|
|
2270
|
-
const tgt = await browser.resolveRef(client, sessionId, _refs, tgtKey);
|
|
2271
|
-
await browser.dragAndDrop(client, sessionId, src, tgt);
|
|
2272
|
-
output.printSuccess(`Dragged @${srcKey} to @${tgtKey}`);
|
|
2273
|
-
return { success: true };
|
|
2274
|
-
},
|
|
2275
|
-
};
|
|
2276
|
-
const uploadCommand = {
|
|
2277
|
-
name: 'upload',
|
|
2278
|
-
description: 'Upload files to a file input. Usage: monomind browse upload @e1 ./file.pdf',
|
|
2279
|
-
action: async (ctx) => {
|
|
2280
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2281
|
-
const browser = await getBrowser();
|
|
2282
|
-
const refArg = ctx.args[0];
|
|
2283
|
-
const files = ctx.args.slice(1);
|
|
2284
|
-
if (!refArg || files.length === 0)
|
|
2285
|
-
throw new Error('Usage: monomind browse upload @e1 <file1> [file2...]');
|
|
2286
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
2287
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
2288
|
-
await browser.uploadFile(client, sessionId, ref, files);
|
|
2289
|
-
output.printSuccess(`Uploaded ${files.length} file(s) to @${refKey}`);
|
|
2290
|
-
return { success: true };
|
|
2291
|
-
},
|
|
2292
|
-
};
|
|
2293
|
-
const downloadCommand = {
|
|
2294
|
-
name: 'download',
|
|
2295
|
-
description: 'Click an element and capture the triggered file download. Usage: monomind browse download @e1 ./output.pdf',
|
|
2296
|
-
options: [
|
|
2297
|
-
{
|
|
2298
|
-
name: 'timeout',
|
|
2299
|
-
short: 't',
|
|
2300
|
-
type: 'number',
|
|
2301
|
-
description: 'Max wait for download in ms',
|
|
2302
|
-
default: 30000,
|
|
2303
|
-
},
|
|
2304
|
-
{ name: 'json', type: 'boolean', description: 'Output result as JSON', default: false },
|
|
2305
|
-
],
|
|
2306
|
-
action: async (ctx) => {
|
|
2307
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2308
|
-
const _browser = await getBrowser();
|
|
2309
|
-
const refOrSel = ctx.args[0];
|
|
2310
|
-
const savePath = ctx.args[1];
|
|
2311
|
-
if (!refOrSel || !savePath)
|
|
2312
|
-
throw new Error('Usage: monomind browse download @e1|selector <save-path>');
|
|
2313
|
-
const { mkdir } = await import('node:fs/promises');
|
|
2314
|
-
const { dirname } = await import('node:path');
|
|
2315
|
-
const { tmpdir } = await import('node:os');
|
|
2316
|
-
const { join } = await import('node:path');
|
|
2317
|
-
const downloadDir = join(tmpdir(), `mm-download-${Date.now()}`);
|
|
2318
|
-
await mkdir(downloadDir, { recursive: true });
|
|
2319
|
-
// Enable Page.downloadWillBegin / Page.downloadProgress events
|
|
2320
|
-
await client
|
|
2321
|
-
.send('Browser.setDownloadBehavior', {
|
|
2322
|
-
behavior: 'allow',
|
|
2323
|
-
downloadPath: downloadDir,
|
|
2324
|
-
eventsEnabled: true,
|
|
2325
|
-
}, undefined)
|
|
2326
|
-
.catch(() => {
|
|
2327
|
-
// Fallback: older Chrome API (session-scoped)
|
|
2328
|
-
return client
|
|
2329
|
-
.send('Page.setDownloadBehavior', {
|
|
2330
|
-
behavior: 'allow',
|
|
2331
|
-
downloadPath: downloadDir,
|
|
2332
|
-
}, sessionId)
|
|
2333
|
-
.catch(() => { });
|
|
2334
|
-
});
|
|
2335
|
-
// Track when download completes
|
|
2336
|
-
const downloadPromise = new Promise((resolve, reject) => {
|
|
2337
|
-
let guid = '';
|
|
2338
|
-
// C2: capture off() functions to avoid listener leaks in batch mode
|
|
2339
|
-
const offBegin = client.on('Browser.downloadWillBegin', (params) => {
|
|
2340
|
-
guid = params.guid;
|
|
2341
|
-
});
|
|
2342
|
-
let offProgress;
|
|
2343
|
-
// cleanup defined before setTimeout so the timeout callback can call it
|
|
2344
|
-
let timeout;
|
|
2345
|
-
const cleanup = () => {
|
|
2346
|
-
clearTimeout(timeout);
|
|
2347
|
-
offBegin?.();
|
|
2348
|
-
offProgress?.();
|
|
2349
|
-
};
|
|
2350
|
-
timeout = setTimeout(() => {
|
|
2351
|
-
cleanup();
|
|
2352
|
-
reject(new Error('Download timed out'));
|
|
2353
|
-
}, ctx.flags.timeout);
|
|
2354
|
-
offProgress = client.on('Browser.downloadProgress', async (params) => {
|
|
2355
|
-
if (params.guid === guid && params.state === 'completed') {
|
|
2356
|
-
cleanup();
|
|
2357
|
-
// Find the downloaded file in downloadDir
|
|
2358
|
-
const { readdir, rename, rmdir } = await import('node:fs/promises');
|
|
2359
|
-
const files = await readdir(downloadDir);
|
|
2360
|
-
if (files.length > 0) {
|
|
2361
|
-
const src = join(downloadDir, files[0]);
|
|
2362
|
-
await mkdir(dirname(savePath), { recursive: true });
|
|
2363
|
-
await rename(src, savePath);
|
|
2364
|
-
await rmdir(downloadDir).catch(() => { }); // I1: cleanup temp dir
|
|
2365
|
-
resolve(savePath);
|
|
2366
|
-
}
|
|
2367
|
-
else {
|
|
2368
|
-
await rmdir(downloadDir).catch(() => { }); // I1: cleanup temp dir
|
|
2369
|
-
reject(new Error('Download completed but no file found'));
|
|
2370
|
-
}
|
|
2371
|
-
}
|
|
2372
|
-
else if (params.guid === guid && params.state === 'canceled') {
|
|
2373
|
-
cleanup();
|
|
2374
|
-
reject(new Error('Download was canceled'));
|
|
2375
|
-
}
|
|
2376
|
-
});
|
|
2377
|
-
});
|
|
2378
|
-
// Click the element to trigger download
|
|
2379
|
-
const objectId = await resolveElementObjectId(client, sessionId, _refs, refOrSel);
|
|
2380
|
-
await client.send('Runtime.callFunctionOn', {
|
|
2381
|
-
functionDeclaration: 'function(){ this.click(); }',
|
|
2382
|
-
objectId,
|
|
2383
|
-
returnByValue: true,
|
|
2384
|
-
}, sessionId);
|
|
2385
|
-
const finalPath = await downloadPromise;
|
|
2386
|
-
if (ctx.flags.json)
|
|
2387
|
-
print(JSON.stringify({ data: { path: finalPath } }));
|
|
2388
|
-
else
|
|
2389
|
-
output.printSuccess(`Downloaded: ${finalPath}`);
|
|
2390
|
-
return { success: true, data: { path: finalPath } };
|
|
2391
|
-
},
|
|
2392
|
-
};
|
|
2393
|
-
const mouseCommand = {
|
|
2394
|
-
name: 'mouse',
|
|
2395
|
-
description: 'Fine-grained mouse control. Usage: monomind browse mouse move|down|up|wheel <args>',
|
|
2396
|
-
options: [
|
|
2397
|
-
{ name: 'button', type: 'string', description: 'Button: left|right|middle', default: 'left' },
|
|
2398
|
-
],
|
|
2399
|
-
action: async (ctx) => {
|
|
2400
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2401
|
-
const browser = await getBrowser();
|
|
2402
|
-
const action = ctx.args[0];
|
|
2403
|
-
switch (action) {
|
|
2404
|
-
case 'move': {
|
|
2405
|
-
const x = parseFloat(ctx.args[1]);
|
|
2406
|
-
const y = parseFloat(ctx.args[2]);
|
|
2407
|
-
await browser.mouseMove(client, sessionId, x, y);
|
|
2408
|
-
output.printSuccess(`Mouse moved to (${x}, ${y})`);
|
|
2409
|
-
break;
|
|
2410
|
-
}
|
|
2411
|
-
case 'down': {
|
|
2412
|
-
const x = parseFloat(ctx.args[1]) || 0;
|
|
2413
|
-
const y = parseFloat(ctx.args[2]) || 0;
|
|
2414
|
-
const button = ctx.flags.button ?? 'left';
|
|
2415
|
-
await browser.mouseDown(client, sessionId, x, y, button);
|
|
2416
|
-
output.printSuccess(`Mouse down at (${x}, ${y})`);
|
|
2417
|
-
break;
|
|
2418
|
-
}
|
|
2419
|
-
case 'up': {
|
|
2420
|
-
const x = parseFloat(ctx.args[1]) || 0;
|
|
2421
|
-
const y = parseFloat(ctx.args[2]) || 0;
|
|
2422
|
-
const button = ctx.flags.button ?? 'left';
|
|
2423
|
-
await browser.mouseUp(client, sessionId, x, y, button);
|
|
2424
|
-
output.printSuccess(`Mouse up at (${x}, ${y})`);
|
|
2425
|
-
break;
|
|
2426
|
-
}
|
|
2427
|
-
case 'wheel': {
|
|
2428
|
-
const x = parseFloat(ctx.args[1]) || 0;
|
|
2429
|
-
const y = parseFloat(ctx.args[2]) || 0;
|
|
2430
|
-
const dy = parseFloat(ctx.args[3]) || 0;
|
|
2431
|
-
const dx = parseFloat(ctx.args[4]) || 0;
|
|
2432
|
-
await browser.mouseWheel(client, sessionId, x, y, dy, dx);
|
|
2433
|
-
output.printSuccess(`Mouse wheel (${dx}, ${dy})`);
|
|
2434
|
-
break;
|
|
2435
|
-
}
|
|
2436
|
-
default:
|
|
2437
|
-
throw new Error('Usage: monomind browse mouse move|down|up|wheel <args>');
|
|
2438
|
-
}
|
|
2439
|
-
return { success: true };
|
|
2440
|
-
},
|
|
2441
|
-
};
|
|
2442
|
-
const clipboardCommand = {
|
|
2443
|
-
name: 'clipboard',
|
|
2444
|
-
description: 'Clipboard operations. Usage: monomind browse clipboard read|write|copy|paste',
|
|
2445
|
-
action: async (ctx) => {
|
|
2446
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2447
|
-
const browser = await getBrowser();
|
|
2448
|
-
const action = ctx.args[0];
|
|
2449
|
-
switch (action) {
|
|
2450
|
-
case 'read': {
|
|
2451
|
-
const text = await browser.readClipboard(client, sessionId);
|
|
2452
|
-
print(text);
|
|
2453
|
-
return { success: true, data: { text } };
|
|
2454
|
-
}
|
|
2455
|
-
case 'write': {
|
|
2456
|
-
const text = ctx.args[1];
|
|
2457
|
-
if (!text)
|
|
2458
|
-
throw new Error('Usage: monomind browse clipboard write "text"');
|
|
2459
|
-
await browser.writeClipboard(client, sessionId, text);
|
|
2460
|
-
output.printSuccess('Clipboard written');
|
|
2461
|
-
break;
|
|
2462
|
-
}
|
|
2463
|
-
case 'copy': {
|
|
2464
|
-
const mod = process.platform === 'darwin' ? 4 : 2; // Meta/Cmd on macOS, Ctrl elsewhere
|
|
2465
|
-
await browser.pressKeyCombo(client, sessionId, 'c', mod);
|
|
2466
|
-
output.printSuccess('Copy sent');
|
|
2467
|
-
break;
|
|
2468
|
-
}
|
|
2469
|
-
case 'paste': {
|
|
2470
|
-
const mod = process.platform === 'darwin' ? 4 : 2;
|
|
2471
|
-
await browser.pressKeyCombo(client, sessionId, 'v', mod);
|
|
2472
|
-
output.printSuccess('Paste sent');
|
|
2473
|
-
break;
|
|
2474
|
-
}
|
|
2475
|
-
default:
|
|
2476
|
-
throw new Error('Usage: monomind browse clipboard read|write|copy|paste');
|
|
2477
|
-
}
|
|
2478
|
-
return { success: true };
|
|
2479
|
-
},
|
|
2480
|
-
};
|
|
2481
|
-
const dialogCommand = {
|
|
2482
|
-
name: 'dialog',
|
|
2483
|
-
description: 'Handle browser dialogs. Usage: monomind browse dialog accept|dismiss|status',
|
|
2484
|
-
action: async (ctx) => {
|
|
2485
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2486
|
-
const browser = await getBrowser();
|
|
2487
|
-
const action = ctx.args[0];
|
|
2488
|
-
switch (action) {
|
|
2489
|
-
case 'accept': {
|
|
2490
|
-
const text = ctx.args[1];
|
|
2491
|
-
await browser.acceptDialog(client, sessionId, text);
|
|
2492
|
-
output.printSuccess('Dialog accepted');
|
|
2493
|
-
break;
|
|
2494
|
-
}
|
|
2495
|
-
case 'dismiss':
|
|
2496
|
-
await browser.dismissDialog(client, sessionId);
|
|
2497
|
-
output.printSuccess('Dialog dismissed');
|
|
2498
|
-
break;
|
|
2499
|
-
case 'status': {
|
|
2500
|
-
const info = browser.getDialogStatus(sessionId);
|
|
2501
|
-
if (info) {
|
|
2502
|
-
print(`Dialog open: type=${info.type} message="${info.message}"`);
|
|
2503
|
-
}
|
|
2504
|
-
else {
|
|
2505
|
-
print('No dialog open');
|
|
2506
|
-
}
|
|
2507
|
-
return { success: true, data: { dialog: info } };
|
|
2508
|
-
}
|
|
2509
|
-
default:
|
|
2510
|
-
throw new Error('Usage: monomind browse dialog accept|dismiss|status');
|
|
2511
|
-
}
|
|
2512
|
-
return { success: true };
|
|
2513
|
-
},
|
|
2514
|
-
};
|
|
2515
|
-
const frameCommand = {
|
|
2516
|
-
name: 'frame',
|
|
2517
|
-
description: 'Switch to iframe or back to main. Usage: monomind browse frame "#frame-id" | frame main',
|
|
2518
|
-
action: async (ctx) => {
|
|
2519
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2520
|
-
const browser = await getBrowser();
|
|
2521
|
-
const target = ctx.args[0];
|
|
2522
|
-
if (!target)
|
|
2523
|
-
throw new Error('Usage: monomind browse frame <selector>|main');
|
|
2524
|
-
if (target === 'main') {
|
|
2525
|
-
if (_parentSessionId) {
|
|
2526
|
-
// Detach from the OOPIF session (best-effort — it may already be gone)
|
|
2527
|
-
await client.send('Target.detachFromTarget', { sessionId: _sessionId }).catch(() => { });
|
|
2528
|
-
_sessionId = _parentSessionId;
|
|
2529
|
-
_parentSessionId = '';
|
|
2530
|
-
}
|
|
2531
|
-
output.printSuccess('Switched to main frame');
|
|
2532
|
-
}
|
|
2533
|
-
else {
|
|
2534
|
-
const frameResult = await browser.switchToFrame(client, sessionId, target);
|
|
2535
|
-
if (frameResult.sessionId) {
|
|
2536
|
-
// Save the parent session so `frame main` can restore it
|
|
2537
|
-
_parentSessionId = _sessionId;
|
|
2538
|
-
_sessionId = frameResult.sessionId;
|
|
2539
|
-
// Enable CDP domains on the iframe's session so subsequent commands work
|
|
2540
|
-
await browser.enableSessionDomains(client, _sessionId);
|
|
2541
|
-
}
|
|
2542
|
-
output.printSuccess(`Switched to frame: ${frameResult.url ?? target}`);
|
|
2543
|
-
}
|
|
2544
|
-
return { success: true };
|
|
2545
|
-
},
|
|
2546
|
-
};
|
|
2547
|
-
const tabCommand = {
|
|
2548
|
-
name: 'tab',
|
|
2549
|
-
description: 'Tab management. Usage: monomind browse tab list|new|close [url]',
|
|
2550
|
-
options: [{ name: 'label', type: 'string', description: 'Label for new tab' }],
|
|
2551
|
-
action: async (ctx) => {
|
|
2552
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2553
|
-
const browser = await getBrowser();
|
|
2554
|
-
const action = ctx.args[0];
|
|
2555
|
-
switch (action ?? 'list') {
|
|
2556
|
-
case 'list': {
|
|
2557
|
-
const tabs = await browser.listTabs(_port);
|
|
2558
|
-
for (const t of tabs)
|
|
2559
|
-
print(` ${t.id}: ${t.title} (${t.url})`);
|
|
2560
|
-
return { success: true, data: { tabs } };
|
|
2561
|
-
}
|
|
2562
|
-
case 'new': {
|
|
2563
|
-
const url = ctx.args[1];
|
|
2564
|
-
const tab = await browser.newTab(_port, url);
|
|
2565
|
-
output.printSuccess(`New tab: ${tab.id} ${url ?? ''}`);
|
|
2566
|
-
return { success: true, data: { tab } };
|
|
2567
|
-
}
|
|
2568
|
-
case 'close': {
|
|
2569
|
-
const tabId = ctx.args[1];
|
|
2570
|
-
if (!tabId)
|
|
2571
|
-
throw new Error('Usage: monomind browse tab close <tabId>');
|
|
2572
|
-
if (tabId === _targetId) {
|
|
2573
|
-
const sid = _sessionId;
|
|
2574
|
-
// Stop profiling before closing the tab so CDP commands still reach the live session
|
|
2575
|
-
if (browser.getHarStatus(sid).recording) {
|
|
2576
|
-
try {
|
|
2577
|
-
await browser.stopHarRecording(client, sid);
|
|
2578
|
-
}
|
|
2579
|
-
catch {
|
|
2580
|
-
/* ignore */
|
|
2581
|
-
}
|
|
2582
|
-
}
|
|
2583
|
-
if (browser.getTraceStatus(sid)) {
|
|
2584
|
-
try {
|
|
2585
|
-
await browser.stopTrace(client, sid);
|
|
2586
|
-
}
|
|
2587
|
-
catch {
|
|
2588
|
-
/* ignore */
|
|
2589
|
-
}
|
|
2590
|
-
}
|
|
2591
|
-
if (browser.isProfilingActive(sid)) {
|
|
2592
|
-
try {
|
|
2593
|
-
await browser.stopCpuProfile(client, sid);
|
|
2594
|
-
}
|
|
2595
|
-
catch {
|
|
2596
|
-
/* ignore */
|
|
2597
|
-
}
|
|
2598
|
-
}
|
|
2599
|
-
browser.teardownRouteInterception(sid);
|
|
2600
|
-
browser.stopRequestCapture(sid);
|
|
2601
|
-
browser.teardownDialogHandling(sid);
|
|
2602
|
-
browser.teardownConsoleCapture(sid);
|
|
2603
|
-
await browser.closeTab(client, sessionId, tabId);
|
|
2604
|
-
client.close();
|
|
2605
|
-
_client = null;
|
|
2606
|
-
_sessionId = '';
|
|
2607
|
-
_parentSessionId = '';
|
|
2608
|
-
_targetId = '';
|
|
2609
|
-
_refs = new Map();
|
|
2610
|
-
}
|
|
2611
|
-
else {
|
|
2612
|
-
await browser.closeTab(client, sessionId, tabId);
|
|
2613
|
-
}
|
|
2614
|
-
output.printSuccess(`Closed tab: ${tabId}`);
|
|
2615
|
-
break;
|
|
2616
|
-
}
|
|
2617
|
-
default: {
|
|
2618
|
-
// Attach to new tab FIRST — only tear down old session if that succeeds
|
|
2619
|
-
const newSid = await browser.activateTab(client, sessionId, action);
|
|
2620
|
-
const oldSid = _sessionId;
|
|
2621
|
-
if (browser.getHarStatus(oldSid).recording) {
|
|
2622
|
-
try {
|
|
2623
|
-
await browser.stopHarRecording(client, oldSid);
|
|
2624
|
-
}
|
|
2625
|
-
catch {
|
|
2626
|
-
/* ignore */
|
|
2627
|
-
}
|
|
2628
|
-
}
|
|
2629
|
-
if (browser.getTraceStatus(oldSid)) {
|
|
2630
|
-
try {
|
|
2631
|
-
await browser.stopTrace(client, oldSid);
|
|
2632
|
-
}
|
|
2633
|
-
catch {
|
|
2634
|
-
/* ignore */
|
|
2635
|
-
}
|
|
2636
|
-
}
|
|
2637
|
-
if (browser.isProfilingActive(oldSid)) {
|
|
2638
|
-
try {
|
|
2639
|
-
await browser.stopCpuProfile(client, oldSid);
|
|
2640
|
-
}
|
|
2641
|
-
catch {
|
|
2642
|
-
/* ignore */
|
|
2643
|
-
}
|
|
2644
|
-
}
|
|
2645
|
-
await browser.disableInterception(client, oldSid).catch(() => { });
|
|
2646
|
-
browser.stopRequestCapture(oldSid);
|
|
2647
|
-
browser.teardownDialogHandling(oldSid);
|
|
2648
|
-
browser.teardownConsoleCapture(oldSid);
|
|
2649
|
-
_sessionId = newSid;
|
|
2650
|
-
_targetId = action;
|
|
2651
|
-
_refs = new Map();
|
|
2652
|
-
await browser.enableSessionDomains(client, _sessionId);
|
|
2653
|
-
output.printSuccess(`Switched to tab: ${action}`);
|
|
2654
|
-
}
|
|
2655
|
-
}
|
|
2656
|
-
return { success: true };
|
|
2657
|
-
},
|
|
2658
|
-
};
|
|
2659
|
-
const windowCommand = {
|
|
2660
|
-
name: 'window',
|
|
2661
|
-
description: 'Browser window management. Usage: monomind browse window new [url]',
|
|
2662
|
-
action: async (ctx) => {
|
|
2663
|
-
const { client, sessionId: _sid } = await ensureConnected(_port);
|
|
2664
|
-
const browser = await getBrowser();
|
|
2665
|
-
const action = ctx.args[0];
|
|
2666
|
-
if (!action || action === 'new') {
|
|
2667
|
-
// Create isolated browser context (incognito-like) with a fresh page
|
|
2668
|
-
const ctxResult = await client.send('Target.createBrowserContext', {}, undefined);
|
|
2669
|
-
const browserContextId = ctxResult.browserContextId;
|
|
2670
|
-
const url = ctx.args[1] || 'about:blank';
|
|
2671
|
-
const targetResult = await client.send('Target.createTarget', { url, browserContextId }, undefined);
|
|
2672
|
-
const targetId = targetResult.targetId;
|
|
2673
|
-
const attachResult = await client.send('Target.attachToTarget', { targetId, flatten: true }, undefined);
|
|
2674
|
-
const newSessionId = attachResult.sessionId;
|
|
2675
|
-
// W3: fully tear down old session before switching
|
|
2676
|
-
const oldSid = _sessionId;
|
|
2677
|
-
if (browser.getHarStatus(oldSid).recording) {
|
|
2678
|
-
try {
|
|
2679
|
-
await browser.stopHarRecording(client, oldSid);
|
|
2680
|
-
}
|
|
2681
|
-
catch {
|
|
2682
|
-
/* ignore */
|
|
2683
|
-
}
|
|
2684
|
-
}
|
|
2685
|
-
if (browser.getTraceStatus(oldSid)) {
|
|
2686
|
-
try {
|
|
2687
|
-
await browser.stopTrace(client, oldSid);
|
|
2688
|
-
}
|
|
2689
|
-
catch {
|
|
2690
|
-
/* ignore */
|
|
2691
|
-
}
|
|
2692
|
-
}
|
|
2693
|
-
if (browser.isProfilingActive(oldSid)) {
|
|
2694
|
-
try {
|
|
2695
|
-
await browser.stopCpuProfile(client, oldSid);
|
|
2696
|
-
}
|
|
2697
|
-
catch {
|
|
2698
|
-
/* ignore */
|
|
2699
|
-
}
|
|
2700
|
-
}
|
|
2701
|
-
browser.teardownRouteInterception(oldSid);
|
|
2702
|
-
browser.stopRequestCapture(oldSid);
|
|
2703
|
-
browser.teardownDialogHandling(oldSid);
|
|
2704
|
-
browser.teardownConsoleCapture(oldSid);
|
|
2705
|
-
_sessionId = newSessionId;
|
|
2706
|
-
_targetId = targetId;
|
|
2707
|
-
_refs = new Map();
|
|
2708
|
-
await browser.enableSessionDomains(client, _sessionId);
|
|
2709
|
-
output.printSuccess(`Opened new window (isolated context): ${targetId} [${url}]`);
|
|
2710
|
-
return { success: true, data: { targetId, browserContextId, sessionId: newSessionId } };
|
|
2711
|
-
}
|
|
2712
|
-
throw new Error(`Unknown window action: ${action}. Use: new`);
|
|
2713
|
-
},
|
|
2714
|
-
};
|
|
2715
|
-
const consoleLogCommand = {
|
|
2716
|
-
name: 'console',
|
|
2717
|
-
description: 'View captured console messages. Usage: monomind browse console [--clear] [--json]',
|
|
2718
|
-
options: [
|
|
2719
|
-
{ name: 'clear', type: 'boolean', description: 'Clear console messages', default: false },
|
|
2720
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
2721
|
-
{ name: 'errors-only', type: 'boolean', description: 'Show only errors', default: false },
|
|
2722
|
-
],
|
|
2723
|
-
action: async (ctx) => {
|
|
2724
|
-
const browser = await getBrowser();
|
|
2725
|
-
if (ctx.flags.clear) {
|
|
2726
|
-
browser.clearConsoleMessages(_sessionId);
|
|
2727
|
-
output.printSuccess('Console cleared');
|
|
2728
|
-
return { success: true };
|
|
2729
|
-
}
|
|
2730
|
-
const allMsgs = browser.getConsoleMessages(_sessionId);
|
|
2731
|
-
const msgs = ctx.flags['errors-only'] ? allMsgs.filter((m) => m.type === 'error') : allMsgs;
|
|
2732
|
-
if (ctx.flags.json) {
|
|
2733
|
-
print(JSON.stringify(msgs));
|
|
2734
|
-
}
|
|
2735
|
-
else {
|
|
2736
|
-
for (const m of msgs) {
|
|
2737
|
-
const prefix = m.type === 'error' ? '[ERROR]' : m.type === 'warn' ? '[WARN]' : '[LOG]';
|
|
2738
|
-
print(`${prefix} ${m.text}`);
|
|
2739
|
-
}
|
|
2740
|
-
}
|
|
2741
|
-
return { success: true, data: { messages: msgs } };
|
|
2742
|
-
},
|
|
2743
|
-
};
|
|
2744
|
-
const errorsCommand = {
|
|
2745
|
-
name: 'errors',
|
|
2746
|
-
description: 'View page errors (uncaught JS exceptions). Usage: monomind browse errors [--clear]',
|
|
2747
|
-
options: [
|
|
2748
|
-
{ name: 'clear', type: 'boolean', description: 'Clear errors', default: false },
|
|
2749
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
2750
|
-
],
|
|
2751
|
-
action: async (ctx) => {
|
|
2752
|
-
const browser = await getBrowser();
|
|
2753
|
-
if (ctx.flags.clear) {
|
|
2754
|
-
browser.clearPageErrors(_sessionId);
|
|
2755
|
-
output.printSuccess('Errors cleared');
|
|
2756
|
-
return { success: true };
|
|
2757
|
-
}
|
|
2758
|
-
const errs = browser.getPageErrors(_sessionId);
|
|
2759
|
-
if (ctx.flags.json) {
|
|
2760
|
-
print(JSON.stringify(errs));
|
|
2761
|
-
}
|
|
2762
|
-
else if (errs.length === 0) {
|
|
2763
|
-
output.printSuccess('No page errors');
|
|
2764
|
-
}
|
|
2765
|
-
else {
|
|
2766
|
-
for (const e of errs)
|
|
2767
|
-
print(`[ERROR] ${e.text} (${e.url}:${e.lineNumber})`);
|
|
2768
|
-
}
|
|
2769
|
-
return { success: true, data: { errors: errs } };
|
|
2770
|
-
},
|
|
2771
|
-
};
|
|
2772
|
-
const storageCommand = {
|
|
2773
|
-
name: 'storage',
|
|
2774
|
-
description: 'localStorage/sessionStorage management. Usage: monomind browse storage local|session [key] [--set val] [--clear]',
|
|
2775
|
-
options: [
|
|
2776
|
-
{ name: 'set', type: 'string', description: 'Value to set for key' },
|
|
2777
|
-
{ name: 'clear', type: 'boolean', description: 'Clear all storage', default: false },
|
|
2778
|
-
{ name: 'remove', type: 'boolean', description: 'Remove a specific key', default: false },
|
|
2779
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
2780
|
-
],
|
|
2781
|
-
action: async (ctx) => {
|
|
2782
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2783
|
-
const browser = await getBrowser();
|
|
2784
|
-
const storageType = ctx.args[0];
|
|
2785
|
-
const key = ctx.args[1];
|
|
2786
|
-
if (!storageType)
|
|
2787
|
-
throw new Error('Usage: monomind browse storage local|session [key]');
|
|
2788
|
-
const isLocal = storageType === 'local';
|
|
2789
|
-
if (ctx.flags.clear) {
|
|
2790
|
-
if (isLocal)
|
|
2791
|
-
await browser.clearLocalStorage(client, sessionId);
|
|
2792
|
-
else
|
|
2793
|
-
await browser.clearSessionStorage(client, sessionId);
|
|
2794
|
-
output.printSuccess(`${storageType}Storage cleared`);
|
|
2795
|
-
return { success: true };
|
|
2796
|
-
}
|
|
2797
|
-
if (key && ctx.flags.set !== undefined) {
|
|
2798
|
-
if (isLocal)
|
|
2799
|
-
await browser.setLocalStorageKey(client, sessionId, key, ctx.flags.set);
|
|
2800
|
-
else
|
|
2801
|
-
await browser.setSessionStorageKey(client, sessionId, key, ctx.flags.set);
|
|
2802
|
-
output.printSuccess(`Set ${key}`);
|
|
2803
|
-
return { success: true };
|
|
2804
|
-
}
|
|
2805
|
-
if (key && ctx.flags.remove) {
|
|
2806
|
-
if (isLocal)
|
|
2807
|
-
await browser.removeLocalStorageKey(client, sessionId, key);
|
|
2808
|
-
else
|
|
2809
|
-
await browser.removeSessionStorageKey(client, sessionId, key);
|
|
2810
|
-
output.printSuccess(`Removed ${key}`);
|
|
2811
|
-
return { success: true };
|
|
2812
|
-
}
|
|
2813
|
-
if (key) {
|
|
2814
|
-
const val = isLocal
|
|
2815
|
-
? await browser.getLocalStorageKey(client, sessionId, key)
|
|
2816
|
-
: await browser.getSessionStorageKey(client, sessionId, key);
|
|
2817
|
-
if (ctx.flags.json)
|
|
2818
|
-
print(JSON.stringify({ data: val }));
|
|
2819
|
-
else
|
|
2820
|
-
print(val ?? '(null)');
|
|
2821
|
-
return { success: true, data: { value: val } };
|
|
2822
|
-
}
|
|
2823
|
-
const all = isLocal
|
|
2824
|
-
? await browser.getAllLocalStorage(client, sessionId)
|
|
2825
|
-
: await browser.getAllSessionStorage(client, sessionId);
|
|
2826
|
-
if (ctx.flags.json)
|
|
2827
|
-
print(JSON.stringify(all));
|
|
2828
|
-
else {
|
|
2829
|
-
for (const [k, v] of Object.entries(all))
|
|
2830
|
-
print(` ${k}: ${v}`);
|
|
2831
|
-
}
|
|
2832
|
-
return { success: true, data: { storage: all } };
|
|
2833
|
-
},
|
|
2834
|
-
};
|
|
2835
|
-
const cookiesCommand = {
|
|
2836
|
-
name: 'cookies',
|
|
2837
|
-
description: 'Cookie management. Usage: monomind browse cookies [list|set|clear]',
|
|
2838
|
-
options: [
|
|
2839
|
-
{ name: 'name', type: 'string', description: 'Cookie name' },
|
|
2840
|
-
{ name: 'value', type: 'string', description: 'Cookie value' },
|
|
2841
|
-
{ name: 'domain', type: 'string', description: 'Cookie domain' },
|
|
2842
|
-
{ name: 'curl', type: 'string', description: 'Import cookies from cURL dump file' },
|
|
2843
|
-
],
|
|
2844
|
-
action: async (ctx) => {
|
|
2845
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2846
|
-
const browser = await getBrowser();
|
|
2847
|
-
const action = ctx.args[0] ?? 'list';
|
|
2848
|
-
switch (action) {
|
|
2849
|
-
case 'list': {
|
|
2850
|
-
const cookies = await browser.getCookies(client, sessionId);
|
|
2851
|
-
print(JSON.stringify(cookies, null, 2));
|
|
2852
|
-
return { success: true, data: { cookies } };
|
|
2853
|
-
}
|
|
2854
|
-
case 'set': {
|
|
2855
|
-
// Support both: cookies set --name n --value v AND cookies set <name> <value>
|
|
2856
|
-
const name = ctx.flags.name ?? ctx.args[1];
|
|
2857
|
-
const value = ctx.flags.value ?? ctx.args[2];
|
|
2858
|
-
if (!name || value === undefined) {
|
|
2859
|
-
throw new Error('Usage: monomind browse cookies set <name> <value> [--domain <d>]');
|
|
2860
|
-
}
|
|
2861
|
-
await browser.setCookies(client, sessionId, [
|
|
2862
|
-
{
|
|
2863
|
-
name,
|
|
2864
|
-
value,
|
|
2865
|
-
domain: ctx.flags.domain,
|
|
2866
|
-
},
|
|
2867
|
-
]);
|
|
2868
|
-
output.printSuccess(`Cookie set: ${name}`);
|
|
2869
|
-
break;
|
|
2870
|
-
}
|
|
2871
|
-
case 'clear':
|
|
2872
|
-
await browser.clearCookies(client, sessionId);
|
|
2873
|
-
output.printSuccess('Cookies cleared');
|
|
2874
|
-
break;
|
|
2875
|
-
default:
|
|
2876
|
-
throw new Error('Usage: monomind browse cookies list|set|clear');
|
|
2877
|
-
}
|
|
2878
|
-
return { success: true };
|
|
2879
|
-
},
|
|
2880
|
-
};
|
|
2881
|
-
const pdfCommand = {
|
|
2882
|
-
name: 'pdf',
|
|
2883
|
-
description: 'Save page as PDF. Usage: monomind browse pdf [path]',
|
|
2884
|
-
options: [
|
|
2885
|
-
{ name: 'landscape', type: 'boolean', description: 'Landscape orientation', default: false },
|
|
2886
|
-
{ name: 'background', type: 'boolean', description: 'Print background', default: true },
|
|
2887
|
-
],
|
|
2888
|
-
action: async (ctx) => {
|
|
2889
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2890
|
-
const browser = await getBrowser();
|
|
2891
|
-
const path = await browser.capturePdf(client, sessionId, {
|
|
2892
|
-
path: ctx.args[0],
|
|
2893
|
-
landscape: ctx.flags.landscape,
|
|
2894
|
-
printBackground: ctx.flags.background,
|
|
2895
|
-
});
|
|
2896
|
-
output.printSuccess(`PDF saved: ${path}`);
|
|
2897
|
-
return { success: true, data: { path } };
|
|
2898
|
-
},
|
|
2899
|
-
};
|
|
2900
|
-
const isCommand = {
|
|
2901
|
-
name: 'is',
|
|
2902
|
-
description: 'Check element state. Usage: monomind browse is visible|enabled|checked @e1',
|
|
2903
|
-
options: [{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false }],
|
|
2904
|
-
action: async (ctx) => {
|
|
2905
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2906
|
-
const browser = await getBrowser();
|
|
2907
|
-
const check = ctx.args[0];
|
|
2908
|
-
const refArg = ctx.args[1];
|
|
2909
|
-
if (!check || !refArg)
|
|
2910
|
-
throw new Error('Usage: monomind browse is visible|enabled|checked @e1');
|
|
2911
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
2912
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
2913
|
-
let result;
|
|
2914
|
-
switch (check) {
|
|
2915
|
-
case 'visible':
|
|
2916
|
-
result = await browser.isVisible(client, sessionId, ref);
|
|
2917
|
-
break;
|
|
2918
|
-
case 'enabled':
|
|
2919
|
-
result = await browser.isEnabled(client, sessionId, ref);
|
|
2920
|
-
break;
|
|
2921
|
-
case 'checked':
|
|
2922
|
-
result = await browser.isChecked(client, sessionId, ref);
|
|
2923
|
-
break;
|
|
2924
|
-
default:
|
|
2925
|
-
throw new Error(`Unknown check: ${check}. Use: visible|enabled|checked`);
|
|
2926
|
-
}
|
|
2927
|
-
if (ctx.flags.json) {
|
|
2928
|
-
print(JSON.stringify({ data: { [check]: result } }));
|
|
2929
|
-
}
|
|
2930
|
-
else {
|
|
2931
|
-
print(result ? 'true' : 'false');
|
|
2932
|
-
}
|
|
2933
|
-
return { success: true, data: { [check]: result } };
|
|
2934
|
-
},
|
|
2935
|
-
};
|
|
2936
|
-
const findCommand = {
|
|
2937
|
-
name: 'find',
|
|
2938
|
-
description: 'Find elements by semantic locators. Usage: monomind browse find role|text|label|placeholder|testid|alttext|title|selector <value> [action]',
|
|
2939
|
-
options: [
|
|
2940
|
-
{ name: 'name', type: 'string', description: 'Filter by accessible name' },
|
|
2941
|
-
{ name: 'exact', type: 'boolean', description: 'Require exact match', default: false },
|
|
2942
|
-
{ name: 'nth', type: 'number', description: 'Find nth match' },
|
|
2943
|
-
{ name: 'last', type: 'boolean', description: 'Find last match', default: false },
|
|
2944
|
-
],
|
|
2945
|
-
action: async (ctx) => {
|
|
2946
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
2947
|
-
const browser = await getBrowser();
|
|
2948
|
-
const locator = ctx.args[0];
|
|
2949
|
-
const value = ctx.args[1];
|
|
2950
|
-
const action = ctx.args[2];
|
|
2951
|
-
if (!locator || !value)
|
|
2952
|
-
throw new Error('Usage: monomind browse find role|text|label|placeholder|testid|alttext|title|selector <value> [action]');
|
|
2953
|
-
const opts = {
|
|
2954
|
-
name: ctx.flags.name,
|
|
2955
|
-
exact: ctx.flags.exact,
|
|
2956
|
-
nth: ctx.flags.nth,
|
|
2957
|
-
last: ctx.flags.last,
|
|
2958
|
-
};
|
|
2959
|
-
// alttext: find element by alt attribute (images, icons)
|
|
2960
|
-
if (locator === 'alttext') {
|
|
2961
|
-
// I2: use JS attribute comparison to avoid broken CSS selectors for values with spaces/quotes
|
|
2962
|
-
const valJson = JSON.stringify(value);
|
|
2963
|
-
const found = (await browser.evaluateJs(client, sessionId, `(function(v){var el=document.querySelector('img[alt]')||null;var all=document.querySelectorAll('[alt]');for(var i=0;i<all.length;i++){if(all[i].getAttribute('alt')===v){all[i].setAttribute('data-mm-located','true');return true;}}return false;})(${valJson})`));
|
|
2964
|
-
if (!found) {
|
|
2965
|
-
output.printWarning(`alttext not found: ${value}`);
|
|
2966
|
-
return { success: false };
|
|
2967
|
-
}
|
|
2968
|
-
output.printSuccess(`Found element with alt="${value}"`);
|
|
2969
|
-
return { success: true, data: { alttext: value } };
|
|
2970
|
-
}
|
|
2971
|
-
// title: find element by title attribute
|
|
2972
|
-
if (locator === 'title') {
|
|
2973
|
-
// I2: use JS attribute comparison to avoid broken CSS selectors for values with spaces/quotes
|
|
2974
|
-
const valJson = JSON.stringify(value);
|
|
2975
|
-
const found = (await browser.evaluateJs(client, sessionId, `(function(v){var all=document.querySelectorAll('[title]');for(var i=0;i<all.length;i++){if(all[i].getAttribute('title')===v){all[i].setAttribute('data-mm-located','true');return true;}}return false;})(${valJson})`));
|
|
2976
|
-
if (!found) {
|
|
2977
|
-
output.printWarning(`title not found: ${value}`);
|
|
2978
|
-
return { success: false };
|
|
2979
|
-
}
|
|
2980
|
-
output.printSuccess(`Found element with title="${value}"`);
|
|
2981
|
-
return { success: true, data: { title: value } };
|
|
2982
|
-
}
|
|
2983
|
-
let ref = null;
|
|
2984
|
-
switch (locator) {
|
|
2985
|
-
case 'role':
|
|
2986
|
-
ref = await browser.findByRole(client, sessionId, _refs, value, opts);
|
|
2987
|
-
break;
|
|
2988
|
-
case 'text':
|
|
2989
|
-
ref = await browser.findByText(client, sessionId, _refs, value, opts);
|
|
2990
|
-
break;
|
|
2991
|
-
case 'label':
|
|
2992
|
-
ref = await browser.findByLabel(client, sessionId, _refs, value, opts);
|
|
2993
|
-
break;
|
|
2994
|
-
case 'placeholder':
|
|
2995
|
-
ref = await browser.findByPlaceholder(client, sessionId, _refs, value, opts);
|
|
2996
|
-
break;
|
|
2997
|
-
case 'selector':
|
|
2998
|
-
ref = await browser.findBySelector(client, sessionId, _refs, value, opts);
|
|
2999
|
-
break;
|
|
3000
|
-
case 'testid': {
|
|
3001
|
-
const sel = await browser.findByTestId(client, sessionId, value);
|
|
3002
|
-
if (!sel) {
|
|
3003
|
-
output.printWarning(`testid not found: ${value}`);
|
|
3004
|
-
return { success: false };
|
|
3005
|
-
}
|
|
3006
|
-
output.printSuccess(`Found testid selector: ${sel}`);
|
|
3007
|
-
return { success: true, data: { selector: sel } };
|
|
3008
|
-
}
|
|
3009
|
-
default:
|
|
3010
|
-
throw new Error(`Unknown locator: ${locator}. Use: role|text|label|placeholder|testid|alttext|title|selector`);
|
|
3011
|
-
}
|
|
3012
|
-
if (!ref) {
|
|
3013
|
-
output.printWarning(`No element found: ${locator}="${value}"`);
|
|
3014
|
-
return { success: false };
|
|
3015
|
-
}
|
|
3016
|
-
output.printSuccess(`Found: ${ref.role} "${ref.name}" [@${ref.ref}]`);
|
|
3017
|
-
if (action) {
|
|
3018
|
-
switch (action) {
|
|
3019
|
-
case 'click':
|
|
3020
|
-
await browser.clickElement(client, sessionId, ref);
|
|
3021
|
-
break;
|
|
3022
|
-
case 'fill': {
|
|
3023
|
-
const fillValue = ctx.args[3];
|
|
3024
|
-
await browser.fillElement(client, sessionId, ref, fillValue ?? '');
|
|
3025
|
-
break;
|
|
3026
|
-
}
|
|
3027
|
-
case 'type': {
|
|
3028
|
-
const typeValue = ctx.args[3];
|
|
3029
|
-
await browser.typeIntoElement(client, sessionId, ref, typeValue ?? '');
|
|
3030
|
-
break;
|
|
3031
|
-
}
|
|
3032
|
-
case 'hover':
|
|
3033
|
-
await browser.hoverElement(client, sessionId, ref);
|
|
3034
|
-
break;
|
|
3035
|
-
case 'focus':
|
|
3036
|
-
await browser.focusElement(client, sessionId, ref);
|
|
3037
|
-
break;
|
|
3038
|
-
case 'check':
|
|
3039
|
-
await browser.checkElement(client, sessionId, ref, true);
|
|
3040
|
-
break;
|
|
3041
|
-
case 'uncheck':
|
|
3042
|
-
await browser.checkElement(client, sessionId, ref, false);
|
|
3043
|
-
break;
|
|
3044
|
-
case 'text': {
|
|
3045
|
-
const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
|
|
3046
|
-
if (objectId) {
|
|
3047
|
-
const r = await client.send('Runtime.callFunctionOn', {
|
|
3048
|
-
functionDeclaration: 'function() { return this.innerText || this.textContent || ""; }',
|
|
3049
|
-
objectId,
|
|
3050
|
-
returnByValue: true,
|
|
3051
|
-
}, sessionId);
|
|
3052
|
-
print(r.result?.value ?? '');
|
|
3053
|
-
}
|
|
3054
|
-
break;
|
|
3055
|
-
}
|
|
3056
|
-
}
|
|
3057
|
-
}
|
|
3058
|
-
return { success: true, data: { ref } };
|
|
3059
|
-
},
|
|
3060
|
-
};
|
|
3061
|
-
const highlightCommand = {
|
|
3062
|
-
name: 'highlight',
|
|
3063
|
-
description: 'Highlight an element for 2 seconds. Usage: monomind browse highlight @e1',
|
|
3064
|
-
action: async (ctx) => {
|
|
3065
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
3066
|
-
const browser = await getBrowser();
|
|
3067
|
-
const refArg = ctx.args[0];
|
|
3068
|
-
if (!refArg)
|
|
3069
|
-
throw new Error('Usage: monomind browse highlight @e1');
|
|
3070
|
-
const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
|
|
3071
|
-
const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
|
|
3072
|
-
await browser.highlightElement(client, sessionId, ref);
|
|
3073
|
-
output.printSuccess(`Highlighted: ${ref.role} "${ref.name}"`);
|
|
3074
|
-
return { success: true };
|
|
3075
|
-
},
|
|
3076
|
-
};
|
|
3077
|
-
const diffCommand = {
|
|
3078
|
-
name: 'diff',
|
|
3079
|
-
description: 'Compare two URLs or snapshots. Usage: monomind browse diff url <url1> <url2> [--interactive] [--json]',
|
|
3080
|
-
options: [
|
|
3081
|
-
{
|
|
3082
|
-
name: 'interactive',
|
|
3083
|
-
short: 'i',
|
|
3084
|
-
type: 'boolean',
|
|
3085
|
-
description: 'Snapshot interactive elements only',
|
|
3086
|
-
default: false,
|
|
3087
|
-
},
|
|
3088
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
3089
|
-
],
|
|
3090
|
-
action: async (ctx) => {
|
|
3091
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
3092
|
-
const browser = await getBrowser();
|
|
3093
|
-
const subAction = ctx.args[0];
|
|
3094
|
-
if (subAction === 'url') {
|
|
3095
|
-
const url1 = ctx.args[1];
|
|
3096
|
-
const url2 = ctx.args[2];
|
|
3097
|
-
if (!url1 || !url2)
|
|
3098
|
-
throw new Error('Usage: monomind browse diff url <url1> <url2>');
|
|
3099
|
-
// Capture snapshot at url1
|
|
3100
|
-
await browser.openUrl(client, sessionId, url1);
|
|
3101
|
-
await browser.waitFor(client, sessionId, { load: 'load', timeout: 15000 });
|
|
3102
|
-
const snap1 = await browser.captureSnapshot(client, sessionId, {
|
|
3103
|
-
interactiveOnly: ctx.flags.interactive,
|
|
3104
|
-
});
|
|
3105
|
-
// Capture snapshot at url2
|
|
3106
|
-
await browser.openUrl(client, sessionId, url2);
|
|
3107
|
-
await browser.waitFor(client, sessionId, { load: 'load', timeout: 15000 });
|
|
3108
|
-
const snap2 = await browser.captureSnapshot(client, sessionId, {
|
|
3109
|
-
interactiveOnly: ctx.flags.interactive,
|
|
3110
|
-
});
|
|
3111
|
-
_refs = snap2.refs;
|
|
3112
|
-
await browser.saveRefCache(_targetId, snap2.url, _refs);
|
|
3113
|
-
// Text diff
|
|
3114
|
-
const lines1 = snap1.text.split('\n');
|
|
3115
|
-
const lines2 = snap2.text.split('\n');
|
|
3116
|
-
const set1 = new Set(lines1);
|
|
3117
|
-
const set2 = new Set(lines2);
|
|
3118
|
-
const onlyIn1 = lines1.filter((l) => !set2.has(l));
|
|
3119
|
-
const onlyIn2 = lines2.filter((l) => !set1.has(l));
|
|
3120
|
-
const changed = onlyIn1.length > 0 || onlyIn2.length > 0;
|
|
3121
|
-
if (ctx.flags.json) {
|
|
3122
|
-
print(JSON.stringify({
|
|
3123
|
-
changed,
|
|
3124
|
-
url1,
|
|
3125
|
-
url2,
|
|
3126
|
-
onlyIn1,
|
|
3127
|
-
onlyIn2,
|
|
3128
|
-
additions: onlyIn2.length,
|
|
3129
|
-
removals: onlyIn1.length,
|
|
3130
|
-
}));
|
|
3131
|
-
}
|
|
3132
|
-
else {
|
|
3133
|
-
if (!changed) {
|
|
3134
|
-
output.printSuccess(`No differences between ${url1} and ${url2}`);
|
|
3135
|
-
}
|
|
3136
|
-
else {
|
|
3137
|
-
output.printWarning(`Diff: ${url1} vs ${url2} — +${onlyIn2.length} lines, -${onlyIn1.length} lines`);
|
|
3138
|
-
for (const l of onlyIn1)
|
|
3139
|
-
print(`\x1b[31m- ${l}\x1b[0m`);
|
|
3140
|
-
for (const l of onlyIn2)
|
|
3141
|
-
print(`\x1b[32m+ ${l}\x1b[0m`);
|
|
3142
|
-
}
|
|
3143
|
-
}
|
|
3144
|
-
return {
|
|
3145
|
-
success: true,
|
|
3146
|
-
data: { changed, url1, url2, additions: onlyIn2.length, removals: onlyIn1.length },
|
|
3147
|
-
};
|
|
3148
|
-
}
|
|
3149
|
-
throw new Error('Usage: monomind browse diff url <url1> <url2>');
|
|
3150
|
-
},
|
|
3151
|
-
};
|
|
3152
|
-
const pushstateCommand = {
|
|
3153
|
-
name: 'pushstate',
|
|
3154
|
-
description: 'SPA navigation via pushState. Usage: monomind browse pushstate /path',
|
|
3155
|
-
action: async (ctx) => {
|
|
3156
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
3157
|
-
const browser = await getBrowser();
|
|
3158
|
-
const url = ctx.args[0];
|
|
3159
|
-
if (!url)
|
|
3160
|
-
throw new Error('Usage: monomind browse pushstate <url>');
|
|
3161
|
-
await browser.pushState(client, sessionId, url);
|
|
3162
|
-
// SPA navigation changes what's on the page without a full page load —
|
|
3163
|
-
// refs captured before this pushState call may now resolve to different content.
|
|
3164
|
-
_refs = new Map();
|
|
3165
|
-
await browser.clearRefCache();
|
|
3166
|
-
output.printSuccess(`pushState: ${url}`);
|
|
3167
|
-
return { success: true };
|
|
3168
|
-
},
|
|
3169
|
-
};
|
|
3170
|
-
function tokenizeBatchCommand(input) {
|
|
3171
|
-
const tokens = [];
|
|
3172
|
-
let current = '';
|
|
3173
|
-
let inQuote = null;
|
|
3174
|
-
for (const ch of input.trim()) {
|
|
3175
|
-
if (inQuote) {
|
|
3176
|
-
if (ch === inQuote) {
|
|
3177
|
-
inQuote = null;
|
|
3178
|
-
}
|
|
3179
|
-
else {
|
|
3180
|
-
current += ch;
|
|
3181
|
-
}
|
|
3182
|
-
}
|
|
3183
|
-
else if (ch === '"' || ch === "'") {
|
|
3184
|
-
inQuote = ch;
|
|
3185
|
-
}
|
|
3186
|
-
else if (/\s/.test(ch)) {
|
|
3187
|
-
if (current) {
|
|
3188
|
-
tokens.push(current);
|
|
3189
|
-
current = '';
|
|
3190
|
-
}
|
|
3191
|
-
}
|
|
3192
|
-
else {
|
|
3193
|
-
current += ch;
|
|
3194
|
-
}
|
|
3195
|
-
}
|
|
3196
|
-
if (current)
|
|
3197
|
-
tokens.push(current);
|
|
3198
|
-
return tokens;
|
|
3199
|
-
}
|
|
3200
|
-
/**
|
|
3201
|
-
* Splits one line of a `batch` command string into a subcommand name, its
|
|
3202
|
-
* args, and any recognized leading flags.
|
|
3203
|
-
*
|
|
3204
|
-
* `eval` is special-cased: its sole argument is a raw JS expression, which
|
|
3205
|
-
* legitimately contains its own string-literal quotes (e.g.
|
|
3206
|
-
* `eval document.querySelector('a')`). tokenizeBatchCommand's shell-style
|
|
3207
|
-
* word-splitting treats `'...'`/`"..."` as grouping delimiters and discards
|
|
3208
|
-
* them — correct for a value like `fill @e1 "some text"`, but for `eval` it
|
|
3209
|
-
* silently deletes the expression's own quotes, turning a string literal
|
|
3210
|
-
* into a bare (undefined) identifier reference and producing a
|
|
3211
|
-
* ReferenceError instead of evaluating the intended expression. For `eval`,
|
|
3212
|
-
* only its own known --flags are consumed from the front; everything after
|
|
3213
|
-
* them is taken verbatim as one argument, untouched by tokenization.
|
|
3214
4
|
*
|
|
3215
|
-
|
|
3216
|
-
*
|
|
3217
|
-
*
|
|
3218
|
-
*
|
|
3219
|
-
* top-left origin — a caller computing its own center as `box.x + width/2`
|
|
3220
|
-
* would otherwise double-offset away from the element (issue #15). Expose
|
|
3221
|
-
* both conventions, explicitly labeled, so neither is ambiguous.
|
|
5
|
+
* This file is the command CATALOGUE: it assembles the `browse` root command
|
|
6
|
+
* from the subcommand modules beside it and stays the single import point for
|
|
7
|
+
* the package. The subcommands themselves are grouped by what the user is
|
|
8
|
+
* doing:
|
|
3222
9
|
*
|
|
3223
|
-
*
|
|
10
|
+
* - `session.ts` the live CDP session every subcommand acts on,
|
|
11
|
+
* plus the shared flag/output helpers.
|
|
12
|
+
* - `commands-session.ts` open / connect / close / resize.
|
|
13
|
+
* - `commands-context.ts` frame / tab / window / state.
|
|
14
|
+
* - `commands-page.ts` snapshot / get / diff.
|
|
15
|
+
* - `commands-navigate.ts` wait / scroll / navigate / set / pushstate.
|
|
16
|
+
* - `commands-input.ts` mouse, keyboard, touch and drag input.
|
|
17
|
+
* - `commands-element.ts` find / is / isvisible / isenabled / ischecked /
|
|
18
|
+
* highlight.
|
|
19
|
+
* - `commands-files.ts` screenshot / pdf / upload / download.
|
|
20
|
+
* - `commands-data.ts` storage / cookies / clipboard / dialog /
|
|
21
|
+
* console / errors.
|
|
22
|
+
* - `commands-trace.ts` network / record / trace / profiler / vitals / har.
|
|
23
|
+
* - `commands-script.ts` eval / batch / init scripts.
|
|
24
|
+
* - `commands-report.ts` the one-command page report.
|
|
3224
25
|
*/
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
}
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
consumedAnother = false;
|
|
3249
|
-
const withValue = rest.match(/^--(max-output|timeout)\s+(-?\d+)\s*/);
|
|
3250
|
-
if (withValue) {
|
|
3251
|
-
flags[withValue[1]] = Number(withValue[2]);
|
|
3252
|
-
rest = rest.slice(withValue[0].length);
|
|
3253
|
-
consumedAnother = true;
|
|
3254
|
-
continue;
|
|
3255
|
-
}
|
|
3256
|
-
const boolFlag = rest.match(/^--(json|stdin)\b\s*/);
|
|
3257
|
-
if (boolFlag) {
|
|
3258
|
-
flags[boolFlag[1]] = true;
|
|
3259
|
-
rest = rest.slice(boolFlag[0].length);
|
|
3260
|
-
consumedAnother = true;
|
|
3261
|
-
}
|
|
3262
|
-
}
|
|
3263
|
-
return { subName: 'eval', subArgs: [rest], flags };
|
|
3264
|
-
}
|
|
26
|
+
import { createActionCommand } from './action.js';
|
|
27
|
+
import { wrapCommanderCommand } from './commander-adapter.js';
|
|
28
|
+
import { frameCommand, stateCommand, tabCommand, windowCommand } from './commands-context.js';
|
|
29
|
+
import { clipboardCommand, consoleLogCommand, cookiesCommand, dialogCommand, errorsCommand, storageCommand, } from './commands-data.js';
|
|
30
|
+
import { findCommand, highlightCommand, isCommand, ischeckedCommand, isenabledCommand, isvisibleCommand, } from './commands-element.js';
|
|
31
|
+
import { downloadCommand, pdfCommand, screenshotCommand, uploadCommand } from './commands-files.js';
|
|
32
|
+
import { checkCommand, clickCommand, dblclickCommand, dragCommand, fillCommand, focusCommand, hoverCommand, keyboardCommand, keydownCommand, keyupCommand, mouseCommand, pressCommand, scrollIntoViewCommand, selectCommand, swipeCommand, tapCommand, typeCommand, uncheckCommand, } from './commands-input.js';
|
|
33
|
+
import { navigateCommand, pushstateCommand, scrollCommand, setCommand, waitCommand, } from './commands-navigate.js';
|
|
34
|
+
import { diffCommand, getCommand, snapshotCommand } from './commands-page.js';
|
|
35
|
+
import { reportCommand } from './commands-report.js';
|
|
36
|
+
import { addinitscriptCommand, evalCommand, parseBatchCommandLine, removeinitscriptCommand, } from './commands-script.js';
|
|
37
|
+
import { closeCommand, connectCommand, openCommand, resizeCommand } from './commands-session.js';
|
|
38
|
+
import { harCommand, networkCommand, profilerCommand, recordCommand, traceCommand, vitalsCommand, } from './commands-trace.js';
|
|
39
|
+
import { output } from './output.js';
|
|
40
|
+
import { createPlatformCommand } from './platform.js';
|
|
41
|
+
// Re-exported for direct unit testing — these were exported from this file
|
|
42
|
+
// before the subcommands were split out, and the tests import them from here.
|
|
43
|
+
export { deriveBoxOutput } from './commands-page.js';
|
|
44
|
+
export { parseBatchCommandLine } from './commands-script.js';
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// batch — dispatches over this file's own subcommand catalogue, which is why
|
|
47
|
+
// it lives here rather than with the other script commands.
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
3265
49
|
const batchCommand = {
|
|
3266
50
|
name: 'batch',
|
|
3267
51
|
description: 'Execute multiple commands. Usage: monomind browse batch "open url" "snapshot -i" "click @e1"',
|
|
@@ -3371,396 +155,6 @@ const batchCommand = {
|
|
|
3371
155
|
return { success: failed === 0, data: { results } };
|
|
3372
156
|
},
|
|
3373
157
|
};
|
|
3374
|
-
const addinitscriptCommand = {
|
|
3375
|
-
name: 'addinitscript',
|
|
3376
|
-
description: 'Add script to run before page navigation. Usage: monomind browse addinitscript "window.x=1"',
|
|
3377
|
-
action: async (ctx) => {
|
|
3378
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
3379
|
-
const browser = await getBrowser();
|
|
3380
|
-
const script = ctx.args[0];
|
|
3381
|
-
if (!script)
|
|
3382
|
-
throw new Error('Usage: monomind browse addinitscript "<js>"');
|
|
3383
|
-
const id = await browser.addInitScript(client, sessionId, script);
|
|
3384
|
-
output.printSuccess(`Init script added: ${id}`);
|
|
3385
|
-
return { success: true, data: { identifier: id } };
|
|
3386
|
-
},
|
|
3387
|
-
};
|
|
3388
|
-
const removeinitscriptCommand = {
|
|
3389
|
-
name: 'removeinitscript',
|
|
3390
|
-
description: 'Remove a previously added init script. Usage: monomind browse removeinitscript <id>',
|
|
3391
|
-
action: async (ctx) => {
|
|
3392
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
3393
|
-
const browser = await getBrowser();
|
|
3394
|
-
const id = ctx.args[0];
|
|
3395
|
-
if (!id)
|
|
3396
|
-
throw new Error('Usage: monomind browse removeinitscript <identifier>');
|
|
3397
|
-
await browser.removeInitScript(client, sessionId, id);
|
|
3398
|
-
output.printSuccess(`Init script removed: ${id}`);
|
|
3399
|
-
return { success: true };
|
|
3400
|
-
},
|
|
3401
|
-
};
|
|
3402
|
-
const connectCommand = {
|
|
3403
|
-
name: 'connect',
|
|
3404
|
-
description: 'Connect to existing Chrome instance; later commands reuse this session (note: `open` without --port still launches on its own default). Usage: monomind browse connect [--port 9222] [--target <id>] [--auto-connect]',
|
|
3405
|
-
options: [
|
|
3406
|
-
{ name: 'port', short: 'p', type: 'number', description: 'CDP port', default: 9222 },
|
|
3407
|
-
{ name: 'target', type: 'string', description: 'Target ID to attach to' },
|
|
3408
|
-
{
|
|
3409
|
-
name: 'auto-connect',
|
|
3410
|
-
type: 'boolean',
|
|
3411
|
-
description: 'Auto-discover running Chrome on ports 9222 and 9229',
|
|
3412
|
-
default: false,
|
|
3413
|
-
},
|
|
3414
|
-
],
|
|
3415
|
-
action: async (ctx) => {
|
|
3416
|
-
let port = ctx.flags.port ?? 9222;
|
|
3417
|
-
if (ctx.flags['auto-connect']) {
|
|
3418
|
-
const probePorts = [9222, 9229];
|
|
3419
|
-
let found = false;
|
|
3420
|
-
for (const p of probePorts) {
|
|
3421
|
-
try {
|
|
3422
|
-
const r = await fetch(`http://127.0.0.1:${p}/json/version`);
|
|
3423
|
-
if (r.ok) {
|
|
3424
|
-
port = p;
|
|
3425
|
-
found = true;
|
|
3426
|
-
break;
|
|
3427
|
-
}
|
|
3428
|
-
}
|
|
3429
|
-
catch {
|
|
3430
|
-
/* port not open */
|
|
3431
|
-
}
|
|
3432
|
-
}
|
|
3433
|
-
if (!found)
|
|
3434
|
-
throw new Error('No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --port.');
|
|
3435
|
-
}
|
|
3436
|
-
const browser = await getBrowser();
|
|
3437
|
-
if (_client) {
|
|
3438
|
-
const prevSid = _sessionId;
|
|
3439
|
-
const prevClient = _client;
|
|
3440
|
-
if (browser.getHarStatus(prevSid).recording) {
|
|
3441
|
-
try {
|
|
3442
|
-
await browser.stopHarRecording(prevClient, prevSid);
|
|
3443
|
-
}
|
|
3444
|
-
catch {
|
|
3445
|
-
/* ignore */
|
|
3446
|
-
}
|
|
3447
|
-
}
|
|
3448
|
-
if (browser.getTraceStatus(prevSid)) {
|
|
3449
|
-
try {
|
|
3450
|
-
await browser.stopTrace(prevClient, prevSid);
|
|
3451
|
-
}
|
|
3452
|
-
catch {
|
|
3453
|
-
/* ignore */
|
|
3454
|
-
}
|
|
3455
|
-
}
|
|
3456
|
-
if (browser.isProfilingActive(prevSid)) {
|
|
3457
|
-
try {
|
|
3458
|
-
await browser.stopCpuProfile(prevClient, prevSid);
|
|
3459
|
-
}
|
|
3460
|
-
catch {
|
|
3461
|
-
/* ignore */
|
|
3462
|
-
}
|
|
3463
|
-
}
|
|
3464
|
-
browser.teardownRouteInterception(prevSid);
|
|
3465
|
-
browser.stopRequestCapture(prevSid);
|
|
3466
|
-
browser.teardownDialogHandling(prevSid);
|
|
3467
|
-
browser.teardownConsoleCapture(prevSid);
|
|
3468
|
-
prevClient.close();
|
|
3469
|
-
_client = null;
|
|
3470
|
-
_sessionId = '';
|
|
3471
|
-
_parentSessionId = '';
|
|
3472
|
-
_targetId = '';
|
|
3473
|
-
_refs = new Map();
|
|
3474
|
-
}
|
|
3475
|
-
const conn = await browser.connectToTarget(port, ctx.flags.target);
|
|
3476
|
-
_client = conn.client;
|
|
3477
|
-
_sessionId = conn.sessionId;
|
|
3478
|
-
_targetId = conn.target.id;
|
|
3479
|
-
_port = port;
|
|
3480
|
-
_refs = new Map();
|
|
3481
|
-
// Persist the port like `open` does — without this, the NEXT CLI process
|
|
3482
|
-
// (each command is a fresh process) resolves the hardcoded default and
|
|
3483
|
-
// tries to launch its own Chrome on 9222 instead of reusing this session.
|
|
3484
|
-
// launched:false marks this browser as someone else's — close must never
|
|
3485
|
-
// kill it, and a dead endpoint must not be silently relaunched.
|
|
3486
|
-
await browser.saveActivePort(port, { launched: false });
|
|
3487
|
-
const url = await browser.getCurrentUrl(_client, _sessionId);
|
|
3488
|
-
const title = await browser.getCurrentTitle(_client, _sessionId);
|
|
3489
|
-
output.printSuccess(`Connected: ${title} (${url})`);
|
|
3490
|
-
return { success: true, data: { targetId: _targetId, url, title } };
|
|
3491
|
-
},
|
|
3492
|
-
};
|
|
3493
|
-
const recordCommand = {
|
|
3494
|
-
name: 'record',
|
|
3495
|
-
description: 'Screen recording. Usage: monomind browse record start|stop|restart|status [path]',
|
|
3496
|
-
options: [
|
|
3497
|
-
{ name: 'format', type: 'string', description: 'jpeg|png', default: 'jpeg' },
|
|
3498
|
-
{ name: 'quality', type: 'number', description: 'Quality 0-100', default: 80 },
|
|
3499
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
3500
|
-
],
|
|
3501
|
-
action: async (ctx) => {
|
|
3502
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
3503
|
-
const browser = await getBrowser();
|
|
3504
|
-
const action = ctx.args[0];
|
|
3505
|
-
if (!action)
|
|
3506
|
-
throw new Error('Usage: monomind browse record start|stop|restart|status');
|
|
3507
|
-
switch (action) {
|
|
3508
|
-
case 'start':
|
|
3509
|
-
await browser.startRecording(client, sessionId, {
|
|
3510
|
-
format: imageFormat(ctx.flags.format, ['jpeg', 'png'], 'jpeg'),
|
|
3511
|
-
quality: ctx.flags.quality,
|
|
3512
|
-
});
|
|
3513
|
-
output.printSuccess('Recording started');
|
|
3514
|
-
return { success: true };
|
|
3515
|
-
case 'stop': {
|
|
3516
|
-
const path = await browser.stopRecording(client, sessionId, ctx.args[1]);
|
|
3517
|
-
if (ctx.flags.json)
|
|
3518
|
-
print(JSON.stringify({ data: { path } }));
|
|
3519
|
-
else
|
|
3520
|
-
output.printSuccess(`Recording saved: ${path}`);
|
|
3521
|
-
return { success: true, data: { path } };
|
|
3522
|
-
}
|
|
3523
|
-
case 'restart': {
|
|
3524
|
-
const prevStatus = browser.getRecordingStatus(sessionId);
|
|
3525
|
-
let prevPath;
|
|
3526
|
-
if (prevStatus.recording || prevStatus.autoStopped) {
|
|
3527
|
-
prevPath = await browser.stopRecording(client, sessionId, ctx.args[1]);
|
|
3528
|
-
output.printInfo(`Previous recording saved: ${prevPath}`);
|
|
3529
|
-
}
|
|
3530
|
-
await browser.startRecording(client, sessionId, {
|
|
3531
|
-
format: imageFormat(ctx.flags.format, ['jpeg', 'png'], 'jpeg'),
|
|
3532
|
-
quality: ctx.flags.quality,
|
|
3533
|
-
});
|
|
3534
|
-
output.printSuccess('Recording restarted');
|
|
3535
|
-
return { success: true, data: { previous: prevPath } };
|
|
3536
|
-
}
|
|
3537
|
-
case 'status': {
|
|
3538
|
-
const status = browser.getRecordingStatus(sessionId);
|
|
3539
|
-
if (ctx.flags.json)
|
|
3540
|
-
print(JSON.stringify({ data: status }));
|
|
3541
|
-
else
|
|
3542
|
-
print(`Recording: ${status.recording} | Frames: ${status.frames}${status.autoStopped ? ' (auto-stopped: buffer limit reached — run "record stop" to save)' : ''}`);
|
|
3543
|
-
return { success: true, data: status };
|
|
3544
|
-
}
|
|
3545
|
-
default:
|
|
3546
|
-
throw new Error('Usage: monomind browse record start|stop|restart|status [path]');
|
|
3547
|
-
}
|
|
3548
|
-
},
|
|
3549
|
-
};
|
|
3550
|
-
const traceCommand = {
|
|
3551
|
-
name: 'trace',
|
|
3552
|
-
description: 'CDP performance trace. Usage: monomind browse trace start|stop [path]',
|
|
3553
|
-
options: [
|
|
3554
|
-
{ name: 'screenshots', type: 'boolean', description: 'Include screenshots', default: false },
|
|
3555
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
3556
|
-
],
|
|
3557
|
-
action: async (ctx) => {
|
|
3558
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
3559
|
-
const browser = await getBrowser();
|
|
3560
|
-
const action = ctx.args[0];
|
|
3561
|
-
if (!action)
|
|
3562
|
-
throw new Error('Usage: monomind browse trace start|stop [path]');
|
|
3563
|
-
switch (action) {
|
|
3564
|
-
case 'start':
|
|
3565
|
-
await browser.startTrace(client, sessionId, {
|
|
3566
|
-
screenshots: ctx.flags.screenshots,
|
|
3567
|
-
});
|
|
3568
|
-
output.printSuccess('Trace started');
|
|
3569
|
-
return { success: true };
|
|
3570
|
-
case 'stop': {
|
|
3571
|
-
const path = await browser.stopTrace(client, sessionId, ctx.args[1]);
|
|
3572
|
-
if (ctx.flags.json)
|
|
3573
|
-
print(JSON.stringify({ data: { path } }));
|
|
3574
|
-
else
|
|
3575
|
-
output.printSuccess(`Trace saved: ${path}`);
|
|
3576
|
-
return { success: true, data: { path } };
|
|
3577
|
-
}
|
|
3578
|
-
case 'status':
|
|
3579
|
-
print(browser.getTraceStatus(sessionId) ? 'Tracing active' : 'Not tracing');
|
|
3580
|
-
return { success: true };
|
|
3581
|
-
default:
|
|
3582
|
-
throw new Error('Usage: monomind browse trace start|stop|status [path]');
|
|
3583
|
-
}
|
|
3584
|
-
},
|
|
3585
|
-
};
|
|
3586
|
-
const profilerCommand = {
|
|
3587
|
-
name: 'profiler',
|
|
3588
|
-
description: 'CPU profiler. Usage: monomind browse profiler start|stop|heap [path]',
|
|
3589
|
-
options: [
|
|
3590
|
-
{ name: 'interval', type: 'number', description: 'Sampling interval µs', default: 1000 },
|
|
3591
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
3592
|
-
],
|
|
3593
|
-
action: async (ctx) => {
|
|
3594
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
3595
|
-
const browser = await getBrowser();
|
|
3596
|
-
const action = ctx.args[0];
|
|
3597
|
-
if (!action)
|
|
3598
|
-
throw new Error('Usage: monomind browse profiler start|stop|heap [path]');
|
|
3599
|
-
switch (action) {
|
|
3600
|
-
case 'start':
|
|
3601
|
-
await browser.startCpuProfile(client, sessionId, {
|
|
3602
|
-
samplingInterval: ctx.flags.interval,
|
|
3603
|
-
});
|
|
3604
|
-
output.printSuccess('CPU profiler started');
|
|
3605
|
-
return { success: true };
|
|
3606
|
-
case 'stop': {
|
|
3607
|
-
const path = await browser.stopCpuProfile(client, sessionId, ctx.args[1]);
|
|
3608
|
-
if (ctx.flags.json)
|
|
3609
|
-
print(JSON.stringify({ data: { path } }));
|
|
3610
|
-
else
|
|
3611
|
-
output.printSuccess(`Profile saved: ${path}`);
|
|
3612
|
-
return { success: true, data: { path } };
|
|
3613
|
-
}
|
|
3614
|
-
case 'heap': {
|
|
3615
|
-
const path = await browser.startHeapSnapshot(client, sessionId, ctx.args[1]);
|
|
3616
|
-
if (ctx.flags.json)
|
|
3617
|
-
print(JSON.stringify({ data: { path } }));
|
|
3618
|
-
else
|
|
3619
|
-
output.printSuccess(`Heap snapshot saved: ${path}`);
|
|
3620
|
-
return { success: true, data: { path } };
|
|
3621
|
-
}
|
|
3622
|
-
default:
|
|
3623
|
-
throw new Error('Usage: monomind browse profiler start|stop|heap [path]');
|
|
3624
|
-
}
|
|
3625
|
-
},
|
|
3626
|
-
};
|
|
3627
|
-
const vitalsCommand = {
|
|
3628
|
-
name: 'vitals',
|
|
3629
|
-
description: 'Collect Core Web Vitals. Usage: monomind browse vitals [--wait 2000]',
|
|
3630
|
-
options: [
|
|
3631
|
-
{ name: 'wait', type: 'number', description: 'Wait ms for observers', default: 2000 },
|
|
3632
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
3633
|
-
],
|
|
3634
|
-
action: async (ctx) => {
|
|
3635
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
3636
|
-
const browser = await getBrowser();
|
|
3637
|
-
const vitals = await browser.collectVitals(client, sessionId, ctx.flags.wait);
|
|
3638
|
-
if (ctx.flags.json) {
|
|
3639
|
-
print(JSON.stringify({ data: vitals }));
|
|
3640
|
-
}
|
|
3641
|
-
else {
|
|
3642
|
-
print(browser.formatVitals(vitals));
|
|
3643
|
-
}
|
|
3644
|
-
return { success: true, data: vitals };
|
|
3645
|
-
},
|
|
3646
|
-
};
|
|
3647
|
-
const harCommand = {
|
|
3648
|
-
name: 'har',
|
|
3649
|
-
description: 'HAR network recording. Usage: monomind browse har start|stop|status [path]',
|
|
3650
|
-
options: [
|
|
3651
|
-
{ name: 'bodies', type: 'boolean', description: 'Capture response bodies', default: false },
|
|
3652
|
-
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
3653
|
-
],
|
|
3654
|
-
action: async (ctx) => {
|
|
3655
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
3656
|
-
const browser = await getBrowser();
|
|
3657
|
-
const action = ctx.args[0];
|
|
3658
|
-
if (!action)
|
|
3659
|
-
throw new Error('Usage: monomind browse har start|stop|status [path]');
|
|
3660
|
-
switch (action) {
|
|
3661
|
-
case 'start':
|
|
3662
|
-
await browser.startHarRecording(client, sessionId);
|
|
3663
|
-
output.printSuccess('HAR recording started');
|
|
3664
|
-
return { success: true };
|
|
3665
|
-
case 'stop': {
|
|
3666
|
-
const path = await browser.stopHarRecording(client, sessionId, ctx.args[1], ctx.flags.bodies);
|
|
3667
|
-
if (ctx.flags.json)
|
|
3668
|
-
print(JSON.stringify({ data: { path } }));
|
|
3669
|
-
else
|
|
3670
|
-
output.printSuccess(`HAR saved: ${path}`);
|
|
3671
|
-
return { success: true, data: { path } };
|
|
3672
|
-
}
|
|
3673
|
-
case 'status': {
|
|
3674
|
-
const status = browser.getHarStatus(sessionId);
|
|
3675
|
-
if (ctx.flags.json)
|
|
3676
|
-
print(JSON.stringify({ data: status }));
|
|
3677
|
-
else
|
|
3678
|
-
print(`Recording: ${status.recording} | Requests: ${status.requestCount}`);
|
|
3679
|
-
return { success: true, data: status };
|
|
3680
|
-
}
|
|
3681
|
-
default:
|
|
3682
|
-
throw new Error('Usage: monomind browse har start|stop|status [path]');
|
|
3683
|
-
}
|
|
3684
|
-
},
|
|
3685
|
-
};
|
|
3686
|
-
const resizeCommand = {
|
|
3687
|
-
name: 'resize',
|
|
3688
|
-
description: 'Resize browser window. Usage: monomind browse resize <width> <height>',
|
|
3689
|
-
action: async (ctx) => {
|
|
3690
|
-
const { client, sessionId } = await ensureConnected(_port);
|
|
3691
|
-
const browser = await getBrowser();
|
|
3692
|
-
const width = parseInt(ctx.args[0], 10);
|
|
3693
|
-
const height = parseInt(ctx.args[1], 10);
|
|
3694
|
-
if (Number.isNaN(width) || Number.isNaN(height))
|
|
3695
|
-
throw new Error('Usage: monomind browse resize <width> <height>');
|
|
3696
|
-
await browser.setViewport(client, sessionId, width, height);
|
|
3697
|
-
output.printSuccess(`Resized to ${width}x${height}`);
|
|
3698
|
-
return { success: true, data: { width, height } };
|
|
3699
|
-
},
|
|
3700
|
-
};
|
|
3701
|
-
// ---------------------------------------------------------------------------
|
|
3702
|
-
// Commander-to-internal adapter
|
|
3703
|
-
// ---------------------------------------------------------------------------
|
|
3704
|
-
/**
|
|
3705
|
-
* Wraps a commander.Command instance as an internal Command object so it can
|
|
3706
|
-
* participate in the browse command's subcommands array. The adapter
|
|
3707
|
-
* reconstructs a raw argv string from the context that was parsed by the
|
|
3708
|
-
* internal CLI, then hands it off to commander's parseAsync.
|
|
3709
|
-
*/
|
|
3710
|
-
function wrapCommanderCommand(factory) {
|
|
3711
|
-
// Lazily instantiate so we don't pay import cost at startup
|
|
3712
|
-
let _cmd = null;
|
|
3713
|
-
const getCmd = () => {
|
|
3714
|
-
if (!_cmd)
|
|
3715
|
-
_cmd = factory();
|
|
3716
|
-
return _cmd;
|
|
3717
|
-
};
|
|
3718
|
-
const cmd = getCmd();
|
|
3719
|
-
const subcommandDefs = cmd.commands.map((sub) => ({
|
|
3720
|
-
name: sub.name(),
|
|
3721
|
-
description: sub.description(),
|
|
3722
|
-
action: async (ctx) => {
|
|
3723
|
-
// Rebuild argv: node <cmd> <sub> [positional args] [--flag [value] ...]
|
|
3724
|
-
const argv = ['node', cmd.name(), sub.name(), ...ctx.args];
|
|
3725
|
-
for (const [key, val] of Object.entries(ctx.flags)) {
|
|
3726
|
-
if (key === '_')
|
|
3727
|
-
continue;
|
|
3728
|
-
if (typeof val === 'boolean') {
|
|
3729
|
-
if (val)
|
|
3730
|
-
argv.push(`--${key}`);
|
|
3731
|
-
}
|
|
3732
|
-
else if (val !== undefined && val !== null) {
|
|
3733
|
-
argv.push(`--${key}`, String(val));
|
|
3734
|
-
}
|
|
3735
|
-
}
|
|
3736
|
-
// Re-use the same commander instance to keep state (e.g. option defaults)
|
|
3737
|
-
await getCmd().parseAsync(argv, { from: 'user' });
|
|
3738
|
-
return { success: true };
|
|
3739
|
-
},
|
|
3740
|
-
}));
|
|
3741
|
-
return {
|
|
3742
|
-
name: cmd.name(),
|
|
3743
|
-
description: cmd.description(),
|
|
3744
|
-
subcommands: subcommandDefs,
|
|
3745
|
-
action: async (ctx) => {
|
|
3746
|
-
// No subcommand provided — show commander help
|
|
3747
|
-
const argv = ['node', cmd.name(), ...ctx.args];
|
|
3748
|
-
for (const [key, val] of Object.entries(ctx.flags)) {
|
|
3749
|
-
if (key === '_')
|
|
3750
|
-
continue;
|
|
3751
|
-
if (typeof val === 'boolean') {
|
|
3752
|
-
if (val)
|
|
3753
|
-
argv.push(`--${key}`);
|
|
3754
|
-
}
|
|
3755
|
-
else if (val !== undefined && val !== null) {
|
|
3756
|
-
argv.push(`--${key}`, String(val));
|
|
3757
|
-
}
|
|
3758
|
-
}
|
|
3759
|
-
await getCmd().parseAsync(argv, { from: 'user' });
|
|
3760
|
-
return { success: true };
|
|
3761
|
-
},
|
|
3762
|
-
};
|
|
3763
|
-
}
|
|
3764
158
|
const actionSubcommand = wrapCommanderCommand(createActionCommand);
|
|
3765
159
|
const platformSubcommand = wrapCommanderCommand(createPlatformCommand);
|
|
3766
160
|
// ---------------------------------------------------------------------------
|