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.
Files changed (55) hide show
  1. package/boat/api-tester/src/ai/curler.ts +70 -66
  2. package/boat/api-tester/src/apibot.ts +1 -0
  3. package/boat/api-tester/src/cli.ts +2 -0
  4. package/boat/api-tester/src/config.ts +18 -1
  5. package/dist/boat/api-tester/src/ai/curler.js +55 -56
  6. package/dist/boat/api-tester/src/apibot.js +1 -0
  7. package/dist/boat/api-tester/src/cli.js +2 -0
  8. package/dist/boat/api-tester/src/config.js +3 -1
  9. package/dist/package.json +2 -2
  10. package/dist/rules/researcher/pagination.md +6 -0
  11. package/dist/src/action-result.d.ts +6 -0
  12. package/dist/src/action-result.js +12 -0
  13. package/dist/src/ai/planner.js +4 -0
  14. package/dist/src/ai/researcher/locators.js +1 -1
  15. package/dist/src/ai/researcher/pagination.d.ts +16 -0
  16. package/dist/src/ai/researcher/pagination.js +62 -0
  17. package/dist/src/ai/researcher/parser.d.ts +3 -0
  18. package/dist/src/ai/researcher/parser.js +22 -6
  19. package/dist/src/ai/researcher/sections.js +1 -1
  20. package/dist/src/ai/researcher.js +7 -2
  21. package/dist/src/ai/rules.js +16 -0
  22. package/dist/src/ai/scout.js +8 -2
  23. package/dist/src/ai/tools.js +10 -3
  24. package/dist/src/commands/options/ws-option.d.ts +7 -0
  25. package/dist/src/commands/options/ws-option.js +14 -0
  26. package/dist/src/config.d.ts +1 -0
  27. package/dist/src/config.js +14 -11
  28. package/dist/src/remote.d.ts +2 -0
  29. package/dist/src/remote.js +23 -16
  30. package/dist/src/utils/aria.d.ts +2 -0
  31. package/dist/src/utils/aria.js +6 -1
  32. package/dist/src/utils/markdown-query.d.ts +2 -0
  33. package/dist/src/utils/markdown-query.js +39 -0
  34. package/dist/src/utils/pagination.d.ts +16 -0
  35. package/dist/src/utils/pagination.js +20 -0
  36. package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
  37. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +125 -97
  38. package/package.json +2 -2
  39. package/rules/researcher/pagination.md +6 -0
  40. package/src/action-result.ts +16 -0
  41. package/src/ai/planner.ts +4 -0
  42. package/src/ai/researcher/locators.ts +1 -1
  43. package/src/ai/researcher/pagination.ts +68 -0
  44. package/src/ai/researcher/parser.ts +23 -5
  45. package/src/ai/researcher/sections.ts +1 -1
  46. package/src/ai/researcher.ts +9 -3
  47. package/src/ai/rules.ts +16 -0
  48. package/src/ai/scout.ts +9 -2
  49. package/src/ai/tools.ts +7 -3
  50. package/src/commands/options/ws-option.ts +14 -0
  51. package/src/config.ts +15 -11
  52. package/src/remote.ts +22 -15
  53. package/src/utils/aria.ts +8 -1
  54. package/src/utils/markdown-query.ts +39 -0
  55. 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', 'Form command executed, but no observable page or form-state change was captured.', {
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: 'Treat the field/form action as not completed. Re-locate the editable control, check whether another UI layer is active, then retry and verify the field value before submitting.',
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
- return pageDiff.urlChanged !== true && (pageDiff.ariaChangeCount ?? 0) >= LARGE_ARIA_CHANGE_THRESHOLD;
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
- if (config.ai) {
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
- async close(exitCode: number): Promise<void> {
77
- if (!this.url) return;
78
- this.send('result', { ok: exitCode === 0, exitCode });
79
- await this.flush();
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 { text: formatDiff(added, removed, toggled, typed), count: added.length + removed.length + toggled.length + typed.length };
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
+ }