cbrowser 11.9.0 → 11.10.1

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
@@ -8,7 +8,7 @@ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkS
8
8
  import { join } from "path";
9
9
  import { mergeConfig, getPaths, ensureDirectories } from "./config.js";
10
10
  import { BUILTIN_PERSONAS, getPersona } from "./personas.js";
11
- import { DEVICE_PRESETS, LOCATION_PRESETS } from "./types.js";
11
+ import { DEVICE_PRESETS, LOCATION_PRESETS, CBrowserErrorCode } from "./types.js";
12
12
  import { runCognitiveJourney, isApiKeyConfigured, } from "./cognitive/index.js";
13
13
  import { SessionManager } from "./browser/session-manager.js";
14
14
  import { SelectorCacheManager } from "./browser/selector-cache.js";
@@ -358,6 +358,292 @@ export class CBrowser {
358
358
  return this.page;
359
359
  }
360
360
  // =========================================================================
361
+ // Browser Crash Recovery (v11.8.0)
362
+ // =========================================================================
363
+ /** Maximum attempts for crash recovery */
364
+ static MAX_RECOVERY_ATTEMPTS = 3;
365
+ /** Default timeout for health check in ms */
366
+ static HEALTH_CHECK_TIMEOUT = 5000;
367
+ /** Base retry delay in ms (exponential backoff) */
368
+ static BASE_RETRY_DELAY = 1000;
369
+ /**
370
+ * Check if the browser is healthy and responsive.
371
+ * This performs a lightweight operation to verify the browser process is alive.
372
+ */
373
+ async isBrowserHealthy() {
374
+ const startTime = Date.now();
375
+ // No page or context means browser needs launch, not recovery
376
+ if (!this.page || !this.context) {
377
+ return {
378
+ healthy: false,
379
+ error: CBrowserErrorCode.BROWSER_DISCONNECTED,
380
+ message: "Browser not launched",
381
+ checkDurationMs: Date.now() - startTime,
382
+ };
383
+ }
384
+ try {
385
+ // Check if page is closed
386
+ if (this.page.isClosed()) {
387
+ return {
388
+ healthy: false,
389
+ error: CBrowserErrorCode.BROWSER_CRASHED,
390
+ message: "Page is closed unexpectedly",
391
+ checkDurationMs: Date.now() - startTime,
392
+ };
393
+ }
394
+ // Try a simple evaluate to verify browser is responsive
395
+ const healthCheck = await Promise.race([
396
+ this.page.evaluate(() => ({
397
+ url: window.location.href,
398
+ readyState: document.readyState,
399
+ timestamp: Date.now(),
400
+ })),
401
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Health check timeout")), CBrowser.HEALTH_CHECK_TIMEOUT)),
402
+ ]);
403
+ if (!healthCheck) {
404
+ return {
405
+ healthy: false,
406
+ error: CBrowserErrorCode.BROWSER_UNRESPONSIVE,
407
+ message: "Browser failed to respond within timeout",
408
+ checkDurationMs: Date.now() - startTime,
409
+ };
410
+ }
411
+ return {
412
+ healthy: true,
413
+ message: `Browser healthy, page at ${healthCheck.url}`,
414
+ checkDurationMs: Date.now() - startTime,
415
+ };
416
+ }
417
+ catch (error) {
418
+ const errorMessage = error instanceof Error ? error.message : String(error);
419
+ // Detect specific crash patterns
420
+ if (errorMessage.includes("Target closed") || errorMessage.includes("Browser closed")) {
421
+ return {
422
+ healthy: false,
423
+ error: CBrowserErrorCode.BROWSER_CRASHED,
424
+ message: `Browser process crashed: ${errorMessage}`,
425
+ checkDurationMs: Date.now() - startTime,
426
+ };
427
+ }
428
+ if (errorMessage.includes("disconnected") || errorMessage.includes("Connection refused")) {
429
+ return {
430
+ healthy: false,
431
+ error: CBrowserErrorCode.BROWSER_DISCONNECTED,
432
+ message: `Browser disconnected: ${errorMessage}`,
433
+ checkDurationMs: Date.now() - startTime,
434
+ };
435
+ }
436
+ if (errorMessage.includes("timeout") || errorMessage.includes("Timeout")) {
437
+ return {
438
+ healthy: false,
439
+ error: CBrowserErrorCode.BROWSER_UNRESPONSIVE,
440
+ message: `Browser unresponsive: ${errorMessage}`,
441
+ checkDurationMs: Date.now() - startTime,
442
+ };
443
+ }
444
+ return {
445
+ healthy: false,
446
+ error: CBrowserErrorCode.BROWSER_CRASHED,
447
+ message: `Browser health check failed: ${errorMessage}`,
448
+ checkDurationMs: Date.now() - startTime,
449
+ };
450
+ }
451
+ }
452
+ /**
453
+ * Attempt to recover from a browser crash by restarting the browser.
454
+ * Uses exponential backoff for retry attempts.
455
+ */
456
+ async recoverBrowser(options) {
457
+ const startTime = Date.now();
458
+ const maxAttempts = options?.maxAttempts ?? CBrowser.MAX_RECOVERY_ATTEMPTS;
459
+ // First check if recovery is actually needed
460
+ const healthResult = await this.isBrowserHealthy();
461
+ if (healthResult.healthy) {
462
+ return {
463
+ success: true,
464
+ recoveryNeeded: false,
465
+ message: "Browser is already healthy, no recovery needed",
466
+ attempts: 0,
467
+ recoveryDurationMs: Date.now() - startTime,
468
+ };
469
+ }
470
+ if (this.config.verbose) {
471
+ console.log(`🔄 Browser crash detected: ${healthResult.message}`);
472
+ console.log(`🔄 Attempting recovery (max ${maxAttempts} attempts)...`);
473
+ }
474
+ // Get the last known URL before crash for restoration
475
+ const savedSession = this.loadSessionState();
476
+ const restoreUrl = options?.restoreUrl ?? savedSession?.url;
477
+ let lastError;
478
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
479
+ try {
480
+ if (this.config.verbose) {
481
+ console.log(`🔄 Recovery attempt ${attempt}/${maxAttempts}...`);
482
+ }
483
+ // Force close any zombie processes
484
+ await this.forceClose();
485
+ // Wait with exponential backoff before retry
486
+ const delay = CBrowser.BASE_RETRY_DELAY * Math.pow(2, attempt - 1);
487
+ await new Promise((r) => setTimeout(r, delay));
488
+ // Relaunch browser
489
+ await this.launch();
490
+ // Restore previous URL if available
491
+ if (restoreUrl && restoreUrl !== "about:blank" && this.page) {
492
+ try {
493
+ await this.page.goto(restoreUrl, {
494
+ waitUntil: "domcontentloaded",
495
+ timeout: 15000,
496
+ });
497
+ }
498
+ catch (e) {
499
+ // URL restoration is best-effort, don't fail recovery
500
+ if (this.config.verbose) {
501
+ console.log(`⚠️ Could not restore URL: ${restoreUrl}`);
502
+ }
503
+ }
504
+ }
505
+ // Verify recovery was successful
506
+ const postRecoveryHealth = await this.isBrowserHealthy();
507
+ if (postRecoveryHealth.healthy) {
508
+ if (this.config.verbose) {
509
+ console.log(`✅ Browser recovered successfully on attempt ${attempt}`);
510
+ }
511
+ return {
512
+ success: true,
513
+ recoveryNeeded: true,
514
+ message: `Browser recovered after ${attempt} attempt(s)`,
515
+ attempts: attempt,
516
+ recoveryDurationMs: Date.now() - startTime,
517
+ };
518
+ }
519
+ lastError = postRecoveryHealth.message;
520
+ }
521
+ catch (error) {
522
+ lastError = error instanceof Error ? error.message : String(error);
523
+ if (this.config.verbose) {
524
+ console.log(`❌ Recovery attempt ${attempt} failed: ${lastError}`);
525
+ }
526
+ }
527
+ }
528
+ // All attempts failed
529
+ return {
530
+ success: false,
531
+ recoveryNeeded: true,
532
+ error: CBrowserErrorCode.BROWSER_RECOVERY_FAILED,
533
+ message: `Failed to recover browser after ${maxAttempts} attempts: ${lastError}`,
534
+ attempts: maxAttempts,
535
+ recoveryDurationMs: Date.now() - startTime,
536
+ retryAfterMs: CBrowser.BASE_RETRY_DELAY * Math.pow(2, maxAttempts),
537
+ };
538
+ }
539
+ /**
540
+ * Force close browser processes without normal cleanup.
541
+ * Used when browser is unresponsive and normal close() would hang.
542
+ */
543
+ async forceClose() {
544
+ // Remove listeners to prevent memory leaks
545
+ if (this.page) {
546
+ try {
547
+ this.page.removeAllListeners();
548
+ }
549
+ catch {
550
+ // Ignore - page may be destroyed
551
+ }
552
+ }
553
+ // Force close context
554
+ if (this.context) {
555
+ try {
556
+ await Promise.race([
557
+ this.context.close(),
558
+ new Promise((r) => setTimeout(r, 2000)), // 2s timeout for close
559
+ ]);
560
+ }
561
+ catch {
562
+ // Ignore - may already be closed
563
+ }
564
+ this.context = null;
565
+ this.page = null;
566
+ }
567
+ // Force close browser
568
+ if (this.browser) {
569
+ try {
570
+ await Promise.race([
571
+ this.browser.close(),
572
+ new Promise((r) => setTimeout(r, 2000)), // 2s timeout for close
573
+ ]);
574
+ }
575
+ catch {
576
+ // Ignore - may already be closed
577
+ }
578
+ this.browser = null;
579
+ }
580
+ }
581
+ /**
582
+ * Execute an operation with automatic crash recovery.
583
+ * If the browser crashes during the operation, it will be restarted and the operation retried.
584
+ *
585
+ * @param operation - The async operation to execute
586
+ * @param operationName - Name of the operation for error messages
587
+ * @returns Result of the operation or throws if recovery fails
588
+ */
589
+ async withCrashRecovery(operation, operationName = "operation") {
590
+ try {
591
+ return await operation();
592
+ }
593
+ catch (error) {
594
+ const errorMessage = error instanceof Error ? error.message : String(error);
595
+ // Check if this looks like a browser crash
596
+ const crashIndicators = [
597
+ "Target closed",
598
+ "Browser closed",
599
+ "disconnected",
600
+ "Connection refused",
601
+ "Protocol error",
602
+ "browser has been closed",
603
+ "Execution context was destroyed",
604
+ ];
605
+ const isCrash = crashIndicators.some((indicator) => errorMessage.toLowerCase().includes(indicator.toLowerCase()));
606
+ if (!isCrash) {
607
+ // Not a crash, re-throw original error
608
+ throw error;
609
+ }
610
+ if (this.config.verbose) {
611
+ console.log(`🔄 Browser crash detected during ${operationName}, attempting recovery...`);
612
+ }
613
+ // Attempt recovery
614
+ const recovery = await this.recoverBrowser();
615
+ if (!recovery.success) {
616
+ // Recovery failed, throw structured error
617
+ throw new Error(JSON.stringify({
618
+ error: "browser_crash",
619
+ errorCode: CBrowserErrorCode.BROWSER_RECOVERY_FAILED,
620
+ message: recovery.message,
621
+ recovering: false,
622
+ retryAfterMs: recovery.retryAfterMs ?? 5000,
623
+ operation: operationName,
624
+ }));
625
+ }
626
+ // Recovery succeeded, retry the operation once
627
+ if (this.config.verbose) {
628
+ console.log(`🔄 Retrying ${operationName} after recovery...`);
629
+ }
630
+ try {
631
+ return await operation();
632
+ }
633
+ catch (retryError) {
634
+ // Second failure after recovery
635
+ throw new Error(JSON.stringify({
636
+ error: "browser_crash",
637
+ errorCode: CBrowserErrorCode.BROWSER_CRASHED,
638
+ message: `Operation failed after recovery: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
639
+ recovering: false,
640
+ retryAfterMs: 5000,
641
+ operation: operationName,
642
+ }));
643
+ }
644
+ }
645
+ }
646
+ // =========================================================================
361
647
  // Network Mocking
362
648
  // =========================================================================
363
649
  /**
@@ -864,14 +1150,51 @@ export class CBrowser {
864
1150
  }
865
1151
  }
866
1152
  const loadTime = Date.now() - startTime;
1153
+ // v11.9.0: Post-navigation verification to detect context desync (issue #84)
1154
+ const actualUrl = page.url();
1155
+ const actualTitle = await page.title();
1156
+ // Extract domains for comparison
1157
+ const getHostname = (urlStr) => {
1158
+ try {
1159
+ return new URL(urlStr).hostname.toLowerCase();
1160
+ }
1161
+ catch {
1162
+ return urlStr.toLowerCase();
1163
+ }
1164
+ };
1165
+ const expectedHost = getHostname(url);
1166
+ const actualHost = getHostname(actualUrl);
1167
+ // Check for domain mismatch (indicates desync)
1168
+ const hostMismatch = expectedHost !== actualHost &&
1169
+ !actualHost.endsWith(`.${expectedHost}`) && // Allow subdomains
1170
+ !expectedHost.endsWith(`.${actualHost}`); // Allow parent domains
1171
+ if (hostMismatch) {
1172
+ // Page context is desynced - return error with details
1173
+ const screenshot = await this.screenshot();
1174
+ if (this.config.verbose) {
1175
+ console.log(`⚠️ Page context desync detected: expected ${expectedHost}, got ${actualHost}`);
1176
+ }
1177
+ return {
1178
+ url: actualUrl,
1179
+ title: actualTitle,
1180
+ screenshot,
1181
+ errors: [...errors, `Page context desync: navigated to ${url} but landed on ${actualUrl}`],
1182
+ warnings: [...warnings, `Expected domain: ${expectedHost}, Actual domain: ${actualHost}`],
1183
+ loadTime,
1184
+ success: false,
1185
+ desyncDetected: true,
1186
+ expectedUrl: url,
1187
+ };
1188
+ }
867
1189
  const screenshot = await this.screenshot();
868
1190
  return {
869
- url: page.url(),
870
- title: await page.title(),
1191
+ url: actualUrl,
1192
+ title: actualTitle,
871
1193
  screenshot,
872
1194
  errors,
873
1195
  warnings,
874
1196
  loadTime,
1197
+ success: true,
875
1198
  };
876
1199
  }
877
1200
  /**
@@ -2216,21 +2539,33 @@ export class CBrowser {
2216
2539
  selector: getSelector(submitBtn),
2217
2540
  text: submitBtn.textContent?.trim(),
2218
2541
  } : undefined;
2219
- // Determine form purpose
2542
+ // v11.9.0: Enhanced form purpose detection (issue #89)
2220
2543
  let purpose = "unknown";
2221
2544
  const formHtml = form.innerHTML.toLowerCase();
2222
- if (formHtml.includes("password") && formHtml.includes("email")) {
2545
+ const hasPasswordField = !!form.querySelector('input[type="password"]');
2546
+ const hasEmailField = !!form.querySelector('input[type="email"]');
2547
+ const hasUsernameField = !!form.querySelector('[name*="user" i], [name*="login" i], [placeholder*="user" i], [placeholder*="login" i]');
2548
+ const hasSearchField = !!form.querySelector('input[type="search"], [name*="search" i], [placeholder*="search" i], [role="search"]');
2549
+ // Login: has password field + (email OR username field) + few fields
2550
+ if (hasPasswordField && (hasEmailField || hasUsernameField || formHtml.includes("sign in") || formHtml.includes("log in"))) {
2551
+ purpose = fields.length <= 4 ? "login" : "signup";
2552
+ }
2553
+ else if (hasPasswordField && formHtml.includes("password") && !formHtml.includes("confirm")) {
2554
+ // Single password field with no confirm = likely login
2223
2555
  purpose = fields.length <= 3 ? "login" : "signup";
2224
2556
  }
2225
- else if (formHtml.includes("search")) {
2557
+ else if (hasSearchField || formHtml.includes("search")) {
2226
2558
  purpose = "search";
2227
2559
  }
2228
- else if (formHtml.includes("contact") || formHtml.includes("message")) {
2560
+ else if (formHtml.includes("contact") || formHtml.includes("message") || formHtml.includes("feedback")) {
2229
2561
  purpose = "contact";
2230
2562
  }
2231
- else if (formHtml.includes("card") || formHtml.includes("payment")) {
2563
+ else if (formHtml.includes("card") || formHtml.includes("payment") || formHtml.includes("checkout")) {
2232
2564
  purpose = "checkout";
2233
2565
  }
2566
+ else if (formHtml.includes("register") || formHtml.includes("sign up") || formHtml.includes("create account")) {
2567
+ purpose = "signup";
2568
+ }
2234
2569
  return {
2235
2570
  action: form.action || undefined,
2236
2571
  method: form.method || undefined,