explorbot 0.4.6 → 0.4.7
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/boat/api-tester/src/ai/curler.ts +70 -66
- package/boat/api-tester/src/apibot.ts +1 -0
- package/boat/api-tester/src/cli.ts +2 -0
- package/boat/api-tester/src/config.ts +18 -1
- package/dist/boat/api-tester/src/ai/curler.js +55 -56
- package/dist/boat/api-tester/src/apibot.js +1 -0
- package/dist/boat/api-tester/src/cli.js +2 -0
- package/dist/boat/api-tester/src/config.js +3 -1
- package/dist/package.json +2 -2
- package/dist/rules/researcher/pagination.md +6 -0
- package/dist/src/action-result.d.ts +6 -0
- package/dist/src/action-result.js +12 -0
- package/dist/src/ai/planner.js +4 -0
- package/dist/src/ai/researcher/locators.js +1 -1
- package/dist/src/ai/researcher/pagination.d.ts +16 -0
- package/dist/src/ai/researcher/pagination.js +62 -0
- package/dist/src/ai/researcher/parser.d.ts +3 -0
- package/dist/src/ai/researcher/parser.js +22 -6
- package/dist/src/ai/researcher/sections.js +1 -1
- package/dist/src/ai/researcher.js +7 -2
- package/dist/src/ai/rules.js +16 -0
- package/dist/src/ai/scout.js +8 -2
- package/dist/src/ai/tools.js +10 -3
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +14 -0
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +14 -11
- package/dist/src/remote.d.ts +2 -0
- package/dist/src/remote.js +23 -16
- package/dist/src/utils/aria.d.ts +2 -0
- package/dist/src/utils/aria.js +6 -1
- package/dist/src/utils/markdown-query.d.ts +2 -0
- package/dist/src/utils/markdown-query.js +39 -0
- package/dist/src/utils/pagination.d.ts +16 -0
- package/dist/src/utils/pagination.js +20 -0
- package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
- package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +125 -97
- package/package.json +2 -2
- package/rules/researcher/pagination.md +6 -0
- package/src/action-result.ts +16 -0
- package/src/ai/planner.ts +4 -0
- package/src/ai/researcher/locators.ts +1 -1
- package/src/ai/researcher/pagination.ts +68 -0
- package/src/ai/researcher/parser.ts +23 -5
- package/src/ai/researcher/sections.ts +1 -1
- package/src/ai/researcher.ts +9 -3
- package/src/ai/rules.ts +16 -0
- package/src/ai/scout.ts +9 -2
- package/src/ai/tools.ts +7 -3
- package/src/commands/options/ws-option.ts +14 -0
- package/src/config.ts +15 -11
- package/src/remote.ts +22 -15
- package/src/utils/aria.ts +8 -1
- package/src/utils/markdown-query.ts +39 -0
- package/src/utils/pagination.ts +36 -0
package/src/ai/tools.ts
CHANGED
|
@@ -374,6 +374,7 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
374
374
|
- Working with iframes (switch context with I.switchTo)
|
|
375
375
|
- Performing multiple form actions in a single batch
|
|
376
376
|
- Complex interactions requiring sequential commands
|
|
377
|
+
- Reaching items further down a list (I.scrollTo)
|
|
377
378
|
|
|
378
379
|
Example - filling a form with context (PREFERRED):
|
|
379
380
|
I.fillField('Username', 'John', '.login-form')
|
|
@@ -441,10 +442,10 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
441
442
|
|
|
442
443
|
if (!hasObservablePageChange(toolResult)) {
|
|
443
444
|
activeNote.commit(TestResult.FAILED);
|
|
444
|
-
return failedToolResult('form', '
|
|
445
|
+
return failedToolResult('form', 'Command executed, but nothing on the page changed: no navigation, no ARIA change, no HTML change and no request.', {
|
|
445
446
|
...toolResult,
|
|
446
447
|
code: codeBlock,
|
|
447
|
-
suggestion: '
|
|
448
|
+
suggestion: 'The command ran without reaching anything. Re-locate the target, check whether another UI layer is active, then retry. If the goal was to load more of a list, no change means the collection has ended.',
|
|
448
449
|
});
|
|
449
450
|
}
|
|
450
451
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
@@ -1238,7 +1239,10 @@ export function successToolResult(action: string, data?: Record<string, any>, so
|
|
|
1238
1239
|
}
|
|
1239
1240
|
|
|
1240
1241
|
export function isMajorPageChange(pageDiff: PageDiff): boolean {
|
|
1241
|
-
|
|
1242
|
+
if (pageDiff.urlChanged === true) return false;
|
|
1243
|
+
if ((pageDiff.ariaChangeCount ?? 0) < LARGE_ARIA_CHANGE_THRESHOLD) return false;
|
|
1244
|
+
if (pageDiff.ariaRemoved === 0 && (pageDiff.ariaAdded ?? 0) > 0) return false;
|
|
1245
|
+
return true;
|
|
1242
1246
|
}
|
|
1243
1247
|
|
|
1244
1248
|
export function hasFailedRequest(pageDiff: PageDiff): boolean {
|
|
@@ -6,6 +6,20 @@ export class WsOption extends BaseOption {
|
|
|
6
6
|
flags = '--ws <url>';
|
|
7
7
|
description = 'Stream this run to a remote UI over WebSocket';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* An open socket holds the event loop, and `WebSocket` has no `unref` on
|
|
11
|
+
* either runtime — so a command that ends by returning rather than through
|
|
12
|
+
* `showStatsAndExit` would never exit once it is attached. The option that
|
|
13
|
+
* opened the connection is what closes it.
|
|
14
|
+
*/
|
|
15
|
+
override register(command: Command): void {
|
|
16
|
+
super.register(command);
|
|
17
|
+
command.hook('postAction', async () => {
|
|
18
|
+
if (!remote.isAttached()) return;
|
|
19
|
+
await remote.close(0);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
protected apply(options: Record<string, any>, command: Command): void {
|
|
10
24
|
const url = options.ws || process.env.EXPLORBOT_WS_URL;
|
|
11
25
|
if (!url) return;
|
package/src/config.ts
CHANGED
|
@@ -677,17 +677,7 @@ export class ConfigParser {
|
|
|
677
677
|
config.playwright.url = options.baseUrl;
|
|
678
678
|
}
|
|
679
679
|
|
|
680
|
-
|
|
681
|
-
const langfuse = config.ai.langfuse;
|
|
682
|
-
const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
683
|
-
const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
684
|
-
config.ai.langfuse = {
|
|
685
|
-
enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
|
|
686
|
-
publicKey,
|
|
687
|
-
secretKey,
|
|
688
|
-
baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
|
|
689
|
-
};
|
|
690
|
-
}
|
|
680
|
+
resolveLangfuse(config.ai);
|
|
691
681
|
|
|
692
682
|
return config;
|
|
693
683
|
}
|
|
@@ -857,6 +847,20 @@ export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
|
|
|
857
847
|
}
|
|
858
848
|
}
|
|
859
849
|
|
|
850
|
+
export function resolveLangfuse(ai?: AIConfig): void {
|
|
851
|
+
if (!ai) return;
|
|
852
|
+
|
|
853
|
+
const langfuse = ai.langfuse;
|
|
854
|
+
const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
855
|
+
const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
856
|
+
ai.langfuse = {
|
|
857
|
+
enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
|
|
858
|
+
publicKey,
|
|
859
|
+
secretKey,
|
|
860
|
+
baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
860
864
|
export function resolveOutputRoot(baseUrl?: string): string {
|
|
861
865
|
if (cachedOutputRoot) return cachedOutputRoot;
|
|
862
866
|
|
package/src/remote.ts
CHANGED
|
@@ -28,6 +28,7 @@ export class Remote implements LogDestination {
|
|
|
28
28
|
private asks = new Map<string, (value: string | null) => void>();
|
|
29
29
|
private askCounter = 0;
|
|
30
30
|
private lastActivity: string | null = null;
|
|
31
|
+
private closing: Promise<void> | null = null;
|
|
31
32
|
|
|
32
33
|
attach(url: string, command: string): void {
|
|
33
34
|
if (this.url) return;
|
|
@@ -73,21 +74,10 @@ export class Remote implements LogDestination {
|
|
|
73
74
|
});
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
if (!this.url) return;
|
|
78
|
-
this.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.url = null;
|
|
82
|
-
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
83
|
-
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
84
|
-
// route them into a closed socket and park them until the ask times out.
|
|
85
|
-
executionController.clearInputCallback();
|
|
86
|
-
for (const resolve of this.asks.values()) resolve(null);
|
|
87
|
-
this.asks.clear();
|
|
88
|
-
this.queue = [];
|
|
89
|
-
this.socket?.close();
|
|
90
|
-
this.socket = null;
|
|
77
|
+
close(exitCode: number): Promise<void> {
|
|
78
|
+
if (!this.url) return Promise.resolve();
|
|
79
|
+
if (!this.closing) this.closing = this.shutdown(exitCode);
|
|
80
|
+
return this.closing;
|
|
91
81
|
}
|
|
92
82
|
|
|
93
83
|
isEnabled(): boolean {
|
|
@@ -116,6 +106,23 @@ export class Remote implements LogDestination {
|
|
|
116
106
|
});
|
|
117
107
|
}
|
|
118
108
|
|
|
109
|
+
private async shutdown(exitCode: number): Promise<void> {
|
|
110
|
+
this.send('result', { ok: exitCode === 0, exitCode });
|
|
111
|
+
await this.flush();
|
|
112
|
+
|
|
113
|
+
this.url = null;
|
|
114
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
115
|
+
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
116
|
+
// route them into a closed socket and park them until the ask times out.
|
|
117
|
+
executionController.clearInputCallback();
|
|
118
|
+
for (const resolve of this.asks.values()) resolve(null);
|
|
119
|
+
this.asks.clear();
|
|
120
|
+
this.queue = [];
|
|
121
|
+
this.socket?.close();
|
|
122
|
+
this.socket = null;
|
|
123
|
+
this.closing = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
119
126
|
private connect(): void {
|
|
120
127
|
if (!this.url) return;
|
|
121
128
|
|
package/src/utils/aria.ts
CHANGED
|
@@ -521,7 +521,12 @@ export const diffAriaSnapshots = (previous: string | null, current: string | nul
|
|
|
521
521
|
const renames = detectRenames(prev, curr, prevTotals, currTotals);
|
|
522
522
|
const added = [...byCount.added, ...renames.added];
|
|
523
523
|
const removed = [...byCount.removed, ...renames.removed];
|
|
524
|
-
return {
|
|
524
|
+
return {
|
|
525
|
+
text: formatDiff(added, removed, toggled, typed),
|
|
526
|
+
count: added.length + removed.length + toggled.length + typed.length,
|
|
527
|
+
added: added.length,
|
|
528
|
+
removed: removed.length,
|
|
529
|
+
};
|
|
525
530
|
};
|
|
526
531
|
|
|
527
532
|
export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
|
|
@@ -587,6 +592,8 @@ export const LARGE_ARIA_CHANGE_THRESHOLD = 50;
|
|
|
587
592
|
export interface AriaDiff {
|
|
588
593
|
text: string | null;
|
|
589
594
|
count: number;
|
|
595
|
+
added: number;
|
|
596
|
+
removed: number;
|
|
590
597
|
}
|
|
591
598
|
|
|
592
599
|
type AriaNode = {
|
|
@@ -162,6 +162,12 @@ function matchText(text: string, matcher: TextMatcher): boolean {
|
|
|
162
162
|
return matcher.negated ? !result : result;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
function entryKey(line: string): string | null {
|
|
166
|
+
const separator = line.indexOf(':');
|
|
167
|
+
if (separator < 1) return null;
|
|
168
|
+
return line.slice(0, separator).trim().toLowerCase();
|
|
169
|
+
}
|
|
170
|
+
|
|
165
171
|
function getTokenText(token: Token): string {
|
|
166
172
|
const t = token as any;
|
|
167
173
|
switch (token.type) {
|
|
@@ -407,6 +413,39 @@ export class MarkdownQuery {
|
|
|
407
413
|
return results;
|
|
408
414
|
}
|
|
409
415
|
|
|
416
|
+
keyValue(): Record<string, string> {
|
|
417
|
+
const entries: Record<string, string> = {};
|
|
418
|
+
|
|
419
|
+
for (const range of this.matches) {
|
|
420
|
+
for (const line of getTokenText(range.token).split('\n')) {
|
|
421
|
+
const key = entryKey(line);
|
|
422
|
+
if (!key) continue;
|
|
423
|
+
const value = line.slice(line.indexOf(':') + 1).trim();
|
|
424
|
+
if (value) entries[key] = value;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return entries;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
setKeyValue(key: string, value: string | null): string {
|
|
432
|
+
return this.replaceEach((match) => {
|
|
433
|
+
const token = match.matches[0].token;
|
|
434
|
+
const lines = getTokenText(token)
|
|
435
|
+
.split('\n')
|
|
436
|
+
.map((line) => line.trim())
|
|
437
|
+
.filter(Boolean);
|
|
438
|
+
|
|
439
|
+
const index = lines.findIndex((line) => entryKey(line) === key.toLowerCase());
|
|
440
|
+
if (index < 0 && value) lines.push(`${key}: ${value}`);
|
|
441
|
+
if (index >= 0 && value) lines[index] = `${key}: ${value}`;
|
|
442
|
+
if (index >= 0 && !value) lines.splice(index, 1);
|
|
443
|
+
|
|
444
|
+
if (token.type !== 'blockquote') return lines.join('\n');
|
|
445
|
+
return lines.map((line) => `> ${line}`).join('\n');
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
410
449
|
replace(content: string): string {
|
|
411
450
|
return this.replaceEach(() => content);
|
|
412
451
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function inspectList(css: string): ListMeasure | null {
|
|
2
|
+
const element = document.querySelector(css);
|
|
3
|
+
if (!element) return null;
|
|
4
|
+
const rect = element.getBoundingClientRect();
|
|
5
|
+
return {
|
|
6
|
+
hasPagingControls: !!element.querySelector('a[rel="next"], a[rel="prev"]'),
|
|
7
|
+
isFeed: element.matches('[role="feed"]') || !!element.querySelector('[role="feed"]'),
|
|
8
|
+
scrolls: element.scrollHeight > element.clientHeight + 1 || rect.bottom > window.innerHeight,
|
|
9
|
+
items: element.querySelectorAll(':scope > *').length,
|
|
10
|
+
scrollTop: element.scrollTop,
|
|
11
|
+
pageScrollY: window.scrollY,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function restoreScroll({ css, scrollTop, pageScrollY }: ScrollPosition): void {
|
|
16
|
+
const element = document.querySelector(css);
|
|
17
|
+
if (element) element.scrollTop = scrollTop;
|
|
18
|
+
window.scrollTo(0, pageScrollY);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PaginationStrategy = 'controls' | 'infinite';
|
|
22
|
+
|
|
23
|
+
export interface ListMeasure {
|
|
24
|
+
hasPagingControls: boolean;
|
|
25
|
+
isFeed: boolean;
|
|
26
|
+
scrolls: boolean;
|
|
27
|
+
items: number;
|
|
28
|
+
scrollTop: number;
|
|
29
|
+
pageScrollY: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ScrollPosition {
|
|
33
|
+
css: string;
|
|
34
|
+
scrollTop: number;
|
|
35
|
+
pageScrollY: number;
|
|
36
|
+
}
|