browsecraft 0.4.0 → 0.5.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/README.md CHANGED
@@ -341,12 +341,14 @@ Everything works without AI. These features enhance the experience when availabl
341
341
  | Feature | What it does |
342
342
  | --- | --- |
343
343
  | Self-healing selectors | When a CSS selector breaks, suggests a replacement using page context |
344
+ | AI failure diagnosis | Analyzes failures with page context and suggests fixes |
344
345
  | Test generation | Generates test code from a natural-language description |
345
346
  | Visual regression | Compares screenshots pixel-by-pixel, with optional AI semantic analysis |
346
347
  | Auto-step generation | Writes BDD step definitions from `.feature` files automatically |
348
+ | Persistent AI cache | Caches AI results on disk to avoid redundant API calls |
347
349
 
348
350
  ```js
349
- import { healSelector, generateTest, compareScreenshots } from 'browsecraft-ai';
351
+ import { healSelector, generateTest, compareScreenshots, diagnoseFailure } from 'browsecraft-ai';
350
352
  ```
351
353
 
352
354
  ## Examples
@@ -371,15 +373,16 @@ node test.mjs --maximized # full screen
371
373
 
372
374
  ## Architecture
373
375
 
374
- Five npm packages, one monorepo:
376
+ Six npm packages, one monorepo:
375
377
 
376
378
  | Package | Role |
377
379
  | --- | --- |
378
- | `browsecraft` | Main package. Page API, Browser, config, CLI. |
379
- | `browsecraft-bdd` | Gherkin parser, step registry, executor, hooks, tags, TS-native BDD. |
380
+ | `browsecraft` | Main package. Page API, Browser, config, CLI, adaptive timing, self-healing. |
381
+ | `browsecraft-bdd` | Gherkin parser, step registry, executor, hooks, tags, TS-native BDD, 38 built-in steps, AI auto-steps. |
380
382
  | `browsecraft-bidi` | WebDriver BiDi protocol client and browser launcher. |
381
- | `browsecraft-runner` | Test file discovery, execution, reporter types. |
382
- | `browsecraft-ai` | Self-healing selectors, test generation, visual diff. |
383
+ | `browsecraft-runner` | Event bus, multi-browser worker pool, parallel scheduler, result aggregator, failure classification, smart retry. |
384
+ | `browsecraft-ai` | Self-healing selectors, AI failure diagnosis, test generation, visual diff, persistent cache. |
385
+ | `create-browsecraft` | Project scaffolding CLI (`npm init browsecraft`). Zero dependencies. |
383
386
 
384
387
  Most users only need `browsecraft`. Add `browsecraft-bdd` for BDD, `browsecraft-ai` for AI features.
385
388
 
@@ -31,6 +31,42 @@ function resolveConfig(userConfig) {
31
31
  viewport: userConfig.viewport ?? DEFAULTS.viewport
32
32
  };
33
33
  }
34
+ function resolveAIConfig(ai) {
35
+ if (ai === "off") return null;
36
+ if (typeof ai === "object") return ai;
37
+ const explicitProvider = process.env.BROWSECRAFT_AI_PROVIDER;
38
+ if (explicitProvider) {
39
+ switch (explicitProvider.toLowerCase()) {
40
+ case "github-models":
41
+ case "github":
42
+ return { provider: "github-models" };
43
+ case "openai":
44
+ return { provider: "openai" };
45
+ case "anthropic":
46
+ return { provider: "anthropic" };
47
+ case "ollama":
48
+ return { provider: "ollama" };
49
+ default:
50
+ console.warn(
51
+ `[browsecraft] Unknown AI provider "${explicitProvider}". Supported: github-models, openai, anthropic, ollama`
52
+ );
53
+ return null;
54
+ }
55
+ }
56
+ if (process.env.OPENAI_API_KEY) {
57
+ return { provider: "openai" };
58
+ }
59
+ if (process.env.ANTHROPIC_API_KEY) {
60
+ return { provider: "anthropic" };
61
+ }
62
+ if (process.env.BROWSECRAFT_GITHUB_TOKEN || process.env.GITHUB_TOKEN) {
63
+ return { provider: "github-models" };
64
+ }
65
+ if (process.env.OLLAMA_HOST) {
66
+ return { provider: "ollama" };
67
+ }
68
+ return null;
69
+ }
34
70
 
35
71
  // src/errors.ts
36
72
  var BrowsecraftError = class extends Error {
@@ -130,6 +166,78 @@ var NetworkError = class extends BrowsecraftError {
130
166
  var TimeoutError = class extends BrowsecraftError {
131
167
  name = "TimeoutError";
132
168
  };
169
+ function classifyFailure(error) {
170
+ if (!(error instanceof Error)) {
171
+ return { category: "unknown", retryable: true, description: "Non-Error thrown" };
172
+ }
173
+ if (error instanceof ElementNotFoundError) {
174
+ return {
175
+ category: "element",
176
+ retryable: true,
177
+ description: "Element not found \u2014 page may still be loading or selector changed"
178
+ };
179
+ }
180
+ if (error instanceof ElementNotActionableError) {
181
+ return {
182
+ category: "actionability",
183
+ retryable: true,
184
+ description: `Element not actionable (${error.reason}) \u2014 may become ready`
185
+ };
186
+ }
187
+ if (error instanceof NetworkError) {
188
+ return {
189
+ category: "network",
190
+ retryable: true,
191
+ description: "Network failure \u2014 may be transient"
192
+ };
193
+ }
194
+ if (error instanceof TimeoutError) {
195
+ return {
196
+ category: "timeout",
197
+ retryable: true,
198
+ description: "Timed out \u2014 environment may be slow or element delayed"
199
+ };
200
+ }
201
+ const name = error.name ?? "";
202
+ if (name === "AssertionError" || name === "AssertionError [ERR_ASSERTION]" || name === "ERR_ASSERTION" || error.constructor?.name === "AssertionError") {
203
+ return {
204
+ category: "assertion",
205
+ retryable: false,
206
+ description: "Assertion failed \u2014 retrying won't help"
207
+ };
208
+ }
209
+ const msg = error.message ?? "";
210
+ const lowerMsg = msg.toLowerCase();
211
+ if (lowerMsg.includes("expected") && (lowerMsg.includes("to equal") || lowerMsg.includes("to be") || lowerMsg.includes("to have") || lowerMsg.includes("to match") || lowerMsg.includes("to contain") || lowerMsg.includes("but got") || lowerMsg.includes("but received"))) {
212
+ return {
213
+ category: "assertion",
214
+ retryable: false,
215
+ description: "Assertion failed \u2014 retrying won't help"
216
+ };
217
+ }
218
+ if (name === "SyntaxError" || name === "ReferenceError" || name === "TypeError" || name === "RangeError") {
219
+ return {
220
+ category: "script",
221
+ retryable: false,
222
+ description: `${name} \u2014 likely a code bug, retrying won't help`
223
+ };
224
+ }
225
+ if (lowerMsg.includes("timed out") || lowerMsg.includes("timeout")) {
226
+ return {
227
+ category: "timeout",
228
+ retryable: true,
229
+ description: "Timed out \u2014 environment may be slow"
230
+ };
231
+ }
232
+ if (lowerMsg.includes("econnrefused") || lowerMsg.includes("econnreset") || lowerMsg.includes("enotfound") || lowerMsg.includes("fetch failed") || lowerMsg.includes("network")) {
233
+ return {
234
+ category: "network",
235
+ retryable: true,
236
+ description: "Network failure \u2014 may be transient"
237
+ };
238
+ }
239
+ return { category: "unknown", retryable: true, description: "Unknown error \u2014 will retry" };
240
+ }
133
241
  function formatElementState(state) {
134
242
  if (!state.found) return "Element state: NOT FOUND in DOM";
135
243
  const lines = ["Element state:"];
@@ -680,6 +788,10 @@ var Page = class {
680
788
  interceptIds = [];
681
789
  /** @internal -- event listener unsubscribe functions for cleanup */
682
790
  eventCleanups = [];
791
+ /** @internal -- adaptive timing: multiplier derived from environment speed */
792
+ timingMultiplier = 1;
793
+ /** @internal -- whether timing has been calibrated */
794
+ timingCalibrated = false;
683
795
  constructor(session, contextId, config) {
684
796
  this.session = session;
685
797
  this.contextId = contextId;
@@ -699,11 +811,17 @@ var Page = class {
699
811
  async goto(url, options) {
700
812
  const fullUrl = this.resolveURL(url);
701
813
  const waitUntil = options?.waitUntil ?? "complete";
814
+ const navStart = Date.now();
702
815
  await this.session.browsingContext.navigate({
703
816
  context: this.contextId,
704
817
  url: fullUrl,
705
818
  wait: waitUntil
706
819
  });
820
+ if (!this.timingCalibrated) {
821
+ this.timingCalibrated = true;
822
+ const elapsed = Date.now() - navStart;
823
+ this.timingMultiplier = Math.max(1, Math.min(5, elapsed / 800));
824
+ }
707
825
  }
708
826
  /**
709
827
  * Reload the current page.
@@ -746,7 +864,7 @@ var Page = class {
746
864
  */
747
865
  async click(target, options) {
748
866
  const timeout = options?.timeout ?? this.config.timeout;
749
- const located = await locateElement(this.session, this.contextId, target, { timeout });
867
+ const located = await this.locateWithHealing(target, "click", { timeout });
750
868
  await this.ensureActionable(located, "click", target, { timeout });
751
869
  await this.scrollIntoViewAndClick(located, options);
752
870
  }
@@ -762,7 +880,7 @@ var Page = class {
762
880
  async fill(target, value, options) {
763
881
  const timeout = options?.timeout ?? this.config.timeout;
764
882
  const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
765
- const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
883
+ const located = await this.locateWithHealing(resolvedTarget, "fill", { timeout });
766
884
  await this.ensureActionable(located, "fill", target, { timeout });
767
885
  const ref = this.getSharedRef(located.node);
768
886
  await this.session.script.callFunction({
@@ -801,7 +919,7 @@ var Page = class {
801
919
  async type(target, text, options) {
802
920
  const timeout = options?.timeout ?? this.config.timeout;
803
921
  const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
804
- const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
922
+ const located = await this.locateWithHealing(resolvedTarget, "type", { timeout });
805
923
  await this.ensureActionable(located, "type", target, { timeout });
806
924
  const ref = this.getSharedRef(located.node);
807
925
  await this.session.script.callFunction({
@@ -830,7 +948,7 @@ var Page = class {
830
948
  async select(target, value, options) {
831
949
  const timeout = options?.timeout ?? this.config.timeout;
832
950
  const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
833
- const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
951
+ const located = await this.locateWithHealing(resolvedTarget, "select", { timeout });
834
952
  await this.ensureActionable(located, "select", target, { timeout });
835
953
  const ref = this.getSharedRef(located.node);
836
954
  await this.session.script.callFunction({
@@ -858,7 +976,7 @@ var Page = class {
858
976
  */
859
977
  async check(target, options) {
860
978
  const timeout = options?.timeout ?? this.config.timeout;
861
- const located = await locateElement(this.session, this.contextId, target, { timeout });
979
+ const located = await this.locateWithHealing(target, "check", { timeout });
862
980
  await this.ensureActionable(located, "check", target, { timeout });
863
981
  const ref = this.getSharedRef(located.node);
864
982
  const result = await this.session.script.callFunction({
@@ -877,7 +995,7 @@ var Page = class {
877
995
  */
878
996
  async uncheck(target, options) {
879
997
  const timeout = options?.timeout ?? this.config.timeout;
880
- const located = await locateElement(this.session, this.contextId, target, { timeout });
998
+ const located = await this.locateWithHealing(target, "uncheck", { timeout });
881
999
  await this.ensureActionable(located, "uncheck", target, { timeout });
882
1000
  const ref = this.getSharedRef(located.node);
883
1001
  const result = await this.session.script.callFunction({
@@ -900,7 +1018,7 @@ var Page = class {
900
1018
  */
901
1019
  async hover(target, options) {
902
1020
  const timeout = options?.timeout ?? this.config.timeout;
903
- const located = await locateElement(this.session, this.contextId, target, { timeout });
1021
+ const located = await this.locateWithHealing(target, "hover", { timeout });
904
1022
  await this.ensureActionable(located, "hover", target, { timeout });
905
1023
  const ref = this.getSharedRef(located.node);
906
1024
  await this.session.script.callFunction({
@@ -1234,7 +1352,7 @@ var Page = class {
1234
1352
  */
1235
1353
  async tap(target, options) {
1236
1354
  const timeout = options?.timeout ?? this.config.timeout;
1237
- const located = await locateElement(this.session, this.contextId, target, { timeout });
1355
+ const located = await this.locateWithHealing(target, "tap", { timeout });
1238
1356
  await this.ensureActionable(located, "tap", target, { timeout });
1239
1357
  const ref = this.getSharedRef(located.node);
1240
1358
  await this.session.script.callFunction({
@@ -1270,7 +1388,7 @@ var Page = class {
1270
1388
  async focus(target, options) {
1271
1389
  const timeout = options?.timeout ?? this.config.timeout;
1272
1390
  const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
1273
- const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
1391
+ const located = await this.locateWithHealing(resolvedTarget, "focus", { timeout });
1274
1392
  await this.ensureActionable(located, "focus", target, { timeout, enabled: false });
1275
1393
  const ref = this.getSharedRef(located.node);
1276
1394
  await this.session.script.callFunction({
@@ -1290,7 +1408,7 @@ var Page = class {
1290
1408
  async blur(target, options) {
1291
1409
  const timeout = options?.timeout ?? this.config.timeout;
1292
1410
  const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
1293
- const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
1411
+ const located = await this.locateWithHealing(resolvedTarget, "blur", { timeout });
1294
1412
  const ref = this.getSharedRef(located.node);
1295
1413
  await this.session.script.callFunction({
1296
1414
  functionDeclaration: "function(el) { el.blur(); }",
@@ -1308,7 +1426,7 @@ var Page = class {
1308
1426
  */
1309
1427
  async innerText(target, options) {
1310
1428
  const timeout = options?.timeout ?? this.config.timeout;
1311
- const located = await locateElement(this.session, this.contextId, target, { timeout });
1429
+ const located = await this.locateWithHealing(target, "innerText", { timeout });
1312
1430
  const ref = this.getSharedRef(located.node);
1313
1431
  const result = await this.session.script.callFunction({
1314
1432
  functionDeclaration: 'function(el) { return el.innerText || ""; }',
@@ -1327,7 +1445,7 @@ var Page = class {
1327
1445
  */
1328
1446
  async innerHTML(target, options) {
1329
1447
  const timeout = options?.timeout ?? this.config.timeout;
1330
- const located = await locateElement(this.session, this.contextId, target, { timeout });
1448
+ const located = await this.locateWithHealing(target, "innerHTML", { timeout });
1331
1449
  const ref = this.getSharedRef(located.node);
1332
1450
  const result = await this.session.script.callFunction({
1333
1451
  functionDeclaration: 'function(el) { return el.innerHTML || ""; }',
@@ -1347,7 +1465,7 @@ var Page = class {
1347
1465
  async inputValue(target, options) {
1348
1466
  const timeout = options?.timeout ?? this.config.timeout;
1349
1467
  const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
1350
- const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
1468
+ const located = await this.locateWithHealing(resolvedTarget, "inputValue", { timeout });
1351
1469
  const ref = this.getSharedRef(located.node);
1352
1470
  const result = await this.session.script.callFunction({
1353
1471
  functionDeclaration: 'function(el) { return el.value ?? ""; }',
@@ -1367,7 +1485,7 @@ var Page = class {
1367
1485
  async selectOption(target, values, options) {
1368
1486
  const timeout = options?.timeout ?? this.config.timeout;
1369
1487
  const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
1370
- const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
1488
+ const located = await this.locateWithHealing(resolvedTarget, "selectOption", { timeout });
1371
1489
  await this.ensureActionable(located, "selectOption", target, { timeout });
1372
1490
  const ref = this.getSharedRef(located.node);
1373
1491
  const valuesArray = Array.isArray(values) ? values : [values];
@@ -1431,7 +1549,7 @@ var Page = class {
1431
1549
  * ```
1432
1550
  */
1433
1551
  async waitForSelector(target, options) {
1434
- const timeout = options?.timeout ?? this.config.timeout;
1552
+ const timeout = this.adaptTimeout(options?.timeout ?? this.config.timeout);
1435
1553
  const state = options?.state ?? "visible";
1436
1554
  if (state === "hidden") {
1437
1555
  await waitFor(
@@ -1561,7 +1679,7 @@ var Page = class {
1561
1679
  async see(target, options) {
1562
1680
  const timeout = options?.timeout ?? this.config.timeout;
1563
1681
  const startTime = Date.now();
1564
- const located = await locateElement(this.session, this.contextId, target, { timeout });
1682
+ const located = await this.locateWithHealing(target, "see", { timeout });
1565
1683
  const ref = this.getSharedRef(located.node);
1566
1684
  const elapsed = Date.now() - startTime;
1567
1685
  const remaining = Math.max(timeout - elapsed, 5e3);
@@ -1751,10 +1869,137 @@ var Page = class {
1751
1869
  // -----------------------------------------------------------------------
1752
1870
  // Private helpers
1753
1871
  // -----------------------------------------------------------------------
1872
+ /**
1873
+ * Locate an element with automatic self-healing on failure.
1874
+ *
1875
+ * When locateElement() throws ElementNotFoundError, this method:
1876
+ * 1. Captures a lightweight DOM snapshot of the page
1877
+ * 2. Calls healSelector() to find a likely replacement
1878
+ * 3. If healed, retries with the new selector and logs a warning
1879
+ *
1880
+ * Zero user configuration. Zero new API. Just smarter element finding.
1881
+ */
1882
+ async locateWithHealing(target, action, options) {
1883
+ const adaptedOptions = { timeout: this.adaptTimeout(options.timeout) };
1884
+ try {
1885
+ return await locateElement(this.session, this.contextId, target, adaptedOptions);
1886
+ } catch (err) {
1887
+ if (!(err instanceof ElementNotFoundError)) throw err;
1888
+ const selectorText = this.extractSelector(target);
1889
+ if (!selectorText) throw err;
1890
+ try {
1891
+ const aiPkg = "browsecraft-ai";
1892
+ const { healSelector } = await import(aiPkg);
1893
+ const snapshot = await this.captureSnapshot();
1894
+ const aiConfig = resolveAIConfig(this.config.ai);
1895
+ const result = await healSelector(selectorText, snapshot, {
1896
+ context: `${action} action on ${typeof target === "string" ? target : selectorText}`,
1897
+ useAI: aiConfig !== null,
1898
+ provider: aiConfig ? {
1899
+ provider: aiConfig.provider,
1900
+ token: "token" in aiConfig ? aiConfig.token : void 0,
1901
+ baseUrl: "baseUrl" in aiConfig ? aiConfig.baseUrl : void 0
1902
+ } : void 0
1903
+ });
1904
+ if (result.healed && result.selector) {
1905
+ console.warn(
1906
+ `\u26A0 [browsecraft] Self-healed: '${selectorText}' \u2192 '${result.selector}' (${result.method}, ${(result.confidence * 100).toFixed(0)}% confidence)`
1907
+ );
1908
+ return await locateElement(
1909
+ this.session,
1910
+ this.contextId,
1911
+ { selector: result.selector },
1912
+ adaptedOptions
1913
+ );
1914
+ }
1915
+ } catch {
1916
+ }
1917
+ throw err;
1918
+ }
1919
+ }
1920
+ /**
1921
+ * Capture a lightweight DOM snapshot for self-healing.
1922
+ * Extracts interactive elements with their attributes for matching.
1923
+ */
1924
+ async captureSnapshot() {
1925
+ const [url, title] = await Promise.all([this.url(), this.title()]);
1926
+ const result = await this.session.script.callFunction({
1927
+ functionDeclaration: `function() {
1928
+ const selectors = 'button, a, input, select, textarea, [role], [data-testid], [data-test-id], [aria-label], label, h1, h2, h3, h4, h5, h6, img, nav, form';
1929
+ const els = document.querySelectorAll(selectors);
1930
+ const elements = [];
1931
+
1932
+ for (const el of els) {
1933
+ if (elements.length >= 100) break;
1934
+
1935
+ const rect = el.getBoundingClientRect();
1936
+ if (rect.width === 0 && rect.height === 0) continue;
1937
+
1938
+ const id = el.id || undefined;
1939
+ const classes = el.className && typeof el.className === 'string'
1940
+ ? el.className.split(/\\s+/).filter(Boolean)
1941
+ : undefined;
1942
+ const text = (el.innerText || '').trim().slice(0, 200) || undefined;
1943
+ const ariaLabel = el.getAttribute('aria-label') || undefined;
1944
+ const role = el.getAttribute('role') || undefined;
1945
+ const type = el.getAttribute('type') || undefined;
1946
+ const name = el.getAttribute('name') || undefined;
1947
+ const placeholder = el.getAttribute('placeholder') || undefined;
1948
+ const href = el.tagName === 'A' ? el.getAttribute('href') || undefined : undefined;
1949
+ const testId = el.getAttribute('data-testid') || el.getAttribute('data-test-id') || undefined;
1950
+
1951
+ // Generate a unique selector
1952
+ let selector;
1953
+ if (id) selector = '#' + id;
1954
+ else if (testId) selector = '[data-testid="' + testId + '"]';
1955
+ else {
1956
+ const tag = el.tagName.toLowerCase();
1957
+ const nth = Array.from(el.parentNode?.children || []).indexOf(el);
1958
+ selector = tag + (classes && classes.length ? '.' + classes[0] : '') + ':nth-child(' + (nth + 1) + ')';
1959
+ }
1960
+
1961
+ elements.push({
1962
+ tag: el.tagName.toLowerCase(),
1963
+ id, classes, text, ariaLabel, role, type, name, placeholder, href, testId, selector
1964
+ });
1965
+ }
1966
+ return elements;
1967
+ }`,
1968
+ target: { context: this.contextId },
1969
+ awaitPromise: false
1970
+ });
1971
+ let elements = [];
1972
+ if (result.type === "success" && result.result?.type === "array") {
1973
+ const arr = result.result.value;
1974
+ elements = arr.map((item) => this.deserializeRemoteValue(item)).filter(
1975
+ (e) => e !== null && typeof e === "object" && "tag" in e && "selector" in e
1976
+ );
1977
+ }
1978
+ return { url, title, elements };
1979
+ }
1980
+ /**
1981
+ * Extract a CSS selector string from a target, if applicable.
1982
+ * Only returns a value for selector-based or CSS-like targets.
1983
+ */
1984
+ extractSelector(target) {
1985
+ if (typeof target === "string") {
1986
+ return target.match(/^[#.\[]/) || target.includes(":") ? target : null;
1987
+ }
1988
+ return target.selector ?? target.testId ? `[data-testid="${target.testId}"]` : null;
1989
+ }
1754
1990
  /**
1755
1991
  * Ensure an element is actionable (visible + enabled) before interacting.
1756
1992
  * Throws a rich ElementNotActionableError if it's not ready within the timeout.
1757
1993
  */
1994
+ /**
1995
+ * Apply the adaptive timing multiplier to a timeout.
1996
+ * On slow environments, timeouts automatically scale up so tests don't
1997
+ * flake. On fast machines the multiplier stays 1.0 — no overhead.
1998
+ * @internal
1999
+ */
2000
+ adaptTimeout(ms) {
2001
+ return Math.round(ms * this.timingMultiplier);
2002
+ }
1758
2003
  async ensureActionable(located, action, target, options) {
1759
2004
  const ref = this.getSharedRef(located.node);
1760
2005
  const targetDesc = typeof target === "string" ? target : describeTarget2(target);
@@ -1763,7 +2008,7 @@ var Page = class {
1763
2008
  this.contextId,
1764
2009
  ref,
1765
2010
  targetDesc,
1766
- { timeout: Math.min(options.timeout, 5e3) },
2011
+ { timeout: this.adaptTimeout(Math.min(options.timeout, 5e3)) },
1767
2012
  {
1768
2013
  visible: true,
1769
2014
  enabled: options.enabled !== false
@@ -2356,11 +2601,13 @@ var Browser = class _Browser {
2356
2601
  try {
2357
2602
  const result = await this.session.send("browser.createUserContext", {});
2358
2603
  const userContext = result.userContext;
2604
+ await this.closeOrphanBlankTabs();
2359
2605
  return new BrowserContext(this.session, this.config, userContext);
2360
2606
  } catch (err) {
2361
2607
  console.warn(
2362
2608
  `[browsecraft] Warning: browser.createUserContext is not supported. Pages will share cookies/storage. ${err instanceof Error ? err.message : String(err)}`
2363
2609
  );
2610
+ await this.closeOrphanBlankTabs();
2364
2611
  return new BrowserContext(this.session, this.config, null);
2365
2612
  }
2366
2613
  }
@@ -2381,6 +2628,27 @@ var Browser = class _Browser {
2381
2628
  getConfig() {
2382
2629
  return { ...this.config };
2383
2630
  }
2631
+ /**
2632
+ * Close any about:blank tabs that aren't tracked as pages.
2633
+ * Chrome (and some other browsers) always opens with an initial blank tab.
2634
+ * When we create an isolated context, that tab can't be reused (wrong context)
2635
+ * and just sits there as a ghost window. This method cleans it up.
2636
+ * @internal
2637
+ */
2638
+ async closeOrphanBlankTabs() {
2639
+ try {
2640
+ const tree = await this.session.browsingContext.getTree();
2641
+ const contexts = tree.contexts ?? [];
2642
+ for (const ctx of contexts) {
2643
+ const alreadyTracked = this.pages.some((p) => p.contextId === ctx.context);
2644
+ if (!alreadyTracked && ctx.url === "about:blank") {
2645
+ await this.session.browsingContext.close({ context: ctx.context }).catch(() => {
2646
+ });
2647
+ }
2648
+ }
2649
+ } catch {
2650
+ }
2651
+ }
2384
2652
  /**
2385
2653
  * Try to find and reuse an existing about:blank tab (Chrome opens one on startup).
2386
2654
  * Returns the context ID if found, or null if no idle tab exists.
@@ -2417,10 +2685,10 @@ var BrowserContext = class {
2417
2685
  }
2418
2686
  /** Create a new page in this context. */
2419
2687
  async newPage() {
2420
- const params = { type: "tab" };
2421
- if (this.userContext) {
2422
- params.userContext = this.userContext;
2423
- }
2688
+ const params = {
2689
+ type: "tab",
2690
+ ...this.userContext ? { userContext: this.userContext } : {}
2691
+ };
2424
2692
  const result = await this.session.browsingContext.create(params);
2425
2693
  const contextId = result.context;
2426
2694
  await applyViewport(this.session, contextId, this.config);
@@ -2645,6 +2913,6 @@ async function captureScreenshot(page, testCase, outputDir) {
2645
2913
  return filePath;
2646
2914
  }
2647
2915
 
2648
- export { BrowsecraftError, Browser, BrowserContext, ElementHandle, ElementNotActionableError, ElementNotFoundError, NetworkError, Page, TimeoutError, afterAll, afterEach, beforeAll, beforeEach, defineConfig, describe, resetTestState, resolveConfig, runAfterAllHooks, runTest, sleep, test, testRegistry, waitFor };
2649
- //# sourceMappingURL=chunk-P3F7FGFM.js.map
2650
- //# sourceMappingURL=chunk-P3F7FGFM.js.map
2916
+ export { BrowsecraftError, Browser, BrowserContext, ElementHandle, ElementNotActionableError, ElementNotFoundError, NetworkError, Page, TimeoutError, afterAll, afterEach, beforeAll, beforeEach, classifyFailure, defineConfig, describe, resetTestState, resolveAIConfig, resolveConfig, runAfterAllHooks, runTest, sleep, test, testRegistry, waitFor };
2917
+ //# sourceMappingURL=chunk-GAN2YUTJ.js.map
2918
+ //# sourceMappingURL=chunk-GAN2YUTJ.js.map