cbrowser 2.3.0 → 3.0.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/dist/browser.d.ts +278 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +943 -3
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +954 -8
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +31 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +42 -6
- package/dist/config.js.map +1 -1
- package/dist/types.d.ts +257 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +87 -0
- package/dist/types.js.map +1 -1
- package/package.json +9 -2
package/dist/browser.js
CHANGED
|
@@ -5,12 +5,16 @@
|
|
|
5
5
|
* AI-powered browser automation with constitutional safety.
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
-
exports.CBrowser = void 0;
|
|
8
|
+
exports.FluentCBrowser = exports.CBrowser = void 0;
|
|
9
|
+
exports.parseNaturalLanguage = parseNaturalLanguage;
|
|
10
|
+
exports.executeNaturalLanguage = executeNaturalLanguage;
|
|
11
|
+
exports.executeNaturalLanguageScript = executeNaturalLanguageScript;
|
|
9
12
|
const playwright_1 = require("playwright");
|
|
10
13
|
const fs_1 = require("fs");
|
|
11
14
|
const path_1 = require("path");
|
|
12
15
|
const config_js_1 = require("./config.js");
|
|
13
16
|
const personas_js_1 = require("./personas.js");
|
|
17
|
+
const types_js_1 = require("./types.js");
|
|
14
18
|
class CBrowser {
|
|
15
19
|
config;
|
|
16
20
|
paths;
|
|
@@ -18,6 +22,10 @@ class CBrowser {
|
|
|
18
22
|
context = null;
|
|
19
23
|
page = null;
|
|
20
24
|
currentPersona = null;
|
|
25
|
+
networkRequests = [];
|
|
26
|
+
networkResponses = new Map();
|
|
27
|
+
harEntries = [];
|
|
28
|
+
isRecordingHar = false;
|
|
21
29
|
constructor(userConfig = {}) {
|
|
22
30
|
this.config = (0, config_js_1.mergeConfig)(userConfig);
|
|
23
31
|
this.paths = (0, config_js_1.ensureDirectories)((0, config_js_1.getPaths)(this.config.dataDir));
|
|
@@ -40,13 +48,77 @@ class CBrowser {
|
|
|
40
48
|
this.browser = await browserType.launch({
|
|
41
49
|
headless: this.config.headless,
|
|
42
50
|
});
|
|
43
|
-
|
|
51
|
+
// Build context options
|
|
52
|
+
const contextOptions = {
|
|
44
53
|
viewport: {
|
|
45
54
|
width: this.config.viewportWidth,
|
|
46
55
|
height: this.config.viewportHeight,
|
|
47
56
|
},
|
|
48
|
-
}
|
|
57
|
+
};
|
|
58
|
+
// Apply device emulation if configured
|
|
59
|
+
if (this.config.device && types_js_1.DEVICE_PRESETS[this.config.device]) {
|
|
60
|
+
const device = types_js_1.DEVICE_PRESETS[this.config.device];
|
|
61
|
+
contextOptions.viewport = device.viewport;
|
|
62
|
+
contextOptions.userAgent = device.userAgent;
|
|
63
|
+
contextOptions.deviceScaleFactor = device.deviceScaleFactor;
|
|
64
|
+
contextOptions.isMobile = device.isMobile;
|
|
65
|
+
contextOptions.hasTouch = device.hasTouch;
|
|
66
|
+
}
|
|
67
|
+
else if (this.config.deviceDescriptor) {
|
|
68
|
+
const device = this.config.deviceDescriptor;
|
|
69
|
+
contextOptions.viewport = device.viewport;
|
|
70
|
+
contextOptions.userAgent = device.userAgent;
|
|
71
|
+
contextOptions.deviceScaleFactor = device.deviceScaleFactor;
|
|
72
|
+
contextOptions.isMobile = device.isMobile;
|
|
73
|
+
contextOptions.hasTouch = device.hasTouch;
|
|
74
|
+
}
|
|
75
|
+
// Apply custom user agent if set (overrides device)
|
|
76
|
+
if (this.config.userAgent) {
|
|
77
|
+
contextOptions.userAgent = this.config.userAgent;
|
|
78
|
+
}
|
|
79
|
+
// Apply geolocation if configured
|
|
80
|
+
if (this.config.geolocation) {
|
|
81
|
+
contextOptions.geolocation = {
|
|
82
|
+
latitude: this.config.geolocation.latitude,
|
|
83
|
+
longitude: this.config.geolocation.longitude,
|
|
84
|
+
accuracy: this.config.geolocation.accuracy,
|
|
85
|
+
};
|
|
86
|
+
contextOptions.permissions = ["geolocation"];
|
|
87
|
+
}
|
|
88
|
+
// Apply locale if configured
|
|
89
|
+
if (this.config.locale) {
|
|
90
|
+
contextOptions.locale = this.config.locale;
|
|
91
|
+
}
|
|
92
|
+
// Apply timezone if configured
|
|
93
|
+
if (this.config.timezone) {
|
|
94
|
+
contextOptions.timezoneId = this.config.timezone;
|
|
95
|
+
}
|
|
96
|
+
// Apply color scheme if configured
|
|
97
|
+
if (this.config.colorScheme) {
|
|
98
|
+
contextOptions.colorScheme = this.config.colorScheme;
|
|
99
|
+
}
|
|
100
|
+
// Enable video recording if configured
|
|
101
|
+
if (this.config.recordVideo) {
|
|
102
|
+
const videoDir = this.config.videoDir || (0, path_1.join)(this.paths.dataDir, "videos");
|
|
103
|
+
if (!(0, fs_1.existsSync)(videoDir)) {
|
|
104
|
+
(0, fs_1.mkdirSync)(videoDir, { recursive: true });
|
|
105
|
+
}
|
|
106
|
+
contextOptions.recordVideo = {
|
|
107
|
+
dir: videoDir,
|
|
108
|
+
size: {
|
|
109
|
+
width: contextOptions.viewport?.width || 1280,
|
|
110
|
+
height: contextOptions.viewport?.height || 800,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
this.context = await this.browser.newContext(contextOptions);
|
|
49
115
|
this.page = await this.context.newPage();
|
|
116
|
+
// Apply network mocks if configured
|
|
117
|
+
if (this.config.networkMocks && this.config.networkMocks.length > 0) {
|
|
118
|
+
await this.setupNetworkMocks(this.config.networkMocks);
|
|
119
|
+
}
|
|
120
|
+
// Set up network request/response tracking for HAR
|
|
121
|
+
this.setupNetworkTracking();
|
|
50
122
|
}
|
|
51
123
|
/**
|
|
52
124
|
* Close the browser.
|
|
@@ -69,6 +141,423 @@ class CBrowser {
|
|
|
69
141
|
return this.page;
|
|
70
142
|
}
|
|
71
143
|
// =========================================================================
|
|
144
|
+
// Network Mocking
|
|
145
|
+
// =========================================================================
|
|
146
|
+
/**
|
|
147
|
+
* Set up network mocks for API interception.
|
|
148
|
+
*/
|
|
149
|
+
async setupNetworkMocks(mocks) {
|
|
150
|
+
const page = this.page;
|
|
151
|
+
for (const mock of mocks) {
|
|
152
|
+
const pattern = typeof mock.urlPattern === "string"
|
|
153
|
+
? new RegExp(mock.urlPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
154
|
+
: mock.urlPattern;
|
|
155
|
+
await page.route(pattern, async (route) => {
|
|
156
|
+
const request = route.request();
|
|
157
|
+
// Check method match if specified
|
|
158
|
+
if (mock.method && request.method() !== mock.method.toUpperCase()) {
|
|
159
|
+
await route.continue();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Handle abort
|
|
163
|
+
if (mock.abort) {
|
|
164
|
+
await route.abort();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Apply delay if specified
|
|
168
|
+
if (mock.delay) {
|
|
169
|
+
await new Promise((r) => setTimeout(r, mock.delay));
|
|
170
|
+
}
|
|
171
|
+
// Fulfill with mock response
|
|
172
|
+
const body = typeof mock.body === "object"
|
|
173
|
+
? JSON.stringify(mock.body)
|
|
174
|
+
: mock.body || "";
|
|
175
|
+
await route.fulfill({
|
|
176
|
+
status: mock.status || 200,
|
|
177
|
+
headers: mock.headers || { "Content-Type": "application/json" },
|
|
178
|
+
body,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Add a network mock at runtime.
|
|
185
|
+
*/
|
|
186
|
+
async addNetworkMock(mock) {
|
|
187
|
+
const page = await this.getPage();
|
|
188
|
+
await this.setupNetworkMocks([mock]);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Clear all network mocks.
|
|
192
|
+
*/
|
|
193
|
+
async clearNetworkMocks() {
|
|
194
|
+
const page = await this.getPage();
|
|
195
|
+
await page.unrouteAll();
|
|
196
|
+
}
|
|
197
|
+
// =========================================================================
|
|
198
|
+
// Network Tracking & HAR Export
|
|
199
|
+
// =========================================================================
|
|
200
|
+
/**
|
|
201
|
+
* Set up network request/response tracking for HAR.
|
|
202
|
+
*/
|
|
203
|
+
setupNetworkTracking() {
|
|
204
|
+
if (!this.page)
|
|
205
|
+
return;
|
|
206
|
+
this.page.on("request", (request) => {
|
|
207
|
+
const networkRequest = {
|
|
208
|
+
url: request.url(),
|
|
209
|
+
method: request.method(),
|
|
210
|
+
headers: request.headers(),
|
|
211
|
+
postData: request.postData() || undefined,
|
|
212
|
+
resourceType: request.resourceType(),
|
|
213
|
+
timestamp: new Date().toISOString(),
|
|
214
|
+
};
|
|
215
|
+
this.networkRequests.push(networkRequest);
|
|
216
|
+
if (this.isRecordingHar) {
|
|
217
|
+
// Start HAR entry
|
|
218
|
+
const harEntry = {
|
|
219
|
+
startedDateTime: new Date().toISOString(),
|
|
220
|
+
request: {
|
|
221
|
+
method: request.method(),
|
|
222
|
+
url: request.url(),
|
|
223
|
+
httpVersion: "HTTP/1.1",
|
|
224
|
+
headers: Object.entries(request.headers()).map(([name, value]) => ({ name, value })),
|
|
225
|
+
queryString: [],
|
|
226
|
+
headersSize: -1,
|
|
227
|
+
bodySize: request.postData()?.length || 0,
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
this.networkResponses.set(request.url() + request.method(), harEntry);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
this.page.on("response", async (response) => {
|
|
234
|
+
const key = response.url() + response.request().method();
|
|
235
|
+
const networkResponse = {
|
|
236
|
+
url: response.url(),
|
|
237
|
+
status: response.status(),
|
|
238
|
+
statusText: response.statusText(),
|
|
239
|
+
headers: response.headers(),
|
|
240
|
+
};
|
|
241
|
+
if (this.isRecordingHar && this.networkResponses.has(key)) {
|
|
242
|
+
const partial = this.networkResponses.get(key);
|
|
243
|
+
const startTime = new Date(partial.startedDateTime).getTime();
|
|
244
|
+
const endTime = Date.now();
|
|
245
|
+
const harEntry = {
|
|
246
|
+
...partial,
|
|
247
|
+
time: endTime - startTime,
|
|
248
|
+
response: {
|
|
249
|
+
status: response.status(),
|
|
250
|
+
statusText: response.statusText(),
|
|
251
|
+
httpVersion: "HTTP/1.1",
|
|
252
|
+
headers: Object.entries(response.headers()).map(([name, value]) => ({ name, value })),
|
|
253
|
+
content: {
|
|
254
|
+
size: parseInt(response.headers()["content-length"] || "0"),
|
|
255
|
+
mimeType: response.headers()["content-type"] || "application/octet-stream",
|
|
256
|
+
},
|
|
257
|
+
redirectURL: response.headers()["location"] || "",
|
|
258
|
+
headersSize: -1,
|
|
259
|
+
bodySize: parseInt(response.headers()["content-length"] || "-1"),
|
|
260
|
+
},
|
|
261
|
+
cache: {},
|
|
262
|
+
timings: {
|
|
263
|
+
blocked: 0,
|
|
264
|
+
dns: -1,
|
|
265
|
+
connect: -1,
|
|
266
|
+
send: 0,
|
|
267
|
+
wait: endTime - startTime,
|
|
268
|
+
receive: 0,
|
|
269
|
+
ssl: -1,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
this.harEntries.push(harEntry);
|
|
273
|
+
this.networkResponses.delete(key);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Start recording HAR.
|
|
279
|
+
*/
|
|
280
|
+
startHarRecording() {
|
|
281
|
+
this.isRecordingHar = true;
|
|
282
|
+
this.harEntries = [];
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Stop recording and export HAR.
|
|
286
|
+
*/
|
|
287
|
+
async exportHar(outputPath) {
|
|
288
|
+
this.isRecordingHar = false;
|
|
289
|
+
const harLog = {
|
|
290
|
+
version: "1.2",
|
|
291
|
+
creator: { name: "CBrowser", version: "2.4.0" },
|
|
292
|
+
entries: this.harEntries,
|
|
293
|
+
};
|
|
294
|
+
const har = { log: harLog };
|
|
295
|
+
const harDir = (0, path_1.join)(this.paths.dataDir, "har");
|
|
296
|
+
if (!(0, fs_1.existsSync)(harDir)) {
|
|
297
|
+
(0, fs_1.mkdirSync)(harDir, { recursive: true });
|
|
298
|
+
}
|
|
299
|
+
const filename = outputPath || (0, path_1.join)(harDir, `har-${Date.now()}.har`);
|
|
300
|
+
(0, fs_1.writeFileSync)(filename, JSON.stringify(har, null, 2));
|
|
301
|
+
return filename;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Get all captured network requests.
|
|
305
|
+
*/
|
|
306
|
+
getNetworkRequests() {
|
|
307
|
+
return [...this.networkRequests];
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Clear network request history.
|
|
311
|
+
*/
|
|
312
|
+
clearNetworkHistory() {
|
|
313
|
+
this.networkRequests = [];
|
|
314
|
+
this.harEntries = [];
|
|
315
|
+
}
|
|
316
|
+
// =========================================================================
|
|
317
|
+
// Performance Metrics
|
|
318
|
+
// =========================================================================
|
|
319
|
+
/**
|
|
320
|
+
* Collect Core Web Vitals and performance metrics.
|
|
321
|
+
*/
|
|
322
|
+
async getPerformanceMetrics() {
|
|
323
|
+
const page = await this.getPage();
|
|
324
|
+
const metrics = await page.evaluate(() => {
|
|
325
|
+
const result = {};
|
|
326
|
+
// Navigation timing
|
|
327
|
+
const navTiming = performance.getEntriesByType("navigation")[0];
|
|
328
|
+
if (navTiming) {
|
|
329
|
+
result.ttfb = navTiming.responseStart - navTiming.requestStart;
|
|
330
|
+
result.domContentLoaded = navTiming.domContentLoadedEventEnd - navTiming.startTime;
|
|
331
|
+
result.load = navTiming.loadEventEnd - navTiming.startTime;
|
|
332
|
+
}
|
|
333
|
+
// Paint timing
|
|
334
|
+
const paintEntries = performance.getEntriesByType("paint");
|
|
335
|
+
for (const entry of paintEntries) {
|
|
336
|
+
if (entry.name === "first-contentful-paint") {
|
|
337
|
+
result.fcp = entry.startTime;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// LCP from PerformanceObserver (if available)
|
|
341
|
+
const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
|
|
342
|
+
if (lcpEntries.length > 0) {
|
|
343
|
+
result.lcp = lcpEntries[lcpEntries.length - 1].startTime;
|
|
344
|
+
}
|
|
345
|
+
// CLS from layout-shift entries
|
|
346
|
+
const clsEntries = performance.getEntriesByType("layout-shift");
|
|
347
|
+
let clsScore = 0;
|
|
348
|
+
for (const entry of clsEntries) {
|
|
349
|
+
if (!entry.hadRecentInput) {
|
|
350
|
+
clsScore += entry.value || 0;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
result.cls = clsScore;
|
|
354
|
+
// Resource counts
|
|
355
|
+
const resources = performance.getEntriesByType("resource");
|
|
356
|
+
result.resourceCount = resources.length;
|
|
357
|
+
result.transferSize = resources.reduce((sum, r) => sum + (r.transferSize || 0), 0);
|
|
358
|
+
return result;
|
|
359
|
+
});
|
|
360
|
+
// Rate the metrics
|
|
361
|
+
const lcpRating = metrics.lcp
|
|
362
|
+
? metrics.lcp <= 2500 ? "good" : metrics.lcp <= 4000 ? "needs-improvement" : "poor"
|
|
363
|
+
: undefined;
|
|
364
|
+
const clsRating = metrics.cls !== undefined
|
|
365
|
+
? metrics.cls <= 0.1 ? "good" : metrics.cls <= 0.25 ? "needs-improvement" : "poor"
|
|
366
|
+
: undefined;
|
|
367
|
+
return {
|
|
368
|
+
lcp: metrics.lcp,
|
|
369
|
+
cls: metrics.cls,
|
|
370
|
+
fcp: metrics.fcp,
|
|
371
|
+
ttfb: metrics.ttfb,
|
|
372
|
+
domContentLoaded: metrics.domContentLoaded,
|
|
373
|
+
load: metrics.load,
|
|
374
|
+
resourceCount: metrics.resourceCount,
|
|
375
|
+
transferSize: metrics.transferSize,
|
|
376
|
+
lcpRating: lcpRating,
|
|
377
|
+
clsRating: clsRating,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Run a performance audit against a budget.
|
|
382
|
+
*/
|
|
383
|
+
async auditPerformance(url) {
|
|
384
|
+
if (url) {
|
|
385
|
+
await this.navigate(url);
|
|
386
|
+
}
|
|
387
|
+
const page = await this.getPage();
|
|
388
|
+
const metrics = await this.getPerformanceMetrics();
|
|
389
|
+
const budget = this.config.performanceBudget;
|
|
390
|
+
const violations = [];
|
|
391
|
+
let passed = true;
|
|
392
|
+
if (budget) {
|
|
393
|
+
if (budget.lcp && metrics.lcp && metrics.lcp > budget.lcp) {
|
|
394
|
+
violations.push(`LCP ${metrics.lcp}ms exceeds budget ${budget.lcp}ms`);
|
|
395
|
+
passed = false;
|
|
396
|
+
}
|
|
397
|
+
if (budget.fcp && metrics.fcp && metrics.fcp > budget.fcp) {
|
|
398
|
+
violations.push(`FCP ${metrics.fcp}ms exceeds budget ${budget.fcp}ms`);
|
|
399
|
+
passed = false;
|
|
400
|
+
}
|
|
401
|
+
if (budget.cls && metrics.cls && metrics.cls > budget.cls) {
|
|
402
|
+
violations.push(`CLS ${metrics.cls} exceeds budget ${budget.cls}`);
|
|
403
|
+
passed = false;
|
|
404
|
+
}
|
|
405
|
+
if (budget.ttfb && metrics.ttfb && metrics.ttfb > budget.ttfb) {
|
|
406
|
+
violations.push(`TTFB ${metrics.ttfb}ms exceeds budget ${budget.ttfb}ms`);
|
|
407
|
+
passed = false;
|
|
408
|
+
}
|
|
409
|
+
if (budget.transferSize && metrics.transferSize && metrics.transferSize > budget.transferSize) {
|
|
410
|
+
violations.push(`Transfer size ${metrics.transferSize}B exceeds budget ${budget.transferSize}B`);
|
|
411
|
+
passed = false;
|
|
412
|
+
}
|
|
413
|
+
if (budget.resourceCount && metrics.resourceCount && metrics.resourceCount > budget.resourceCount) {
|
|
414
|
+
violations.push(`Resource count ${metrics.resourceCount} exceeds budget ${budget.resourceCount}`);
|
|
415
|
+
passed = false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
url: page.url(),
|
|
420
|
+
timestamp: new Date().toISOString(),
|
|
421
|
+
metrics,
|
|
422
|
+
budget,
|
|
423
|
+
passed,
|
|
424
|
+
violations,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
// =========================================================================
|
|
428
|
+
// Cookie Management
|
|
429
|
+
// =========================================================================
|
|
430
|
+
/**
|
|
431
|
+
* Get all cookies for the current context.
|
|
432
|
+
*/
|
|
433
|
+
async getCookies(urls) {
|
|
434
|
+
if (!this.context) {
|
|
435
|
+
await this.launch();
|
|
436
|
+
}
|
|
437
|
+
return await this.context.cookies(urls);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Set cookies.
|
|
441
|
+
*/
|
|
442
|
+
async setCookies(cookies) {
|
|
443
|
+
if (!this.context) {
|
|
444
|
+
await this.launch();
|
|
445
|
+
}
|
|
446
|
+
await this.context.addCookies(cookies);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Clear all cookies.
|
|
450
|
+
*/
|
|
451
|
+
async clearCookies() {
|
|
452
|
+
if (!this.context)
|
|
453
|
+
return;
|
|
454
|
+
await this.context.clearCookies();
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Delete specific cookies by name.
|
|
458
|
+
*/
|
|
459
|
+
async deleteCookie(name, domain) {
|
|
460
|
+
const cookies = await this.getCookies();
|
|
461
|
+
const filtered = cookies.filter((c) => {
|
|
462
|
+
if (c.name !== name)
|
|
463
|
+
return true;
|
|
464
|
+
if (domain && c.domain !== domain)
|
|
465
|
+
return true;
|
|
466
|
+
return false;
|
|
467
|
+
});
|
|
468
|
+
await this.clearCookies();
|
|
469
|
+
if (filtered.length > 0) {
|
|
470
|
+
await this.setCookies(filtered);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// =========================================================================
|
|
474
|
+
// Video Recording
|
|
475
|
+
// =========================================================================
|
|
476
|
+
/**
|
|
477
|
+
* Get the path to the video file (after browser closes).
|
|
478
|
+
*/
|
|
479
|
+
async getVideoPath() {
|
|
480
|
+
if (!this.page)
|
|
481
|
+
return null;
|
|
482
|
+
const video = this.page.video();
|
|
483
|
+
if (!video)
|
|
484
|
+
return null;
|
|
485
|
+
return await video.path();
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Save the video with a custom filename.
|
|
489
|
+
*/
|
|
490
|
+
async saveVideo(outputPath) {
|
|
491
|
+
if (!this.page)
|
|
492
|
+
return null;
|
|
493
|
+
const video = this.page.video();
|
|
494
|
+
if (!video)
|
|
495
|
+
return null;
|
|
496
|
+
await video.saveAs(outputPath);
|
|
497
|
+
return outputPath;
|
|
498
|
+
}
|
|
499
|
+
// =========================================================================
|
|
500
|
+
// Device Emulation
|
|
501
|
+
// =========================================================================
|
|
502
|
+
/**
|
|
503
|
+
* Set device emulation (requires browser restart).
|
|
504
|
+
*/
|
|
505
|
+
setDevice(deviceName) {
|
|
506
|
+
if (types_js_1.DEVICE_PRESETS[deviceName]) {
|
|
507
|
+
this.config.device = deviceName;
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* List available device presets.
|
|
514
|
+
*/
|
|
515
|
+
static listDevices() {
|
|
516
|
+
return Object.keys(types_js_1.DEVICE_PRESETS);
|
|
517
|
+
}
|
|
518
|
+
// =========================================================================
|
|
519
|
+
// Geolocation
|
|
520
|
+
// =========================================================================
|
|
521
|
+
/**
|
|
522
|
+
* Set geolocation (requires browser restart or use setGeolocationRuntime).
|
|
523
|
+
*/
|
|
524
|
+
setGeolocation(location) {
|
|
525
|
+
if (typeof location === "string") {
|
|
526
|
+
if (types_js_1.LOCATION_PRESETS[location]) {
|
|
527
|
+
this.config.geolocation = types_js_1.LOCATION_PRESETS[location];
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
this.config.geolocation = location;
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Set geolocation at runtime without restarting.
|
|
537
|
+
*/
|
|
538
|
+
async setGeolocationRuntime(location) {
|
|
539
|
+
if (!this.context)
|
|
540
|
+
return false;
|
|
541
|
+
let geo;
|
|
542
|
+
if (typeof location === "string") {
|
|
543
|
+
if (!types_js_1.LOCATION_PRESETS[location])
|
|
544
|
+
return false;
|
|
545
|
+
geo = types_js_1.LOCATION_PRESETS[location];
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
geo = location;
|
|
549
|
+
}
|
|
550
|
+
await this.context.setGeolocation(geo);
|
|
551
|
+
await this.context.grantPermissions(["geolocation"]);
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* List available location presets.
|
|
556
|
+
*/
|
|
557
|
+
static listLocations() {
|
|
558
|
+
return Object.keys(types_js_1.LOCATION_PRESETS);
|
|
559
|
+
}
|
|
560
|
+
// =========================================================================
|
|
72
561
|
// Navigation
|
|
73
562
|
// =========================================================================
|
|
74
563
|
/**
|
|
@@ -620,6 +1109,457 @@ class CBrowser {
|
|
|
620
1109
|
stats.audit = countDir(this.paths.auditDir, /\.json$/i);
|
|
621
1110
|
return stats;
|
|
622
1111
|
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Get the data directory path.
|
|
1114
|
+
*/
|
|
1115
|
+
getDataDir() {
|
|
1116
|
+
return this.paths.dataDir;
|
|
1117
|
+
}
|
|
1118
|
+
// =========================================================================
|
|
1119
|
+
// Tier 2: Visual Regression (v2.5.0)
|
|
1120
|
+
// =========================================================================
|
|
1121
|
+
/**
|
|
1122
|
+
* Save a visual baseline screenshot.
|
|
1123
|
+
*/
|
|
1124
|
+
async saveBaseline(name, url) {
|
|
1125
|
+
const baselinesDir = (0, path_1.join)(this.paths.dataDir, "baselines");
|
|
1126
|
+
if (!(0, fs_1.existsSync)(baselinesDir)) {
|
|
1127
|
+
(0, fs_1.mkdirSync)(baselinesDir, { recursive: true });
|
|
1128
|
+
}
|
|
1129
|
+
const page = await this.getPage();
|
|
1130
|
+
const screenshotPath = (0, path_1.join)(baselinesDir, `${name}.png`);
|
|
1131
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
1132
|
+
const baseline = {
|
|
1133
|
+
name,
|
|
1134
|
+
url: url || page.url(),
|
|
1135
|
+
viewport: page.viewportSize() || { width: 1280, height: 800 },
|
|
1136
|
+
screenshotPath,
|
|
1137
|
+
created: new Date().toISOString(),
|
|
1138
|
+
lastUsed: new Date().toISOString(),
|
|
1139
|
+
};
|
|
1140
|
+
const metaPath = (0, path_1.join)(baselinesDir, `${name}.json`);
|
|
1141
|
+
(0, fs_1.writeFileSync)(metaPath, JSON.stringify(baseline, null, 2));
|
|
1142
|
+
return screenshotPath;
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Compare current page to a baseline.
|
|
1146
|
+
*/
|
|
1147
|
+
async compareBaseline(name, threshold = 0.1) {
|
|
1148
|
+
const baselinesDir = (0, path_1.join)(this.paths.dataDir, "baselines");
|
|
1149
|
+
const metaPath = (0, path_1.join)(baselinesDir, `${name}.json`);
|
|
1150
|
+
if (!(0, fs_1.existsSync)(metaPath)) {
|
|
1151
|
+
throw new Error(`Baseline not found: ${name}`);
|
|
1152
|
+
}
|
|
1153
|
+
const baseline = JSON.parse((0, fs_1.readFileSync)(metaPath, "utf-8"));
|
|
1154
|
+
const page = await this.getPage();
|
|
1155
|
+
const currentPath = (0, path_1.join)(baselinesDir, `${name}-current-${Date.now()}.png`);
|
|
1156
|
+
await page.screenshot({ path: currentPath, fullPage: true });
|
|
1157
|
+
const baselineBuffer = (0, fs_1.readFileSync)(baseline.screenshotPath);
|
|
1158
|
+
const currentBuffer = (0, fs_1.readFileSync)(currentPath);
|
|
1159
|
+
const sizeDiff = Math.abs(baselineBuffer.length - currentBuffer.length);
|
|
1160
|
+
const maxSize = Math.max(baselineBuffer.length, currentBuffer.length);
|
|
1161
|
+
const diffPercentage = sizeDiff / maxSize;
|
|
1162
|
+
return {
|
|
1163
|
+
baseline: baseline.screenshotPath,
|
|
1164
|
+
current: currentPath,
|
|
1165
|
+
diffPercentage,
|
|
1166
|
+
passed: diffPercentage <= threshold,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* List all visual baselines.
|
|
1171
|
+
*/
|
|
1172
|
+
listBaselines() {
|
|
1173
|
+
const baselinesDir = (0, path_1.join)(this.paths.dataDir, "baselines");
|
|
1174
|
+
if (!(0, fs_1.existsSync)(baselinesDir))
|
|
1175
|
+
return [];
|
|
1176
|
+
return (0, fs_1.readdirSync)(baselinesDir)
|
|
1177
|
+
.filter(f => f.endsWith(".json"))
|
|
1178
|
+
.map(f => f.replace(".json", ""));
|
|
1179
|
+
}
|
|
1180
|
+
// =========================================================================
|
|
1181
|
+
// Tier 2: Accessibility Audit (v2.5.0)
|
|
1182
|
+
// =========================================================================
|
|
1183
|
+
/**
|
|
1184
|
+
* Run accessibility audit on current page.
|
|
1185
|
+
*/
|
|
1186
|
+
async auditAccessibility() {
|
|
1187
|
+
const page = await this.getPage();
|
|
1188
|
+
const results = await page.evaluate(() => {
|
|
1189
|
+
const violations = [];
|
|
1190
|
+
// Check images without alt
|
|
1191
|
+
document.querySelectorAll("img").forEach(img => {
|
|
1192
|
+
if (!img.alt && !img.getAttribute("aria-label")) {
|
|
1193
|
+
violations.push({
|
|
1194
|
+
id: "img-alt",
|
|
1195
|
+
impact: "serious",
|
|
1196
|
+
description: "Image missing alt text",
|
|
1197
|
+
helpUrl: "https://dequeuniversity.com/rules/axe/4.4/image-alt",
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
// Check buttons without text
|
|
1202
|
+
document.querySelectorAll("button").forEach(btn => {
|
|
1203
|
+
if (!btn.textContent?.trim() && !btn.getAttribute("aria-label")) {
|
|
1204
|
+
violations.push({
|
|
1205
|
+
id: "button-name",
|
|
1206
|
+
impact: "critical",
|
|
1207
|
+
description: "Button has no accessible name",
|
|
1208
|
+
helpUrl: "https://dequeuniversity.com/rules/axe/4.4/button-name",
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
// Check inputs without labels
|
|
1213
|
+
document.querySelectorAll("input:not([type='hidden'])").forEach(input => {
|
|
1214
|
+
const id = input.id;
|
|
1215
|
+
const hasLabel = id && document.querySelector(`label[for="${id}"]`);
|
|
1216
|
+
if (!hasLabel && !input.getAttribute("aria-label")) {
|
|
1217
|
+
violations.push({
|
|
1218
|
+
id: "label",
|
|
1219
|
+
impact: "serious",
|
|
1220
|
+
description: "Form input missing label",
|
|
1221
|
+
helpUrl: "https://dequeuniversity.com/rules/axe/4.4/label",
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
// Check lang attribute
|
|
1226
|
+
if (!document.documentElement.lang) {
|
|
1227
|
+
violations.push({
|
|
1228
|
+
id: "html-has-lang",
|
|
1229
|
+
impact: "serious",
|
|
1230
|
+
description: "Page missing lang attribute",
|
|
1231
|
+
helpUrl: "https://dequeuniversity.com/rules/axe/4.4/html-has-lang",
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
const passes = document.querySelectorAll("img[alt], button:not(:empty), label").length;
|
|
1235
|
+
return { violations, passes };
|
|
1236
|
+
});
|
|
1237
|
+
const score = results.passes > 0
|
|
1238
|
+
? Math.round((results.passes / (results.passes + results.violations.length)) * 100)
|
|
1239
|
+
: 100;
|
|
1240
|
+
return {
|
|
1241
|
+
url: page.url(),
|
|
1242
|
+
violations: results.violations,
|
|
1243
|
+
passes: results.passes,
|
|
1244
|
+
score,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
// =========================================================================
|
|
1248
|
+
// Tier 2: Test Recording (v2.5.0)
|
|
1249
|
+
// =========================================================================
|
|
1250
|
+
recordingActions = [];
|
|
1251
|
+
isRecording = false;
|
|
1252
|
+
/**
|
|
1253
|
+
* Start recording user interactions.
|
|
1254
|
+
*/
|
|
1255
|
+
async startRecording(url) {
|
|
1256
|
+
this.isRecording = true;
|
|
1257
|
+
this.recordingActions = [];
|
|
1258
|
+
if (url) {
|
|
1259
|
+
await this.navigate(url);
|
|
1260
|
+
this.recordingActions.push({ type: "navigate", url, timestamp: Date.now() });
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Stop recording and return actions.
|
|
1265
|
+
*/
|
|
1266
|
+
stopRecording() {
|
|
1267
|
+
this.isRecording = false;
|
|
1268
|
+
return [...this.recordingActions];
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Save recording to file.
|
|
1272
|
+
*/
|
|
1273
|
+
saveRecording(name, actions) {
|
|
1274
|
+
const recordingsDir = (0, path_1.join)(this.paths.dataDir, "recordings");
|
|
1275
|
+
if (!(0, fs_1.existsSync)(recordingsDir)) {
|
|
1276
|
+
(0, fs_1.mkdirSync)(recordingsDir, { recursive: true });
|
|
1277
|
+
}
|
|
1278
|
+
const recording = {
|
|
1279
|
+
name,
|
|
1280
|
+
actions: actions || this.recordingActions,
|
|
1281
|
+
created: new Date().toISOString(),
|
|
1282
|
+
};
|
|
1283
|
+
const filePath = (0, path_1.join)(recordingsDir, `${name}.json`);
|
|
1284
|
+
(0, fs_1.writeFileSync)(filePath, JSON.stringify(recording, null, 2));
|
|
1285
|
+
return filePath;
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Generate test code from recording.
|
|
1289
|
+
*/
|
|
1290
|
+
generateTestCode(name, actions) {
|
|
1291
|
+
let code = `// Generated test: ${name}\n\n`;
|
|
1292
|
+
code += `import { CBrowser } from 'cbrowser';\n\n`;
|
|
1293
|
+
code += `async function test_${name.replace(/[^a-zA-Z0-9]/g, "_")}() {\n`;
|
|
1294
|
+
code += ` const browser = new CBrowser();\n\n`;
|
|
1295
|
+
for (const action of actions) {
|
|
1296
|
+
switch (action.type) {
|
|
1297
|
+
case "navigate":
|
|
1298
|
+
code += ` await browser.navigate("${action.url}");\n`;
|
|
1299
|
+
break;
|
|
1300
|
+
case "click":
|
|
1301
|
+
code += ` await browser.click("${action.selector}");\n`;
|
|
1302
|
+
break;
|
|
1303
|
+
case "fill":
|
|
1304
|
+
code += ` await browser.fill("${action.selector}", "${action.value}");\n`;
|
|
1305
|
+
break;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
code += `\n await browser.close();\n`;
|
|
1309
|
+
code += `}\n\n`;
|
|
1310
|
+
code += `test_${name.replace(/[^a-zA-Z0-9]/g, "_")}();\n`;
|
|
1311
|
+
return code;
|
|
1312
|
+
}
|
|
1313
|
+
// =========================================================================
|
|
1314
|
+
// Tier 2: Test Export (v2.5.0)
|
|
1315
|
+
// =========================================================================
|
|
1316
|
+
/**
|
|
1317
|
+
* Export test results as JUnit XML.
|
|
1318
|
+
*/
|
|
1319
|
+
exportJUnit(suite, outputPath) {
|
|
1320
|
+
const filename = outputPath || (0, path_1.join)(this.paths.dataDir, `junit-${Date.now()}.xml`);
|
|
1321
|
+
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
|
1322
|
+
xml += `<testsuite name="${suite.name}" tests="${suite.tests.length}">\n`;
|
|
1323
|
+
for (const test of suite.tests) {
|
|
1324
|
+
xml += ` <testcase name="${test.name}" time="${(test.duration / 1000).toFixed(3)}">\n`;
|
|
1325
|
+
if (test.status === "failed" && test.error) {
|
|
1326
|
+
xml += ` <failure message="${test.error.replace(/"/g, """)}">${test.error}</failure>\n`;
|
|
1327
|
+
}
|
|
1328
|
+
xml += ` </testcase>\n`;
|
|
1329
|
+
}
|
|
1330
|
+
xml += `</testsuite>\n`;
|
|
1331
|
+
(0, fs_1.writeFileSync)(filename, xml);
|
|
1332
|
+
return filename;
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Export test results as TAP format.
|
|
1336
|
+
*/
|
|
1337
|
+
exportTAP(suite, outputPath) {
|
|
1338
|
+
const filename = outputPath || (0, path_1.join)(this.paths.dataDir, `tap-${Date.now()}.tap`);
|
|
1339
|
+
let tap = `TAP version 13\n`;
|
|
1340
|
+
tap += `1..${suite.tests.length}\n`;
|
|
1341
|
+
suite.tests.forEach((test, i) => {
|
|
1342
|
+
const status = test.status === "passed" ? "ok" : "not ok";
|
|
1343
|
+
tap += `${status} ${i + 1} ${test.name}\n`;
|
|
1344
|
+
});
|
|
1345
|
+
(0, fs_1.writeFileSync)(filename, tap);
|
|
1346
|
+
return filename;
|
|
1347
|
+
}
|
|
1348
|
+
// =========================================================================
|
|
1349
|
+
// Tier 2: Parallel Execution (v2.5.0)
|
|
1350
|
+
// =========================================================================
|
|
1351
|
+
/**
|
|
1352
|
+
* Run multiple browser tasks in parallel.
|
|
1353
|
+
*/
|
|
1354
|
+
static async parallel(tasks, options = {}) {
|
|
1355
|
+
const maxConcurrency = options.maxConcurrency || tasks.length;
|
|
1356
|
+
const results = [];
|
|
1357
|
+
// Process tasks in batches
|
|
1358
|
+
for (let i = 0; i < tasks.length; i += maxConcurrency) {
|
|
1359
|
+
const batch = tasks.slice(i, i + maxConcurrency);
|
|
1360
|
+
const batchResults = await Promise.all(batch.map(async (task) => {
|
|
1361
|
+
const startTime = Date.now();
|
|
1362
|
+
const browser = new CBrowser(task.config || {});
|
|
1363
|
+
try {
|
|
1364
|
+
const result = await task.run(browser);
|
|
1365
|
+
return {
|
|
1366
|
+
name: task.name,
|
|
1367
|
+
result,
|
|
1368
|
+
duration: Date.now() - startTime,
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
catch (e) {
|
|
1372
|
+
return {
|
|
1373
|
+
name: task.name,
|
|
1374
|
+
error: e.message,
|
|
1375
|
+
duration: Date.now() - startTime,
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
finally {
|
|
1379
|
+
await browser.close();
|
|
1380
|
+
}
|
|
1381
|
+
}));
|
|
1382
|
+
results.push(...batchResults);
|
|
1383
|
+
}
|
|
1384
|
+
return results;
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Run the same task across multiple device configurations in parallel.
|
|
1388
|
+
*/
|
|
1389
|
+
static async parallelDevices(devices, run, options = {}) {
|
|
1390
|
+
const tasks = devices.map(device => ({
|
|
1391
|
+
name: device,
|
|
1392
|
+
config: { device },
|
|
1393
|
+
run: (browser) => run(browser, device),
|
|
1394
|
+
}));
|
|
1395
|
+
const results = await CBrowser.parallel(tasks, options);
|
|
1396
|
+
return results.map(r => ({ device: r.name, ...r }));
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Run the same task across multiple URLs in parallel.
|
|
1400
|
+
*/
|
|
1401
|
+
static async parallelUrls(urls, run, options = {}) {
|
|
1402
|
+
const tasks = urls.map(url => ({
|
|
1403
|
+
name: url,
|
|
1404
|
+
config: options.config,
|
|
1405
|
+
run: (browser) => run(browser, url),
|
|
1406
|
+
}));
|
|
1407
|
+
const results = await CBrowser.parallel(tasks, options);
|
|
1408
|
+
return results.map(r => ({ url: r.name, ...r }));
|
|
1409
|
+
}
|
|
1410
|
+
// =========================================================================
|
|
1411
|
+
// Tier 3: Fluent API (v3.0.0)
|
|
1412
|
+
// =========================================================================
|
|
1413
|
+
/**
|
|
1414
|
+
* Fluent API - navigate and return chainable instance.
|
|
1415
|
+
*/
|
|
1416
|
+
async goto(url) {
|
|
1417
|
+
await this.navigate(url);
|
|
1418
|
+
return new FluentCBrowser(this);
|
|
1419
|
+
}
|
|
623
1420
|
}
|
|
624
1421
|
exports.CBrowser = CBrowser;
|
|
1422
|
+
/**
|
|
1423
|
+
* Fluent wrapper for chainable API.
|
|
1424
|
+
*/
|
|
1425
|
+
class FluentCBrowser {
|
|
1426
|
+
browser;
|
|
1427
|
+
constructor(browser) {
|
|
1428
|
+
this.browser = browser;
|
|
1429
|
+
}
|
|
1430
|
+
async click(selector, options) {
|
|
1431
|
+
await this.browser.click(selector, options);
|
|
1432
|
+
return this;
|
|
1433
|
+
}
|
|
1434
|
+
async fill(selector, value) {
|
|
1435
|
+
await this.browser.fill(selector, value);
|
|
1436
|
+
return this;
|
|
1437
|
+
}
|
|
1438
|
+
async screenshot(path) {
|
|
1439
|
+
await this.browser.screenshot(path);
|
|
1440
|
+
return this;
|
|
1441
|
+
}
|
|
1442
|
+
async wait(ms) {
|
|
1443
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
1444
|
+
return this;
|
|
1445
|
+
}
|
|
1446
|
+
async extract(what) {
|
|
1447
|
+
const result = await this.browser.extract(what);
|
|
1448
|
+
return { data: result.data, fluent: this };
|
|
1449
|
+
}
|
|
1450
|
+
async close() {
|
|
1451
|
+
await this.browser.close();
|
|
1452
|
+
}
|
|
1453
|
+
get instance() {
|
|
1454
|
+
return this.browser;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
exports.FluentCBrowser = FluentCBrowser;
|
|
1458
|
+
// ============================================================================
|
|
1459
|
+
// Tier 3: Natural Language API (v3.0.0)
|
|
1460
|
+
// ============================================================================
|
|
1461
|
+
/**
|
|
1462
|
+
* Natural language command patterns.
|
|
1463
|
+
*/
|
|
1464
|
+
const NL_PATTERNS = [
|
|
1465
|
+
// Navigation
|
|
1466
|
+
{ pattern: /^(?:go to|navigate to|open|visit)\s+(.+)$/i, action: "navigate", extract: (m) => ({ url: m[1] }) },
|
|
1467
|
+
{ pattern: /^(?:go\s+)?back$/i, action: "back", extract: () => ({}) },
|
|
1468
|
+
{ pattern: /^(?:go\s+)?forward$/i, action: "forward", extract: () => ({}) },
|
|
1469
|
+
{ pattern: /^refresh|reload$/i, action: "reload", extract: () => ({}) },
|
|
1470
|
+
// Clicking
|
|
1471
|
+
{ pattern: /^click(?:\s+on)?\s+(?:the\s+)?["']?(.+?)["']?$/i, action: "click", extract: (m) => ({ selector: m[1] }) },
|
|
1472
|
+
{ pattern: /^press(?:\s+the)?\s+["']?(.+?)["']?(?:\s+button)?$/i, action: "click", extract: (m) => ({ selector: m[1] }) },
|
|
1473
|
+
{ pattern: /^tap(?:\s+on)?\s+["']?(.+?)["']?$/i, action: "click", extract: (m) => ({ selector: m[1] }) },
|
|
1474
|
+
// Form filling
|
|
1475
|
+
{ pattern: /^(?:type|enter|input|fill(?:\s+in)?)\s+["'](.+?)["']\s+(?:in(?:to)?|on)\s+(?:the\s+)?["']?(.+?)["']?$/i, action: "fill", extract: (m) => ({ value: m[1], selector: m[2] }) },
|
|
1476
|
+
{ pattern: /^(?:fill(?:\s+in)?|set)\s+(?:the\s+)?["']?(.+?)["']?\s+(?:to|with|as)\s+["'](.+?)["']$/i, action: "fill", extract: (m) => ({ selector: m[1], value: m[2] }) },
|
|
1477
|
+
// Selecting
|
|
1478
|
+
{ pattern: /^select\s+["'](.+?)["']\s+(?:from|in)\s+(?:the\s+)?["']?(.+?)["']?$/i, action: "select", extract: (m) => ({ value: m[1], selector: m[2] }) },
|
|
1479
|
+
{ pattern: /^choose\s+["'](.+?)["']$/i, action: "click", extract: (m) => ({ selector: m[1] }) },
|
|
1480
|
+
// Screenshots
|
|
1481
|
+
{ pattern: /^(?:take\s+a?\s*)?screenshot(?:\s+as\s+["']?(.+?)["']?)?$/i, action: "screenshot", extract: (m) => ({ path: m[1] || "" }) },
|
|
1482
|
+
{ pattern: /^capture(?:\s+the)?\s+(?:page|screen)$/i, action: "screenshot", extract: () => ({}) },
|
|
1483
|
+
// Waiting
|
|
1484
|
+
{ pattern: /^wait(?:\s+for)?\s+(\d+)\s*(?:ms|milliseconds?)?$/i, action: "wait", extract: (m) => ({ ms: m[1] }) },
|
|
1485
|
+
{ pattern: /^wait(?:\s+for)?\s+(\d+)\s*(?:s|seconds?)$/i, action: "waitSeconds", extract: (m) => ({ seconds: m[1] }) },
|
|
1486
|
+
{ pattern: /^wait(?:\s+for)?\s+["']?(.+?)["']?(?:\s+to\s+appear)?$/i, action: "waitFor", extract: (m) => ({ selector: m[1] }) },
|
|
1487
|
+
// Scrolling
|
|
1488
|
+
{ pattern: /^scroll\s+(?:to\s+)?(?:the\s+)?(top|bottom)$/i, action: "scroll", extract: (m) => ({ direction: m[1] }) },
|
|
1489
|
+
{ pattern: /^scroll\s+(up|down)(?:\s+(\d+))?$/i, action: "scrollBy", extract: (m) => ({ direction: m[1], amount: m[2] || "300" }) },
|
|
1490
|
+
// Extraction
|
|
1491
|
+
{ pattern: /^(?:get|extract|find)\s+(?:all\s+)?(?:the\s+)?(.+)$/i, action: "extract", extract: (m) => ({ what: m[1] }) },
|
|
1492
|
+
];
|
|
1493
|
+
/**
|
|
1494
|
+
* Parse natural language into browser action.
|
|
1495
|
+
*/
|
|
1496
|
+
function parseNaturalLanguage(command) {
|
|
1497
|
+
const trimmed = command.trim();
|
|
1498
|
+
for (const { pattern, action, extract } of NL_PATTERNS) {
|
|
1499
|
+
const match = trimmed.match(pattern);
|
|
1500
|
+
if (match) {
|
|
1501
|
+
return { action, params: extract(match) };
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
return null;
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Execute a natural language command.
|
|
1508
|
+
*/
|
|
1509
|
+
async function executeNaturalLanguage(browser, command) {
|
|
1510
|
+
const parsed = parseNaturalLanguage(command);
|
|
1511
|
+
if (!parsed) {
|
|
1512
|
+
return { success: false, action: "unknown", error: `Could not parse command: "${command}"` };
|
|
1513
|
+
}
|
|
1514
|
+
const { action, params } = parsed;
|
|
1515
|
+
try {
|
|
1516
|
+
let result;
|
|
1517
|
+
switch (action) {
|
|
1518
|
+
case "navigate":
|
|
1519
|
+
result = await browser.navigate(params.url);
|
|
1520
|
+
break;
|
|
1521
|
+
case "click":
|
|
1522
|
+
result = await browser.click(params.selector);
|
|
1523
|
+
break;
|
|
1524
|
+
case "fill":
|
|
1525
|
+
result = await browser.fill(params.selector, params.value);
|
|
1526
|
+
break;
|
|
1527
|
+
case "screenshot":
|
|
1528
|
+
result = await browser.screenshot(params.path || undefined);
|
|
1529
|
+
break;
|
|
1530
|
+
case "wait":
|
|
1531
|
+
await new Promise(r => setTimeout(r, parseInt(params.ms)));
|
|
1532
|
+
result = { waited: parseInt(params.ms) };
|
|
1533
|
+
break;
|
|
1534
|
+
case "waitSeconds":
|
|
1535
|
+
await new Promise(r => setTimeout(r, parseInt(params.seconds) * 1000));
|
|
1536
|
+
result = { waited: parseInt(params.seconds) * 1000 };
|
|
1537
|
+
break;
|
|
1538
|
+
case "extract":
|
|
1539
|
+
result = await browser.extract(params.what);
|
|
1540
|
+
break;
|
|
1541
|
+
default:
|
|
1542
|
+
return { success: false, action, error: `Unsupported action: ${action}` };
|
|
1543
|
+
}
|
|
1544
|
+
return { success: true, action, result };
|
|
1545
|
+
}
|
|
1546
|
+
catch (e) {
|
|
1547
|
+
return { success: false, action, error: e.message };
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Execute multiple natural language commands in sequence.
|
|
1552
|
+
*/
|
|
1553
|
+
async function executeNaturalLanguageScript(browser, commands) {
|
|
1554
|
+
const results = [];
|
|
1555
|
+
for (const command of commands) {
|
|
1556
|
+
if (!command.trim() || command.startsWith("#"))
|
|
1557
|
+
continue; // Skip empty lines and comments
|
|
1558
|
+
const result = await executeNaturalLanguage(browser, command);
|
|
1559
|
+
results.push({ command, ...result });
|
|
1560
|
+
if (!result.success)
|
|
1561
|
+
break; // Stop on first error
|
|
1562
|
+
}
|
|
1563
|
+
return results;
|
|
1564
|
+
}
|
|
625
1565
|
//# sourceMappingURL=browser.js.map
|