explorbot 0.3.5 → 0.4.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/bin/explorbot-cli.ts +23 -16
- package/boat/api-tester/src/ai/chief.ts +7 -1
- package/boat/api-tester/src/ai/curler.ts +7 -1
- package/boat/api-tester/src/apibot.ts +10 -4
- package/boat/api-tester/src/cli.ts +12 -2
- package/boat/api-tester/src/config.ts +28 -8
- package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
- package/boat/doc-collector/src/cli.ts +6 -1
- package/boat/doc-collector/src/docbot.ts +4 -2
- package/boat/prima/bin/prima-cli.ts +2 -0
- package/boat/prima/src/cli.ts +23 -8
- package/boat/prima/src/envelope.ts +35 -9
- package/boat/prima/src/prima.ts +23 -10
- package/dist/bin/explorbot-cli.js +24 -16
- package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
- package/dist/boat/api-tester/src/ai/chief.js +6 -1
- package/dist/boat/api-tester/src/ai/curler.js +6 -1
- package/dist/boat/api-tester/src/apibot.js +7 -3
- package/dist/boat/api-tester/src/cli.js +12 -2
- package/dist/boat/api-tester/src/config.js +31 -8
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
- package/dist/boat/doc-collector/src/cli.js +6 -1
- package/dist/boat/doc-collector/src/docbot.js +4 -2
- package/dist/boat/prima/bin/prima-cli.js +2 -0
- package/dist/boat/prima/src/cli.js +22 -8
- package/dist/boat/prima/src/envelope.js +24 -6
- package/dist/boat/prima/src/prima.js +23 -11
- package/dist/package.json +2 -2
- package/dist/rules/planner/styles/normal.md +1 -1
- package/dist/src/action-result.d.ts +9 -1
- package/dist/src/action-result.js +57 -18
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +87 -12
- package/dist/src/ai/captain.js +1 -1
- package/dist/src/ai/driller.d.ts +0 -1
- package/dist/src/ai/driller.js +8 -20
- package/dist/src/ai/fisherman-tools.d.ts +9 -0
- package/dist/src/ai/fisherman-tools.js +52 -6
- package/dist/src/ai/fisherman.d.ts +4 -2
- package/dist/src/ai/fisherman.js +48 -27
- package/dist/src/ai/historian/codeceptjs.js +1 -1
- package/dist/src/ai/historian/playwright.js +1 -1
- package/dist/src/ai/navigator.js +1 -1
- package/dist/src/ai/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +13 -1
- package/dist/src/ai/planner.js +9 -7
- package/dist/src/ai/provider.js +20 -3
- package/dist/src/ai/researcher/deep-analysis.js +1 -3
- package/dist/src/ai/researcher.js +5 -3
- package/dist/src/ai/rules.js +3 -3
- package/dist/src/ai/tester.d.ts +3 -0
- package/dist/src/ai/tester.js +40 -3
- package/dist/src/ai/tools.d.ts +1 -0
- package/dist/src/ai/tools.js +13 -6
- package/dist/src/api/request-result.d.ts +2 -0
- package/dist/src/api/request-result.js +8 -2
- package/dist/src/api/request-store.d.ts +3 -2
- package/dist/src/api/request-store.js +66 -14
- package/dist/src/api/spec-reader.js +1 -1
- package/dist/src/commands/config-command.js +1 -1
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/explore-command.d.ts +6 -0
- package/dist/src/commands/explore-command.js +39 -3
- package/dist/src/commands/freesail-command.js +10 -1
- package/dist/src/commands/options/base-option.d.ts +8 -0
- package/dist/src/commands/options/base-option.js +12 -0
- package/dist/src/commands/options/index.d.ts +5 -0
- package/dist/src/commands/options/index.js +5 -0
- package/dist/src/commands/options/knowledge-option.d.ts +7 -0
- package/dist/src/commands/options/knowledge-option.js +12 -0
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +21 -0
- package/dist/src/commands/plans-command.js +6 -6
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +12 -0
- package/dist/src/experience-tracker.js +5 -0
- package/dist/src/explorbot.d.ts +0 -1
- package/dist/src/explorbot.js +24 -37
- package/dist/src/knowledge-tracker.d.ts +20 -7
- package/dist/src/knowledge-tracker.js +69 -31
- package/dist/src/remote.d.ts +0 -3
- package/dist/src/remote.js +0 -18
- package/dist/src/state-manager.d.ts +5 -1
- package/dist/src/state-manager.js +10 -7
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +27 -0
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +6 -42
- package/dist/src/utils/html-diff.d.ts +4 -0
- package/dist/src/utils/html-diff.js +62 -7
- package/dist/src/utils/html.d.ts +5 -15
- package/dist/src/utils/html.js +14 -85
- package/dist/src/utils/overlay.d.ts +56 -11
- package/dist/src/utils/overlay.js +191 -21
- package/dist/src/utils/request-map.d.ts +7 -0
- package/dist/src/utils/request-map.js +16 -0
- package/dist/src/utils/url-matcher.js +4 -2
- package/docs/api-testing/basics.md +15 -0
- package/docs/api-testing/planning.md +10 -1
- package/docs/reference/commands.md +32 -5
- package/docs/reference/websocket.md +1 -0
- package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
- package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
- package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
- package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
- package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
- package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
- package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
- package/docs/workflow/agentic-usage.md +12 -2
- package/docs/workflow/ci.md +1 -0
- package/docs/workflow/knowledge.md +46 -2
- package/package.json +2 -2
- package/rules/planner/styles/normal.md +1 -1
- package/src/action-result.ts +61 -22
- package/src/action.ts +87 -14
- package/src/ai/captain.ts +1 -1
- package/src/ai/driller.ts +7 -39
- package/src/ai/fisherman-tools.ts +56 -7
- package/src/ai/fisherman.ts +48 -28
- package/src/ai/historian/codeceptjs.ts +1 -1
- package/src/ai/historian/playwright.ts +1 -1
- package/src/ai/navigator.ts +1 -1
- package/src/ai/pilot.ts +9 -1
- package/src/ai/planner.ts +9 -8
- package/src/ai/provider.ts +21 -3
- package/src/ai/researcher/deep-analysis.ts +1 -2
- package/src/ai/researcher.ts +4 -3
- package/src/ai/rules.ts +3 -3
- package/src/ai/tester.ts +40 -3
- package/src/ai/tools.ts +17 -9
- package/src/api/request-result.ts +10 -2
- package/src/api/request-store.ts +60 -13
- package/src/api/spec-reader.ts +1 -1
- package/src/commands/config-command.ts +1 -1
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/explore-command.ts +37 -3
- package/src/commands/freesail-command.ts +7 -1
- package/src/commands/options/base-option.ts +18 -0
- package/src/commands/options/index.ts +7 -0
- package/src/commands/options/knowledge-option.ts +14 -0
- package/src/commands/options/ws-option.ts +24 -0
- package/src/commands/plans-command.ts +6 -6
- package/src/config.ts +12 -0
- package/src/experience-tracker.ts +5 -1
- package/src/explorbot.ts +21 -37
- package/src/knowledge-tracker.ts +94 -36
- package/src/remote.ts +0 -20
- package/src/state-manager.ts +13 -7
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +7 -44
- package/src/utils/html-diff.ts +62 -7
- package/src/utils/html.ts +14 -91
- package/src/utils/overlay.ts +226 -23
- package/src/utils/request-map.ts +19 -0
- package/src/utils/url-matcher.ts +3 -2
|
@@ -45,7 +45,48 @@ While exploring, use the `/learn` command.
|
|
|
45
45
|
|
|
46
46
|
### API Testing
|
|
47
47
|
|
|
48
|
-
[API testing](../api-testing/basics.md) shares the same `knowledge/` directory. `npx explorbot api know <endpoint> "<description>"` adds endpoint-scoped notes, stored with an `endpoint:` frontmatter field instead of `url:`.
|
|
48
|
+
[API testing](../api-testing/basics.md) shares the same `knowledge/` directory. `npx explorbot api know <endpoint> "<description>"` adds endpoint-scoped notes, stored with an `endpoint:` frontmatter field instead of `url:`. Chief reads them when planning an endpoint and Curler reads them when running its tests, so auth headers and payload rules reach both.
|
|
49
|
+
|
|
50
|
+
## Per-Session Knowledge
|
|
51
|
+
|
|
52
|
+
`--knowledge` passes facts to a single run. Nothing is written to `knowledge/`, so credentials and one-off test data stay out of the repository.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx explorbot explore /pay --knowledge 'My credit card is 4111 1111 1111 1111'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Plain text applies to every page. Add frontmatter to scope it, with the same URL patterns knowledge files use:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx explorbot explore / --knowledge '---
|
|
62
|
+
url: /pay
|
|
63
|
+
---
|
|
64
|
+
Use the sandbox card 4111 1111 1111 1111 with any future expiry'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Repeat the flag for several facts:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx explorbot explore / \
|
|
71
|
+
--knowledge '---
|
|
72
|
+
url: /login
|
|
73
|
+
---
|
|
74
|
+
Log in as admin@example.com / secret123' \
|
|
75
|
+
--knowledge 'Dismiss the cookie banner before anything else'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Everything a knowledge file supports works here: `${env.VAR}` interpolation, and page automation fields such as `wait` and `waitForElement`.
|
|
79
|
+
|
|
80
|
+
The flag works on every command of `explorbot`, `explorbot api`, `explorbot docs` and `prima`, and can go anywhere on the line. Scope API knowledge with `endpoint:` instead of `url:`:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npx explorbot api explore /orders --knowledge '---
|
|
84
|
+
endpoint: /orders/*
|
|
85
|
+
---
|
|
86
|
+
Send X-Api-Key: ${env.API_KEY} on every request'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
For runs driven from the environment — config-free, or on the global configuration — `EXPLORBOT_KNOWLEDGE` does the same job for the length of one run. See [Agentic usage](./agentic-usage.md).
|
|
49
90
|
|
|
50
91
|
## URL Patterns
|
|
51
92
|
|
|
@@ -81,10 +122,13 @@ Notes:
|
|
|
81
122
|
|
|
82
123
|
| Field | Purpose |
|
|
83
124
|
|-------|---------|
|
|
84
|
-
| `url` | URL pattern to match
|
|
125
|
+
| `url` | Page URL pattern to match |
|
|
126
|
+
| `endpoint` | API endpoint pattern to match |
|
|
85
127
|
| `title` | Human-readable title (optional) |
|
|
86
128
|
| Custom fields | Any additional metadata for agents |
|
|
87
129
|
|
|
130
|
+
`url` scopes a file to browser pages and `endpoint` scopes it to API endpoints. A file with neither applies to both.
|
|
131
|
+
|
|
88
132
|
## Variables
|
|
89
133
|
|
|
90
134
|
Knowledge files support variable interpolation with `${namespace.key}` syntax. Explorbot resolves variables when it loads the knowledge.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"dependencies": {
|
|
78
78
|
"@ai-sdk/anthropic": "^4.0",
|
|
79
79
|
"@ai-sdk/google": "^4.0.18",
|
|
80
|
-
"@ai-sdk/groq": "^4.0",
|
|
80
|
+
"@ai-sdk/groq": "^4.0.34",
|
|
81
81
|
"@ai-sdk/mistral": "^4.0.13",
|
|
82
82
|
"@ai-sdk/openai": "^4.0",
|
|
83
83
|
"@ai-sdk/otel": "^1.0.2",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Study the page and figure out its business purpose. What is this page FOR? What would a user come here to do?
|
|
2
2
|
|
|
3
3
|
Based on the page type, propose tests for COMPLETE user workflows:
|
|
4
|
-
- If this is a data page (lists, tables): test
|
|
4
|
+
- If this is a data page (lists, tables): test create, edit and delete as separate tests, each preparing the item it acts on
|
|
5
5
|
- If the page has inputs to fill in: test the full commit flow, not just that the controls render
|
|
6
6
|
- If this has filters and search: test filtering AND verify results change, not just "filter tab clicked"
|
|
7
7
|
- If this has modals/dropdowns: test the ACTION inside them, not just opening/closing them
|
package/src/action-result.ts
CHANGED
|
@@ -35,7 +35,6 @@ interface ActionResultData extends WebPageState {
|
|
|
35
35
|
focusedElement?: FocusedElement | null;
|
|
36
36
|
iframeURL?: string;
|
|
37
37
|
links?: Link[];
|
|
38
|
-
overlayHtml?: string;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
export interface PageDiff {
|
|
@@ -49,6 +48,7 @@ export interface PageDiff {
|
|
|
49
48
|
consoleErrors?: string[];
|
|
50
49
|
htmlParts?: HtmlDiffPart[];
|
|
51
50
|
iframes?: string;
|
|
51
|
+
areaOfInterest?: string;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export interface ToolResultMetadata {
|
|
@@ -89,6 +89,7 @@ export class ActionResult implements ActionResultData {
|
|
|
89
89
|
public links: Link[] = [];
|
|
90
90
|
public verifications?: Record<string, boolean>;
|
|
91
91
|
public overlay: Overlay = new Overlay();
|
|
92
|
+
private _diffCache: { previousId: number | undefined; diff: Diff } | null = null;
|
|
92
93
|
|
|
93
94
|
constructor(data: ActionResultData) {
|
|
94
95
|
this.id = data.id;
|
|
@@ -168,6 +169,7 @@ export class ActionResult implements ActionResultData {
|
|
|
168
169
|
set html(value: string) {
|
|
169
170
|
this._html = value;
|
|
170
171
|
this.snapshotCache.clear();
|
|
172
|
+
this._diffCache = null;
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
get screenshot(): Buffer | undefined {
|
|
@@ -260,6 +262,11 @@ export class ActionResult implements ActionResultData {
|
|
|
260
262
|
|
|
261
263
|
isRelevantExperienceRecord(record: WebPageState, options?: { includeDescendantExperience?: boolean }): boolean {
|
|
262
264
|
if (!record.url || !this.url) return false;
|
|
265
|
+
if (record.region && this.overlay.name !== record.region) return false;
|
|
266
|
+
if (record.root) {
|
|
267
|
+
if (!this.overlay.present) return false;
|
|
268
|
+
if (this.overlay.root && this.overlay.root !== record.root) return false;
|
|
269
|
+
}
|
|
263
270
|
if (this.isMatchedBy(record)) return true;
|
|
264
271
|
if (!options?.includeDescendantExperience) return false;
|
|
265
272
|
const cur = extractStatePath(this.url);
|
|
@@ -476,29 +483,18 @@ export class ActionResult implements ActionResultData {
|
|
|
476
483
|
}
|
|
477
484
|
|
|
478
485
|
getStateHash(): string {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
parts.push(this.relativeUrl || this.url || '/');
|
|
482
|
-
|
|
483
|
-
this.extractHeadings(this.html);
|
|
484
|
-
|
|
485
|
-
if (this.h1) parts.push(`h1_${this.h1}`);
|
|
486
|
-
if (this.h2) parts.push(`h2_${this.h2}`);
|
|
487
|
-
|
|
488
|
-
let stateString = slugify(parts.map((part) => part.substring(0, 100)).join('_'));
|
|
489
|
-
|
|
490
|
-
if (stateString.length > 200) {
|
|
491
|
-
stateString = stateString.substring(0, 200);
|
|
492
|
-
if (stateString.endsWith('_')) {
|
|
493
|
-
stateString = stateString.slice(0, -1);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
486
|
+
return this.computeStateHash(true);
|
|
487
|
+
}
|
|
496
488
|
|
|
497
|
-
|
|
489
|
+
get baseHash(): string {
|
|
490
|
+
return this.computeStateHash(false);
|
|
498
491
|
}
|
|
499
492
|
|
|
500
493
|
async diff(previousState: ActionResult | null): Promise<Diff> {
|
|
501
|
-
|
|
494
|
+
if (this._diffCache && this._diffCache.previousId === previousState?.id) return this._diffCache.diff;
|
|
495
|
+
const diff = await Diff.create(this, previousState);
|
|
496
|
+
this._diffCache = { previousId: previousState?.id, diff };
|
|
497
|
+
return diff;
|
|
502
498
|
}
|
|
503
499
|
|
|
504
500
|
async toToolResult(previousState: ActionResult | null, locator: string): Promise<ToolResultMetadata> {
|
|
@@ -549,7 +545,18 @@ export class ActionResult implements ActionResultData {
|
|
|
549
545
|
pageDiff.ariaChangeCount = diff.ariaChangeCount;
|
|
550
546
|
}
|
|
551
547
|
|
|
552
|
-
if (
|
|
548
|
+
if (this.overlay.present && (!previousState.overlay.present || previousState.overlay.name !== this.overlay.name)) {
|
|
549
|
+
pageDiff.areaOfInterest = this.overlay.describe();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (pageDiff.areaOfInterest && this.overlay.html && this.overlay.root) {
|
|
553
|
+
const htmlConfig = ConfigParser.getInstance().getConfig().html;
|
|
554
|
+
let subtree = await minifyHtml(htmlCombinedSnapshot(this.overlay.html, htmlConfig?.combined));
|
|
555
|
+
if (subtree.length > HTML_PART_SUBTREE_BUDGET) {
|
|
556
|
+
subtree = `${subtree.slice(0, HTML_PART_SUBTREE_BUDGET)}...<!-- truncated -->`;
|
|
557
|
+
}
|
|
558
|
+
pageDiff.htmlParts = [{ container: this.overlay.root, subtree, rawSize: subtree.length, added: [], removed: [] }];
|
|
559
|
+
} else if (diff.isSameUrl() && diff.htmlParts.length > 0) {
|
|
553
560
|
const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
|
|
554
561
|
if (collapsed.length > 0) {
|
|
555
562
|
pageDiff.htmlParts = collapsed;
|
|
@@ -565,6 +572,29 @@ export class ActionResult implements ActionResultData {
|
|
|
565
572
|
return result;
|
|
566
573
|
}
|
|
567
574
|
|
|
575
|
+
private computeStateHash(includeRegion: boolean): string {
|
|
576
|
+
const parts: string[] = [];
|
|
577
|
+
|
|
578
|
+
parts.push(this.relativeUrl || this.url || '/');
|
|
579
|
+
|
|
580
|
+
this.extractHeadings(this.html);
|
|
581
|
+
|
|
582
|
+
if (this.h1) parts.push(`h1_${this.h1}`);
|
|
583
|
+
if (this.h2) parts.push(`h2_${this.h2}`);
|
|
584
|
+
if (includeRegion && this.overlay.present && this.overlay.name) parts.push(`region_${this.overlay.name}`);
|
|
585
|
+
|
|
586
|
+
let stateString = slugify(parts.map((part) => part.substring(0, 100)).join('_'));
|
|
587
|
+
|
|
588
|
+
if (stateString.length > 200) {
|
|
589
|
+
stateString = stateString.substring(0, 200);
|
|
590
|
+
if (stateString.endsWith('_')) {
|
|
591
|
+
stateString = stateString.slice(0, -1);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return stateString;
|
|
596
|
+
}
|
|
597
|
+
|
|
568
598
|
private consoleErrors(): string[] {
|
|
569
599
|
const errors: string[] = [];
|
|
570
600
|
|
|
@@ -681,15 +711,24 @@ export class Diff {
|
|
|
681
711
|
return this._messages;
|
|
682
712
|
}
|
|
683
713
|
|
|
714
|
+
get similarity(): number {
|
|
715
|
+
return this._htmlDiffResult?.similarity ?? 0;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
get pageSize(): number {
|
|
719
|
+
return this._htmlDiffResult?.pageSize ?? 0;
|
|
720
|
+
}
|
|
721
|
+
|
|
684
722
|
async calculate(): Promise<void> {
|
|
685
723
|
if (!this.previous) return;
|
|
686
724
|
|
|
725
|
+
this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html, ConfigParser.getInstance().getConfig().html);
|
|
726
|
+
|
|
687
727
|
if (!this._isSameUrl) {
|
|
688
728
|
this._messages = liveRegionMessages(this.previous.html, this.current.html);
|
|
689
729
|
return;
|
|
690
730
|
}
|
|
691
731
|
|
|
692
|
-
this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html, ConfigParser.getInstance().getConfig().html);
|
|
693
732
|
this._messages = this._htmlDiffResult.messages;
|
|
694
733
|
|
|
695
734
|
const ariaDiff = diffAriaSnapshots(this.previous.ariaSnapshot, this.current.ariaSnapshot);
|
package/src/action.ts
CHANGED
|
@@ -11,9 +11,9 @@ import { Observability } from './observability.ts';
|
|
|
11
11
|
import type { PlaywrightRecorder } from './playwright-recorder.ts';
|
|
12
12
|
import type { StateManager } from './state-manager.js';
|
|
13
13
|
import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
|
|
14
|
-
import { captureHtmlForSnapshot,
|
|
14
|
+
import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
15
15
|
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
|
|
16
|
-
import { Overlay } from './utils/overlay.js';
|
|
16
|
+
import { Overlay, OverlayPage } from './utils/overlay.js';
|
|
17
17
|
import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
|
|
18
18
|
import { safeFilename } from './utils/strings.ts';
|
|
19
19
|
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
|
|
@@ -146,13 +146,11 @@ class Action {
|
|
|
146
146
|
let ariaSnapshot: string | null = null;
|
|
147
147
|
let ariaSnapshotFile: string | undefined = undefined;
|
|
148
148
|
let focusedElement: FocusedElement | null = null;
|
|
149
|
-
let overlayHtml = '';
|
|
150
149
|
|
|
151
150
|
try {
|
|
152
151
|
const page = this.playwrightHelper.page;
|
|
153
152
|
ariaSnapshot = await page.locator('body').ariaSnapshot();
|
|
154
153
|
focusedElement = await page.evaluate(readFocusedElement);
|
|
155
|
-
if (!frame) overlayHtml = await this.captureOverlayHtml();
|
|
156
154
|
} catch (err) {
|
|
157
155
|
debugLog('ARIA snapshot failed:', err instanceof Error ? `${err.message}\n${err.stack}` : err);
|
|
158
156
|
}
|
|
@@ -181,9 +179,9 @@ class Action {
|
|
|
181
179
|
ariaSnapshot,
|
|
182
180
|
ariaSnapshotFile,
|
|
183
181
|
focusedElement,
|
|
184
|
-
overlayHtml: overlayHtml || undefined,
|
|
185
182
|
iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
|
|
186
183
|
});
|
|
184
|
+
if (!frame) await this.detectRegionOfInterest(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
|
|
187
185
|
this.stateManager.updateState(result, codeBlock);
|
|
188
186
|
return result;
|
|
189
187
|
} catch (err) {
|
|
@@ -195,14 +193,64 @@ class Action {
|
|
|
195
193
|
}
|
|
196
194
|
}
|
|
197
195
|
|
|
198
|
-
private async
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
196
|
+
private async detectRegionOfInterest(result: ActionResult): Promise<void> {
|
|
197
|
+
const previousState = this.stateManager.getCurrentState();
|
|
198
|
+
if (!previousState) return;
|
|
199
|
+
const previous = ActionResult.fromState(previousState);
|
|
200
|
+
const previousOverlay = previous.overlay;
|
|
201
|
+
const sameUrl = !!previous.url && result.isSameUrl({ url: previous.url });
|
|
202
|
+
const overlayPage = new OverlayPage(this.playwrightHelper.page);
|
|
203
|
+
|
|
204
|
+
if (result.overlay.detected && previousOverlay.detected && previousOverlay.root && previousOverlay.type === result.overlay.type && previousOverlay.name === result.overlay.name) {
|
|
205
|
+
result.overlay = previousOverlay;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!previous.html) return;
|
|
210
|
+
|
|
211
|
+
if (previous.html === result.html) {
|
|
212
|
+
if (sameUrl && previousOverlay.present && previousOverlay.xpath && !result.overlay.detected) result.overlay = previousOverlay;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let carried: Overlay | null = null;
|
|
217
|
+
if (sameUrl && previousOverlay.present && previousOverlay.xpath) {
|
|
218
|
+
if (await overlayPage.isStillOpen(previousOverlay)) {
|
|
219
|
+
carried = previousOverlay;
|
|
220
|
+
} else {
|
|
221
|
+
debugLog(`Region closed: ${previousOverlay.name || previousOverlay.type}`);
|
|
222
|
+
if (!result.overlay.detected) {
|
|
223
|
+
const parent = previousOverlay.parent;
|
|
224
|
+
if (parent?.xpath) {
|
|
225
|
+
const restored = new Overlay(parent);
|
|
226
|
+
if (await overlayPage.isStillOpen(restored)) result.overlay = restored;
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const diff = await result.diff(previous);
|
|
234
|
+
const detected = await overlayPage.detectRegion({
|
|
235
|
+
parts: diff.htmlParts,
|
|
236
|
+
pageSize: diff.pageSize,
|
|
237
|
+
similarity: diff.similarity,
|
|
238
|
+
sameUrl,
|
|
239
|
+
previousHtml: previous.html,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (result.overlay.detected) {
|
|
243
|
+
if (detected) result.overlay = result.overlay.withGeometry(detected);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (detected) {
|
|
248
|
+
result.overlay = detected;
|
|
249
|
+
if (carried) result.overlay = detected.withParent(carried);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (carried) result.overlay = carried;
|
|
206
254
|
}
|
|
207
255
|
|
|
208
256
|
private async captureMainDocumentStatus(): Promise<number | undefined> {
|
|
@@ -381,6 +429,13 @@ class Action {
|
|
|
381
429
|
await recorder.reset();
|
|
382
430
|
await recorder.start();
|
|
383
431
|
}
|
|
432
|
+
if (executedSteps.length > 0) {
|
|
433
|
+
codeString = executedSteps.map((step) => step.command).join('\n');
|
|
434
|
+
}
|
|
435
|
+
if (!isFatalBrowserError(err)) {
|
|
436
|
+
const captured = await this.captureOnce({ codeBlock: codeString }).catch(() => null);
|
|
437
|
+
if (captured && !captured.error) this.actionResult = captured;
|
|
438
|
+
}
|
|
384
439
|
this.assertionSteps = [];
|
|
385
440
|
throw err;
|
|
386
441
|
} finally {
|
|
@@ -491,11 +546,29 @@ const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeEl
|
|
|
491
546
|
type StepListener = (step: any, error?: any) => void;
|
|
492
547
|
|
|
493
548
|
export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Array<{ name: string; args: any[] }>): (() => void) => {
|
|
549
|
+
const recorded = new WeakMap<object, ExecutedStep>();
|
|
550
|
+
let batchFailed = false;
|
|
494
551
|
const listener: StepListener = (step, error) => {
|
|
495
552
|
if (!step?.toCode) return;
|
|
496
553
|
if (step.name?.startsWith('grab')) return;
|
|
554
|
+
|
|
555
|
+
const existing = recorded.get(step);
|
|
556
|
+
if (existing) {
|
|
557
|
+
if (!error && !existing.success) {
|
|
558
|
+
existing.success = true;
|
|
559
|
+
existing.error = undefined;
|
|
560
|
+
batchFailed = target.some((entry) => !entry.success);
|
|
561
|
+
}
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (batchFailed) return;
|
|
565
|
+
|
|
497
566
|
const executed: ExecutedStep = { command: step.toCode(), success: !error };
|
|
498
|
-
if (error)
|
|
567
|
+
if (error) {
|
|
568
|
+
executed.error = errorToString(error);
|
|
569
|
+
batchFailed = true;
|
|
570
|
+
}
|
|
571
|
+
recorded.set(step, executed);
|
|
499
572
|
target.push(executed);
|
|
500
573
|
if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
|
|
501
574
|
assertionsTarget.push({ name: step.name, args: step.args || [] });
|
package/src/ai/captain.ts
CHANGED
|
@@ -160,7 +160,7 @@ export class Captain extends CaptainBase implements Agent {
|
|
|
160
160
|
const headingsBlock = headingLines.join('\n');
|
|
161
161
|
|
|
162
162
|
let pageSummary = '';
|
|
163
|
-
const cachedResearch = Researcher.getCachedResearch(
|
|
163
|
+
const cachedResearch = Researcher.getCachedResearch(actionResult);
|
|
164
164
|
if (cachedResearch) {
|
|
165
165
|
pageSummary = `<page_summary>\n${this.explorBot.agentResearcher().extractBrief(cachedResearch)}\n</page_summary>`;
|
|
166
166
|
}
|
package/src/ai/driller.ts
CHANGED
|
@@ -6,23 +6,9 @@ import { setActivity } from '../activity.ts';
|
|
|
6
6
|
import { Observability } from '../observability.ts';
|
|
7
7
|
import { Plan, Test, TestResult } from '../test-plan.ts';
|
|
8
8
|
import { collectInteractiveNodes } from '../utils/aria.ts';
|
|
9
|
-
import {
|
|
10
|
-
EXPLORBOT_ATTRS,
|
|
11
|
-
HTML_COMPOSITE_AREA_HINTS,
|
|
12
|
-
HTML_COMPOSITE_TARGET_ROLES,
|
|
13
|
-
HTML_EXTRACTION_LIMITS,
|
|
14
|
-
HTML_FORM_CONTROL_ROLES,
|
|
15
|
-
HTML_FORM_CONTROL_TAGS,
|
|
16
|
-
HTML_INTERACTIVE_ROLES,
|
|
17
|
-
HTML_SELECTORS,
|
|
18
|
-
HTML_VISIBILITY_LIMITS,
|
|
19
|
-
getComponentScopeHtmlExtractorSource,
|
|
20
|
-
getVisibleOverlayHtmlExtractorSource,
|
|
21
|
-
inferHtmlRole,
|
|
22
|
-
} from '../utils/html.ts';
|
|
9
|
+
import { EXPLORBOT_ATTRS, HTML_COMPOSITE_AREA_HINTS, HTML_COMPOSITE_TARGET_ROLES, HTML_EXTRACTION_LIMITS, HTML_FORM_CONTROL_ROLES, HTML_FORM_CONTROL_TAGS, HTML_INTERACTIVE_ROLES, HTML_SELECTORS, getComponentScopeHtmlExtractorSource, inferHtmlRole } from '../utils/html.ts';
|
|
23
10
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
24
11
|
import { loop, pause } from '../utils/loop.ts';
|
|
25
|
-
import { OVERLAY_SELECTORS } from '../utils/overlay.ts';
|
|
26
12
|
import { annotatePageElements } from '../utils/web-annotate.ts';
|
|
27
13
|
import { eidxInContainer } from '../utils/web-eidx.ts';
|
|
28
14
|
import { WebElement } from '../utils/web-element.ts';
|
|
@@ -648,8 +634,11 @@ export class Driller extends TaskAgent implements Agent {
|
|
|
648
634
|
private async detectNestedOverlayContext(component: ComponentInfo, result: any): Promise<string | null> {
|
|
649
635
|
if (!result?.pageDiff?.ariaChanges || result.pageDiff.urlChanged) return null;
|
|
650
636
|
|
|
651
|
-
const
|
|
652
|
-
|
|
637
|
+
const parts = result.pageDiff.htmlParts ?? [];
|
|
638
|
+
let appeared = parts.filter((part: any) => part.added?.length > 0);
|
|
639
|
+
if (result.pageDiff.areaOfInterest) appeared = parts;
|
|
640
|
+
const appearedHtml = appeared.map((part: any) => part.subtree).join('\n');
|
|
641
|
+
if (!appearedHtml) return null;
|
|
653
642
|
|
|
654
643
|
const state = this.stateManager.getCurrentState();
|
|
655
644
|
if (!state) return null;
|
|
@@ -661,7 +650,7 @@ export class Driller extends TaskAgent implements Agent {
|
|
|
661
650
|
Keep the recorded code reusable and include the parent-opening action when the nested element requires the overlay to be open.
|
|
662
651
|
|
|
663
652
|
<overlay_html>
|
|
664
|
-
${
|
|
653
|
+
${appearedHtml}
|
|
665
654
|
</overlay_html>
|
|
666
655
|
|
|
667
656
|
<current_page_aria>
|
|
@@ -671,27 +660,6 @@ export class Driller extends TaskAgent implements Agent {
|
|
|
671
660
|
`;
|
|
672
661
|
}
|
|
673
662
|
|
|
674
|
-
private async getVisibleOverlayHtml(): Promise<string> {
|
|
675
|
-
return this.explorer.withPage((page) =>
|
|
676
|
-
page.evaluate(
|
|
677
|
-
({ extractorSource, config }) => {
|
|
678
|
-
const extract = new Function(`return ${extractorSource}`)() as (config: any) => string;
|
|
679
|
-
return extract(config);
|
|
680
|
-
},
|
|
681
|
-
{
|
|
682
|
-
extractorSource: getVisibleOverlayHtmlExtractorSource(),
|
|
683
|
-
config: {
|
|
684
|
-
interactiveContentSelector: HTML_SELECTORS.interactiveContent,
|
|
685
|
-
limits: HTML_EXTRACTION_LIMITS,
|
|
686
|
-
overlaySelectors: OVERLAY_SELECTORS.semanticOverlays,
|
|
687
|
-
overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
|
|
688
|
-
visibilityLimits: HTML_VISIBILITY_LIMITS,
|
|
689
|
-
},
|
|
690
|
-
}
|
|
691
|
-
)
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
663
|
private async getComponentScopeHtml(component: ComponentInfo, originalState: ActionResult): Promise<string> {
|
|
696
664
|
const scopedHtml = await this.explorer.withPage((page) =>
|
|
697
665
|
page.evaluate(
|
|
@@ -2,16 +2,28 @@ import { tool } from 'ai';
|
|
|
2
2
|
import dedent from 'dedent';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import type { ApiClient } from '../api/api-client.ts';
|
|
5
|
+
import type { RequestResult } from '../api/request-result.ts';
|
|
5
6
|
import type { RequestStore } from '../api/request-store.ts';
|
|
6
7
|
import { extractEndpointDefinition } from '../api/spec-reader.ts';
|
|
7
8
|
import { tag } from '../utils/logger.ts';
|
|
9
|
+
import { RequestMap } from '../utils/request-map.ts';
|
|
10
|
+
import { isDynamicSegment } from '../utils/url-matcher.ts';
|
|
8
11
|
|
|
9
12
|
export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, opts: { spec?: any; baseEndpoint?: string }) {
|
|
10
13
|
let finished = false;
|
|
11
|
-
let result: FishermanResult
|
|
14
|
+
let result: FishermanResult | null = null;
|
|
15
|
+
const ledgerStart = requestStore.getMadeRequests().length;
|
|
12
16
|
|
|
13
|
-
const
|
|
17
|
+
const runRequests = () => requestStore.getMadeRequests().slice(ledgerStart);
|
|
18
|
+
const successfulWrites = () => runRequests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
|
|
19
|
+
const getResult = () => result ?? synthesizeResult(runRequests(), successfulWrites(), false);
|
|
14
20
|
const isFinished = () => finished;
|
|
21
|
+
const finishFromText = (text?: string) => {
|
|
22
|
+
finished = true;
|
|
23
|
+
const synthesized = synthesizeResult(runRequests(), successfulWrites(), true);
|
|
24
|
+
if (text && synthesized.success) synthesized.summary = text;
|
|
25
|
+
result = synthesized;
|
|
26
|
+
};
|
|
15
27
|
|
|
16
28
|
const tools = {
|
|
17
29
|
getEndpointSpec: tool({
|
|
@@ -76,7 +88,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
76
88
|
request: tool({
|
|
77
89
|
description: dedent`
|
|
78
90
|
Make an HTTP request to the API.
|
|
79
|
-
Returns status,
|
|
91
|
+
Returns status, plus IDs and names auto-extracted from the response under 'extracted'.
|
|
80
92
|
`,
|
|
81
93
|
inputSchema: z.object({
|
|
82
94
|
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
|
|
@@ -119,7 +131,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
119
131
|
return {
|
|
120
132
|
success: true,
|
|
121
133
|
status: reqResult.status,
|
|
122
|
-
|
|
134
|
+
extracted,
|
|
123
135
|
};
|
|
124
136
|
},
|
|
125
137
|
}),
|
|
@@ -148,9 +160,32 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
148
160
|
.describe('List of items that could not be created'),
|
|
149
161
|
}),
|
|
150
162
|
execute: async ({ summary, created, failed }) => {
|
|
163
|
+
const writes = successfulWrites();
|
|
164
|
+
if (writes.length === 0) {
|
|
165
|
+
tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
|
|
166
|
+
return { finished: false, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const createdRequests = new RequestMap(writes);
|
|
170
|
+
|
|
171
|
+
const verified: FishermanResult['created'] = [];
|
|
172
|
+
for (const item of created) {
|
|
173
|
+
if (item.id === undefined) {
|
|
174
|
+
verified.push(item);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const request = createdRequests.get(item.id);
|
|
178
|
+
if (!request) {
|
|
179
|
+
tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
verified.push({ ...item, request: request.toEndpoint() });
|
|
183
|
+
}
|
|
184
|
+
if (verified.length === 0) verified.push(...writes.map(toCreatedItem));
|
|
185
|
+
|
|
151
186
|
tag('success').log(`Fisherman done: ${summary}`);
|
|
152
187
|
finished = true;
|
|
153
|
-
result = { success: true, summary, created, failed: failed || [] };
|
|
188
|
+
result = { success: true, summary, created: verified, failed: failed || [] };
|
|
154
189
|
return { finished: true };
|
|
155
190
|
},
|
|
156
191
|
}),
|
|
@@ -169,7 +204,21 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
169
204
|
}),
|
|
170
205
|
};
|
|
171
206
|
|
|
172
|
-
return { tools, getResult, isFinished };
|
|
207
|
+
return { tools, getResult, isFinished, finishFromText };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function synthesizeResult(made: RequestResult[], writes: RequestResult[], declaredDone: boolean): FishermanResult {
|
|
211
|
+
const failures = made.filter((r) => r.status >= 400 || r.error);
|
|
212
|
+
let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
|
|
213
|
+
const lastFailure = failures[failures.length - 1];
|
|
214
|
+
if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`;
|
|
215
|
+
return { success: declaredDone && writes.length > 0, summary, created: writes.map(toCreatedItem), failed: [] };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function toCreatedItem(write: RequestResult): FishermanResult['created'][number] {
|
|
219
|
+
const { id, title } = write.extractIdAndTitle();
|
|
220
|
+
const segments = write.path.split('/').filter((s) => s && !isDynamicSegment(s));
|
|
221
|
+
return { type: segments[segments.length - 1] || 'item', id, title, request: write.toEndpoint() };
|
|
173
222
|
}
|
|
174
223
|
|
|
175
224
|
function responseCategory(status: number): ResponseCategory {
|
|
@@ -209,7 +258,7 @@ function extractKeyFields(body: any, result: Record<string, any> = {}, depth = 0
|
|
|
209
258
|
export interface FishermanResult {
|
|
210
259
|
success: boolean;
|
|
211
260
|
summary: string;
|
|
212
|
-
created: Array<{ type: string; id?: string | number; title?: string }>;
|
|
261
|
+
created: Array<{ type: string; id?: string | number; title?: string; request?: string }>;
|
|
213
262
|
failed: Array<{ type: string; reason: string }>;
|
|
214
263
|
}
|
|
215
264
|
|