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.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
- this.context = await this.browser.newContext({
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, "&quot;")}">${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