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 +9 -6
- package/dist/{chunk-P3F7FGFM.js → chunk-GAN2YUTJ.js} +292 -24
- package/dist/chunk-GAN2YUTJ.js.map +1 -0
- package/dist/{chunk-Y2EJCBDU.cjs → chunk-UE5S5LD3.cjs} +293 -23
- package/dist/chunk-UE5S5LD3.cjs.map +1 -0
- package/dist/cli.cjs +365 -71
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +362 -68
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +32 -24
- package/dist/index.d.cts +144 -4
- package/dist/index.d.ts +144 -4
- package/dist/index.js +1 -1
- package/package.json +4 -4
- package/dist/chunk-P3F7FGFM.js.map +0 -1
- package/dist/chunk-Y2EJCBDU.cjs.map +0 -1
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
|
-
|
|
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` |
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 = {
|
|
2421
|
-
|
|
2422
|
-
|
|
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-
|
|
2650
|
-
//# sourceMappingURL=chunk-
|
|
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
|