@stevederico/dotbot 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -0
- package/CHANGELOG.md +13 -0
- package/README.md +4 -3
- package/bin/dotbot.js +1 -1
- package/core/browser-launcher.js +246 -0
- package/core/cdp.js +617 -0
- package/dotbot.db +0 -0
- package/index.js +0 -5
- package/package.json +7 -6
- package/storage/MemoryStore.js +1 -1
- package/storage/SQLiteAdapter.js +36 -1
- package/storage/index.js +2 -7
- package/test/events.test.js +75 -0
- package/test/normalize.test.js +79 -0
- package/test/storage.test.js +93 -0
- package/test/tools.test.js +43 -0
- package/tools/browser.js +479 -384
- package/storage/MongoAdapter.js +0 -291
- package/storage/MongoCronAdapter.js +0 -347
- package/storage/MongoTaskAdapter.js +0 -242
- package/storage/MongoTriggerAdapter.js +0 -158
package/core/cdp.js
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
// core/cdp.js
|
|
2
|
+
// Minimal Chrome DevTools Protocol client using Node.js built-in WebSocket.
|
|
3
|
+
// Zero external dependencies - requires Node.js 22+.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lightweight CDP client for browser automation.
|
|
7
|
+
* Communicates with Chrome via DevTools Protocol over WebSocket.
|
|
8
|
+
*/
|
|
9
|
+
export class CDPClient {
|
|
10
|
+
/**
|
|
11
|
+
* Create a CDP client instance.
|
|
12
|
+
* @param {string} wsUrl - WebSocket debugger URL from Chrome
|
|
13
|
+
*/
|
|
14
|
+
constructor(wsUrl) {
|
|
15
|
+
this.wsUrl = wsUrl;
|
|
16
|
+
this.ws = null;
|
|
17
|
+
this.id = 0;
|
|
18
|
+
this.pending = new Map();
|
|
19
|
+
this.eventHandlers = new Map();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Connect to the browser's WebSocket debugger endpoint.
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
async connect() {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
// Use Node.js 22+ native WebSocket (global, browser-compatible API)
|
|
29
|
+
this.ws = new WebSocket(this.wsUrl);
|
|
30
|
+
|
|
31
|
+
this.ws.addEventListener('open', () => {
|
|
32
|
+
resolve();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
this.ws.addEventListener('error', (event) => {
|
|
36
|
+
reject(new Error(`CDP connection failed: ${event.message || 'Unknown error'}`));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
this.ws.addEventListener('message', (event) => {
|
|
40
|
+
const msg = JSON.parse(event.data);
|
|
41
|
+
|
|
42
|
+
// Handle responses to our requests
|
|
43
|
+
if (msg.id !== undefined) {
|
|
44
|
+
const handler = this.pending.get(msg.id);
|
|
45
|
+
if (handler) {
|
|
46
|
+
this.pending.delete(msg.id);
|
|
47
|
+
if (msg.error) {
|
|
48
|
+
handler.reject(new Error(msg.error.message));
|
|
49
|
+
} else {
|
|
50
|
+
handler.resolve(msg.result);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Handle events
|
|
56
|
+
if (msg.method) {
|
|
57
|
+
const handlers = this.eventHandlers.get(msg.method);
|
|
58
|
+
if (handlers) {
|
|
59
|
+
for (const fn of handlers) {
|
|
60
|
+
fn(msg.params);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.ws.addEventListener('close', () => {
|
|
67
|
+
// Reject all pending requests
|
|
68
|
+
for (const [, handler] of this.pending) {
|
|
69
|
+
handler.reject(new Error('CDP connection closed'));
|
|
70
|
+
}
|
|
71
|
+
this.pending.clear();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Send a CDP command and wait for response.
|
|
78
|
+
* @param {string} method - CDP method name (e.g., 'Page.navigate')
|
|
79
|
+
* @param {Object} params - Command parameters
|
|
80
|
+
* @param {number} timeout - Timeout in ms (default 30s)
|
|
81
|
+
* @returns {Promise<Object>} Command result
|
|
82
|
+
*/
|
|
83
|
+
async send(method, params = {}, timeout = 30000) {
|
|
84
|
+
if (!this.ws || this.ws.readyState !== 1) { // 1 = OPEN
|
|
85
|
+
throw new Error('CDP not connected');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const id = ++this.id;
|
|
89
|
+
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
this.pending.delete(id);
|
|
93
|
+
reject(new Error(`CDP command timed out: ${method}`));
|
|
94
|
+
}, timeout);
|
|
95
|
+
|
|
96
|
+
this.pending.set(id, {
|
|
97
|
+
resolve: (result) => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
resolve(result);
|
|
100
|
+
},
|
|
101
|
+
reject: (err) => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
reject(err);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Subscribe to a CDP event.
|
|
113
|
+
* @param {string} event - Event name (e.g., 'Page.loadEventFired')
|
|
114
|
+
* @param {Function} handler - Event handler function
|
|
115
|
+
*/
|
|
116
|
+
on(event, handler) {
|
|
117
|
+
if (!this.eventHandlers.has(event)) {
|
|
118
|
+
this.eventHandlers.set(event, new Set());
|
|
119
|
+
}
|
|
120
|
+
this.eventHandlers.get(event).add(handler);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Unsubscribe from a CDP event.
|
|
125
|
+
* @param {string} event - Event name
|
|
126
|
+
* @param {Function} handler - Handler to remove
|
|
127
|
+
*/
|
|
128
|
+
off(event, handler) {
|
|
129
|
+
const handlers = this.eventHandlers.get(event);
|
|
130
|
+
if (handlers) {
|
|
131
|
+
handlers.delete(handler);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Navigate to a URL.
|
|
137
|
+
* @param {string} url - URL to navigate to
|
|
138
|
+
* @returns {Promise<Object>} Navigation result with frameId
|
|
139
|
+
*/
|
|
140
|
+
async navigate(url) {
|
|
141
|
+
await this.send('Page.enable');
|
|
142
|
+
return this.send('Page.navigate', { url });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Wait for the page to finish loading.
|
|
147
|
+
* @param {number} timeout - Timeout in ms
|
|
148
|
+
* @returns {Promise<void>}
|
|
149
|
+
*/
|
|
150
|
+
async waitForLoad(timeout = 30000) {
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
this.off('Page.loadEventFired', handler);
|
|
154
|
+
reject(new Error('Page load timed out'));
|
|
155
|
+
}, timeout);
|
|
156
|
+
|
|
157
|
+
const handler = () => {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
this.off('Page.loadEventFired', handler);
|
|
160
|
+
resolve();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
this.on('Page.loadEventFired', handler);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Evaluate JavaScript in the page context.
|
|
169
|
+
* @param {string} expression - JavaScript expression to evaluate
|
|
170
|
+
* @returns {Promise<any>} Result value
|
|
171
|
+
*/
|
|
172
|
+
async evaluate(expression) {
|
|
173
|
+
await this.send('Runtime.enable');
|
|
174
|
+
const result = await this.send('Runtime.evaluate', {
|
|
175
|
+
expression,
|
|
176
|
+
returnByValue: true,
|
|
177
|
+
awaitPromise: true
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (result.exceptionDetails) {
|
|
181
|
+
throw new Error(result.exceptionDetails.text || 'Evaluation failed');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return result.result?.value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get the page title.
|
|
189
|
+
* @returns {Promise<string>}
|
|
190
|
+
*/
|
|
191
|
+
async getTitle() {
|
|
192
|
+
return this.evaluate('document.title');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get the current URL.
|
|
197
|
+
* @returns {Promise<string>}
|
|
198
|
+
*/
|
|
199
|
+
async getUrl() {
|
|
200
|
+
return this.evaluate('window.location.href');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get text content of the page body.
|
|
205
|
+
* @returns {Promise<string>}
|
|
206
|
+
*/
|
|
207
|
+
async getBodyText() {
|
|
208
|
+
return this.evaluate('document.body?.innerText || ""');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get text content of an element by CSS selector.
|
|
213
|
+
* @param {string} selector - CSS selector
|
|
214
|
+
* @returns {Promise<string>}
|
|
215
|
+
*/
|
|
216
|
+
async getText(selector) {
|
|
217
|
+
const escaped = selector.replace(/"/g, '\\"');
|
|
218
|
+
return this.evaluate(`document.querySelector("${escaped}")?.innerText || ""`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Query a selector and return element info for clicking.
|
|
223
|
+
* @param {string} selector - CSS selector
|
|
224
|
+
* @returns {Promise<{x: number, y: number}|null>} Element center coordinates
|
|
225
|
+
*/
|
|
226
|
+
async querySelector(selector) {
|
|
227
|
+
await this.send('DOM.enable');
|
|
228
|
+
const doc = await this.send('DOM.getDocument');
|
|
229
|
+
const result = await this.send('DOM.querySelector', {
|
|
230
|
+
nodeId: doc.root.nodeId,
|
|
231
|
+
selector
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (!result.nodeId) return null;
|
|
235
|
+
|
|
236
|
+
const boxes = await this.send('DOM.getBoxModel', { nodeId: result.nodeId });
|
|
237
|
+
if (!boxes.model) return null;
|
|
238
|
+
|
|
239
|
+
// Get center point of the content box
|
|
240
|
+
const content = boxes.model.content;
|
|
241
|
+
const x = (content[0] + content[2] + content[4] + content[6]) / 4;
|
|
242
|
+
const y = (content[1] + content[3] + content[5] + content[7]) / 4;
|
|
243
|
+
|
|
244
|
+
return { x, y, nodeId: result.nodeId };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Find element by text content.
|
|
249
|
+
* @param {string} text - Text to search for
|
|
250
|
+
* @param {boolean} exact - Exact match (default: false)
|
|
251
|
+
* @returns {Promise<{x: number, y: number}|null>}
|
|
252
|
+
*/
|
|
253
|
+
async getByText(text, exact = false) {
|
|
254
|
+
const escaped = text.replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
255
|
+
const xpath = exact
|
|
256
|
+
? `//*[text()="${escaped}"]`
|
|
257
|
+
: `//*[contains(text(), "${escaped}")]`;
|
|
258
|
+
|
|
259
|
+
await this.send('DOM.enable');
|
|
260
|
+
const doc = await this.send('DOM.getDocument');
|
|
261
|
+
|
|
262
|
+
const result = await this.send('DOM.performSearch', {
|
|
263
|
+
query: xpath,
|
|
264
|
+
includeUserAgentShadowDOM: false
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
if (!result.resultCount) return null;
|
|
268
|
+
|
|
269
|
+
const nodes = await this.send('DOM.getSearchResults', {
|
|
270
|
+
searchId: result.searchId,
|
|
271
|
+
fromIndex: 0,
|
|
272
|
+
toIndex: 1
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await this.send('DOM.discardSearchResults', { searchId: result.searchId });
|
|
276
|
+
|
|
277
|
+
if (!nodes.nodeIds?.length) return null;
|
|
278
|
+
|
|
279
|
+
const boxes = await this.send('DOM.getBoxModel', { nodeId: nodes.nodeIds[0] });
|
|
280
|
+
if (!boxes.model) return null;
|
|
281
|
+
|
|
282
|
+
const content = boxes.model.content;
|
|
283
|
+
const x = (content[0] + content[2] + content[4] + content[6]) / 4;
|
|
284
|
+
const y = (content[1] + content[3] + content[5] + content[7]) / 4;
|
|
285
|
+
|
|
286
|
+
return { x, y, nodeId: nodes.nodeIds[0] };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Click at specific coordinates.
|
|
291
|
+
* @param {number} x - X coordinate
|
|
292
|
+
* @param {number} y - Y coordinate
|
|
293
|
+
*/
|
|
294
|
+
async click(x, y) {
|
|
295
|
+
await this.send('Input.dispatchMouseEvent', {
|
|
296
|
+
type: 'mousePressed',
|
|
297
|
+
x,
|
|
298
|
+
y,
|
|
299
|
+
button: 'left',
|
|
300
|
+
clickCount: 1
|
|
301
|
+
});
|
|
302
|
+
await this.send('Input.dispatchMouseEvent', {
|
|
303
|
+
type: 'mouseReleased',
|
|
304
|
+
x,
|
|
305
|
+
y,
|
|
306
|
+
button: 'left',
|
|
307
|
+
clickCount: 1
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Click an element by CSS selector.
|
|
313
|
+
* @param {string} selector - CSS selector
|
|
314
|
+
*/
|
|
315
|
+
async clickSelector(selector) {
|
|
316
|
+
const el = await this.querySelector(selector);
|
|
317
|
+
if (!el) throw new Error(`Element not found: ${selector}`);
|
|
318
|
+
await this.click(el.x, el.y);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Click an element by visible text.
|
|
323
|
+
* @param {string} text - Text content to find
|
|
324
|
+
*/
|
|
325
|
+
async clickText(text) {
|
|
326
|
+
const el = await this.getByText(text);
|
|
327
|
+
if (!el) throw new Error(`Element with text "${text}" not found`);
|
|
328
|
+
await this.click(el.x, el.y);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Type text character by character.
|
|
333
|
+
* @param {string} text - Text to type
|
|
334
|
+
*/
|
|
335
|
+
async type(text) {
|
|
336
|
+
for (const char of text) {
|
|
337
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
338
|
+
type: 'keyDown',
|
|
339
|
+
text: char
|
|
340
|
+
});
|
|
341
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
342
|
+
type: 'keyUp',
|
|
343
|
+
text: char
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Press a special key (Enter, Tab, etc).
|
|
350
|
+
* @param {string} key - Key name
|
|
351
|
+
*/
|
|
352
|
+
async press(key) {
|
|
353
|
+
const keyMap = {
|
|
354
|
+
'Enter': { key: 'Enter', code: 'Enter', keyCode: 13 },
|
|
355
|
+
'Tab': { key: 'Tab', code: 'Tab', keyCode: 9 },
|
|
356
|
+
'Escape': { key: 'Escape', code: 'Escape', keyCode: 27 },
|
|
357
|
+
'Backspace': { key: 'Backspace', code: 'Backspace', keyCode: 8 }
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const keyInfo = keyMap[key] || { key, code: key };
|
|
361
|
+
|
|
362
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
363
|
+
type: 'keyDown',
|
|
364
|
+
...keyInfo
|
|
365
|
+
});
|
|
366
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
367
|
+
type: 'keyUp',
|
|
368
|
+
...keyInfo
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Focus an input element and fill it with text.
|
|
374
|
+
* @param {string} selector - CSS selector for the input
|
|
375
|
+
* @param {string} text - Text to fill
|
|
376
|
+
*/
|
|
377
|
+
async fill(selector, text) {
|
|
378
|
+
const el = await this.querySelector(selector);
|
|
379
|
+
if (!el) throw new Error(`Input not found: ${selector}`);
|
|
380
|
+
|
|
381
|
+
// Focus the element
|
|
382
|
+
await this.send('DOM.focus', { nodeId: el.nodeId });
|
|
383
|
+
|
|
384
|
+
// Clear existing value
|
|
385
|
+
await this.evaluate(`document.querySelector("${selector.replace(/"/g, '\\"')}").value = ""`);
|
|
386
|
+
|
|
387
|
+
// Type the text
|
|
388
|
+
await this.type(text);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Take a screenshot of the page.
|
|
393
|
+
* @param {Object} options - Screenshot options
|
|
394
|
+
* @param {boolean} options.fullPage - Capture full scrollable page
|
|
395
|
+
* @returns {Promise<Buffer>} PNG image buffer
|
|
396
|
+
*/
|
|
397
|
+
async screenshot(options = {}) {
|
|
398
|
+
const params = { format: 'png' };
|
|
399
|
+
|
|
400
|
+
if (options.fullPage) {
|
|
401
|
+
// Get full page dimensions
|
|
402
|
+
const metrics = await this.send('Page.getLayoutMetrics');
|
|
403
|
+
params.clip = {
|
|
404
|
+
x: 0,
|
|
405
|
+
y: 0,
|
|
406
|
+
width: metrics.contentSize.width,
|
|
407
|
+
height: metrics.contentSize.height,
|
|
408
|
+
scale: 1
|
|
409
|
+
};
|
|
410
|
+
params.captureBeyondViewport = true;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const result = await this.send('Page.captureScreenshot', params);
|
|
414
|
+
return Buffer.from(result.data, 'base64');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Screenshot a specific element.
|
|
419
|
+
* @param {string} selector - CSS selector
|
|
420
|
+
* @returns {Promise<Buffer>} PNG image buffer
|
|
421
|
+
*/
|
|
422
|
+
async screenshotElement(selector) {
|
|
423
|
+
const el = await this.querySelector(selector);
|
|
424
|
+
if (!el) throw new Error(`Element not found: ${selector}`);
|
|
425
|
+
|
|
426
|
+
const boxes = await this.send('DOM.getBoxModel', { nodeId: el.nodeId });
|
|
427
|
+
const border = boxes.model.border;
|
|
428
|
+
|
|
429
|
+
const x = Math.min(border[0], border[2], border[4], border[6]);
|
|
430
|
+
const y = Math.min(border[1], border[3], border[5], border[7]);
|
|
431
|
+
const width = Math.max(border[0], border[2], border[4], border[6]) - x;
|
|
432
|
+
const height = Math.max(border[1], border[3], border[5], border[7]) - y;
|
|
433
|
+
|
|
434
|
+
const result = await this.send('Page.captureScreenshot', {
|
|
435
|
+
format: 'png',
|
|
436
|
+
clip: { x, y, width, height, scale: 1 }
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
return Buffer.from(result.data, 'base64');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Set viewport size.
|
|
444
|
+
* @param {number} width - Viewport width
|
|
445
|
+
* @param {number} height - Viewport height
|
|
446
|
+
*/
|
|
447
|
+
async setViewport(width, height) {
|
|
448
|
+
await this.send('Emulation.setDeviceMetricsOverride', {
|
|
449
|
+
width,
|
|
450
|
+
height,
|
|
451
|
+
deviceScaleFactor: 1,
|
|
452
|
+
mobile: false
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Close the connection.
|
|
458
|
+
*/
|
|
459
|
+
close() {
|
|
460
|
+
if (this.ws) {
|
|
461
|
+
this.ws.close();
|
|
462
|
+
this.ws = null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ── Retry & Wait Helpers ──
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Retry a function with exponential backoff.
|
|
470
|
+
* @param {Function} fn - Async function to retry
|
|
471
|
+
* @param {Object} options - Retry options
|
|
472
|
+
* @param {number} options.retries - Max retry attempts (default: 3)
|
|
473
|
+
* @param {number} options.delay - Initial delay in ms (default: 100)
|
|
474
|
+
* @param {number} options.maxDelay - Max delay in ms (default: 2000)
|
|
475
|
+
* @returns {Promise<any>} Function result
|
|
476
|
+
*/
|
|
477
|
+
async retry(fn, { retries = 3, delay = 100, maxDelay = 2000 } = {}) {
|
|
478
|
+
let lastError;
|
|
479
|
+
for (let i = 0; i <= retries; i++) {
|
|
480
|
+
try {
|
|
481
|
+
return await fn();
|
|
482
|
+
} catch (err) {
|
|
483
|
+
lastError = err;
|
|
484
|
+
if (i < retries) {
|
|
485
|
+
const wait = Math.min(delay * Math.pow(2, i), maxDelay);
|
|
486
|
+
await new Promise(r => setTimeout(r, wait));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
throw lastError;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Wait for an element to appear in the DOM.
|
|
495
|
+
* @param {string} selector - CSS selector
|
|
496
|
+
* @param {Object} options - Wait options
|
|
497
|
+
* @param {number} options.timeout - Timeout in ms (default: 5000)
|
|
498
|
+
* @param {number} options.interval - Poll interval in ms (default: 100)
|
|
499
|
+
* @returns {Promise<{x: number, y: number, nodeId: number}>} Element info
|
|
500
|
+
*/
|
|
501
|
+
async waitForSelector(selector, { timeout = 5000, interval = 100 } = {}) {
|
|
502
|
+
const start = Date.now();
|
|
503
|
+
while (Date.now() - start < timeout) {
|
|
504
|
+
const el = await this.querySelector(selector);
|
|
505
|
+
if (el) return el;
|
|
506
|
+
await new Promise(r => setTimeout(r, interval));
|
|
507
|
+
}
|
|
508
|
+
throw new Error(`Timeout waiting for selector: ${selector}`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Wait for network to be idle (no requests for a period).
|
|
513
|
+
* @param {Object} options - Wait options
|
|
514
|
+
* @param {number} options.timeout - Timeout in ms (default: 10000)
|
|
515
|
+
* @param {number} options.idleTime - Idle time to wait in ms (default: 500)
|
|
516
|
+
* @returns {Promise<void>}
|
|
517
|
+
*/
|
|
518
|
+
async waitForNetworkIdle({ timeout = 10000, idleTime = 500 } = {}) {
|
|
519
|
+
await this.send('Network.enable');
|
|
520
|
+
|
|
521
|
+
return new Promise((resolve, reject) => {
|
|
522
|
+
let pending = 0;
|
|
523
|
+
let idleTimer = null;
|
|
524
|
+
const timeoutTimer = setTimeout(() => {
|
|
525
|
+
cleanup();
|
|
526
|
+
resolve(); // Don't reject on timeout, just continue
|
|
527
|
+
}, timeout);
|
|
528
|
+
|
|
529
|
+
const checkIdle = () => {
|
|
530
|
+
if (pending === 0) {
|
|
531
|
+
idleTimer = setTimeout(() => {
|
|
532
|
+
cleanup();
|
|
533
|
+
resolve();
|
|
534
|
+
}, idleTime);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const onRequestWillBeSent = () => {
|
|
539
|
+
pending++;
|
|
540
|
+
if (idleTimer) {
|
|
541
|
+
clearTimeout(idleTimer);
|
|
542
|
+
idleTimer = null;
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const onLoadingFinished = () => {
|
|
547
|
+
pending = Math.max(0, pending - 1);
|
|
548
|
+
checkIdle();
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const onLoadingFailed = () => {
|
|
552
|
+
pending = Math.max(0, pending - 1);
|
|
553
|
+
checkIdle();
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const cleanup = () => {
|
|
557
|
+
clearTimeout(timeoutTimer);
|
|
558
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
559
|
+
this.off('Network.requestWillBeSent', onRequestWillBeSent);
|
|
560
|
+
this.off('Network.loadingFinished', onLoadingFinished);
|
|
561
|
+
this.off('Network.loadingFailed', onLoadingFailed);
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
this.on('Network.requestWillBeSent', onRequestWillBeSent);
|
|
565
|
+
this.on('Network.loadingFinished', onLoadingFinished);
|
|
566
|
+
this.on('Network.loadingFailed', onLoadingFailed);
|
|
567
|
+
|
|
568
|
+
// Check if already idle
|
|
569
|
+
checkIdle();
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Wait for element to be visible (has dimensions).
|
|
575
|
+
* @param {string} selector - CSS selector
|
|
576
|
+
* @param {Object} options - Wait options
|
|
577
|
+
* @param {number} options.timeout - Timeout in ms (default: 5000)
|
|
578
|
+
* @returns {Promise<{x: number, y: number, nodeId: number}>} Element info
|
|
579
|
+
*/
|
|
580
|
+
async waitForVisible(selector, { timeout = 5000 } = {}) {
|
|
581
|
+
const start = Date.now();
|
|
582
|
+
while (Date.now() - start < timeout) {
|
|
583
|
+
try {
|
|
584
|
+
const el = await this.querySelector(selector);
|
|
585
|
+
if (el && el.x > 0 && el.y > 0) return el;
|
|
586
|
+
} catch {
|
|
587
|
+
// Element may not exist yet
|
|
588
|
+
}
|
|
589
|
+
await new Promise(r => setTimeout(r, 100));
|
|
590
|
+
}
|
|
591
|
+
throw new Error(`Timeout waiting for visible element: ${selector}`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Click an element with auto-retry and wait.
|
|
596
|
+
* @param {string} selector - CSS selector
|
|
597
|
+
* @param {Object} options - Click options
|
|
598
|
+
* @param {number} options.timeout - Wait timeout in ms (default: 5000)
|
|
599
|
+
* @param {number} options.retries - Retry attempts (default: 2)
|
|
600
|
+
*/
|
|
601
|
+
async clickWithRetry(selector, { timeout = 5000, retries = 2 } = {}) {
|
|
602
|
+
const el = await this.waitForVisible(selector, { timeout });
|
|
603
|
+
await this.retry(() => this.click(el.x, el.y), { retries });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Fill an input with auto-retry and wait.
|
|
608
|
+
* @param {string} selector - CSS selector
|
|
609
|
+
* @param {string} text - Text to fill
|
|
610
|
+
* @param {Object} options - Fill options
|
|
611
|
+
* @param {number} options.timeout - Wait timeout in ms (default: 5000)
|
|
612
|
+
*/
|
|
613
|
+
async fillWithRetry(selector, text, { timeout = 5000 } = {}) {
|
|
614
|
+
await this.waitForVisible(selector, { timeout });
|
|
615
|
+
await this.retry(() => this.fill(selector, text));
|
|
616
|
+
}
|
|
617
|
+
}
|
package/dotbot.db
CHANGED
|
Binary file
|
package/index.js
CHANGED
|
@@ -30,25 +30,20 @@ import {
|
|
|
30
30
|
export {
|
|
31
31
|
SessionStore,
|
|
32
32
|
SQLiteSessionStore,
|
|
33
|
-
MongoSessionStore,
|
|
34
33
|
MemorySessionStore,
|
|
35
34
|
defaultSystemPrompt,
|
|
36
35
|
CronStore,
|
|
37
|
-
MongoCronStore,
|
|
38
36
|
SQLiteCronStore,
|
|
39
37
|
parseInterval,
|
|
40
38
|
HEARTBEAT_INTERVAL_MS,
|
|
41
39
|
HEARTBEAT_PROMPT,
|
|
42
40
|
runWithConcurrency,
|
|
43
41
|
TaskStore,
|
|
44
|
-
MongoTaskStore,
|
|
45
42
|
SQLiteTaskStore,
|
|
46
43
|
// Backwards compatibility aliases
|
|
47
44
|
GoalStore,
|
|
48
|
-
MongoGoalStore,
|
|
49
45
|
SQLiteGoalStore,
|
|
50
46
|
TriggerStore,
|
|
51
|
-
MongoTriggerStore,
|
|
52
47
|
SQLiteTriggerStore,
|
|
53
48
|
SQLiteMemoryStore,
|
|
54
49
|
EventStore,
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevederico/dotbot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "AI agent CLI and library for Node.js — streaming, multi-provider, tool execution, autonomous tasks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"dotbot": "./bin/dotbot.js"
|
|
9
9
|
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test test/*.test.js"
|
|
12
|
+
},
|
|
10
13
|
"exports": {
|
|
11
14
|
".": "./index.js",
|
|
12
15
|
"./core/*": "./core/*.js",
|
|
@@ -28,12 +31,10 @@
|
|
|
28
31
|
],
|
|
29
32
|
"author": "Steve Derico",
|
|
30
33
|
"license": "MIT",
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
},
|
|
34
|
-
"dependencies": {
|
|
35
|
-
"playwright": "^1.58.2"
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22.0.0"
|
|
36
36
|
},
|
|
37
|
+
"dependencies": {},
|
|
37
38
|
"devDependencies": {},
|
|
38
39
|
"repository": {
|
|
39
40
|
"type": "git",
|
package/storage/MemoryStore.js
CHANGED