explorbot 0.4.5 → 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 +19 -2
- package/boat/api-tester/src/cli.ts +87 -274
- package/boat/api-tester/src/commands/api-command.ts +10 -0
- package/boat/api-tester/src/commands/explore-command.ts +52 -0
- package/boat/api-tester/src/commands/init-command.ts +119 -0
- package/boat/api-tester/src/commands/know-command.ts +44 -0
- package/boat/api-tester/src/commands/plan-command.ts +42 -0
- package/boat/api-tester/src/commands/test-command.ts +54 -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 +15 -1
- package/dist/boat/api-tester/src/cli.js +89 -243
- package/dist/boat/api-tester/src/commands/api-command.js +7 -0
- package/dist/boat/api-tester/src/commands/explore-command.js +41 -0
- package/dist/boat/api-tester/src/commands/init-command.js +88 -0
- package/dist/boat/api-tester/src/commands/know-command.js +39 -0
- package/dist/boat/api-tester/src/commands/plan-command.js +37 -0
- package/dist/boat/api-tester/src/commands/test-command.js +45 -0
- package/dist/boat/api-tester/src/config.js +3 -1
- package/dist/package.json +4 -4
- 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/deep-analysis.d.ts +1 -1
- package/dist/src/ai/researcher/deep-analysis.js +14 -6
- 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.d.ts +1 -1
- package/dist/src/ai/tools.js +25 -13
- package/dist/src/api/spec-reader.d.ts +1 -0
- package/dist/src/api/spec-reader.js +93 -1
- package/dist/src/commands/base-command.d.ts +3 -3
- package/dist/src/commands/init-command.d.ts +3 -0
- package/dist/src/commands/init-command.js +6 -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/explorer.d.ts +1 -1
- package/dist/src/explorer.js +1 -1
- 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/html-diff.js +4 -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/api-testing/basics.md +26 -2
- package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
- package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +345 -0
- package/package.json +4 -4
- 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/deep-analysis.ts +13 -6
- 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 +22 -14
- package/src/api/spec-reader.ts +106 -1
- package/src/commands/base-command.ts +3 -3
- package/src/commands/init-command.ts +6 -3
- package/src/commands/options/ws-option.ts +14 -0
- package/src/config.ts +15 -11
- package/src/explorer.ts +1 -1
- package/src/remote.ts +22 -15
- package/src/utils/aria.ts +8 -1
- package/src/utils/html-diff.ts +3 -1
- package/src/utils/markdown-query.ts +39 -0
- package/src/utils/pagination.ts +36 -0
package/src/ai/researcher.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { type CoordinateMethods, WithCoordinates } from './researcher/coordinate
|
|
|
24
24
|
import { type DeepAnalysisMethods, WithDeepAnalysis } from './researcher/deep-analysis.ts';
|
|
25
25
|
import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
|
|
26
26
|
import { type LocatorMethods, WithLocators } from './researcher/locators.ts';
|
|
27
|
+
import { type PaginationMethods, WithPagination } from './researcher/pagination.ts';
|
|
27
28
|
import { extractValidContainers, formatResearchSummary, parseResearchSections } from './researcher/parser.ts';
|
|
28
29
|
import { ResearchResult } from './researcher/research-result.ts';
|
|
29
30
|
import { type SectionMethods, WithSections } from './researcher/sections.ts';
|
|
@@ -44,9 +45,9 @@ export const POSSIBLE_SECTIONS = {
|
|
|
44
45
|
navigation: 'main navigation (top bar, sidebar, breadcrumbs)',
|
|
45
46
|
};
|
|
46
47
|
|
|
47
|
-
const ResearcherBase = WithSections(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent))));
|
|
48
|
+
const ResearcherBase = WithSections(WithPagination(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent)))));
|
|
48
49
|
|
|
49
|
-
export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods, SectionMethods {}
|
|
50
|
+
export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods, SectionMethods, PaginationMethods {}
|
|
50
51
|
|
|
51
52
|
export class Researcher extends ResearcherBase implements Agent {
|
|
52
53
|
protected readonly ACTION_TOOLS = ['click'];
|
|
@@ -277,6 +278,10 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
277
278
|
await this.backfillBrokenLocators(result);
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
if (!interrupted()) {
|
|
282
|
+
await this.detectPagination(result);
|
|
283
|
+
}
|
|
284
|
+
|
|
280
285
|
// Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
|
|
281
286
|
if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
|
|
282
287
|
const sections = parseResearchSections(result.text);
|
|
@@ -426,7 +431,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
426
431
|
|
|
427
432
|
${generalLocatorRuleText}
|
|
428
433
|
|
|
429
|
-
${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl)}
|
|
434
|
+
${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl)}
|
|
430
435
|
|
|
431
436
|
<section_identification>
|
|
432
437
|
Identify page sections in this priority order:
|
|
@@ -502,6 +507,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
502
507
|
- When a section contains a list of similar data items (records, entities, rows — content that varies by data, not by app UI), output it as a Data section with NO table.
|
|
503
508
|
- Data section heading MUST be a level-2 heading (##) that starts exactly with "Data:" — for example: "## Data: Suites List". Do NOT use ### or add section numbers.
|
|
504
509
|
- Data sections must NOT include a UI map table. Only include the container and a brief summary line.
|
|
510
|
+
- When the data list has controls that move between pages of the collection, add "> Pagination: controls" under its container.
|
|
505
511
|
- Example data section:
|
|
506
512
|
|
|
507
513
|
## Data: Suites List
|
package/src/ai/rules.ts
CHANGED
|
@@ -349,6 +349,22 @@ export const actionRule = dedent`
|
|
|
349
349
|
For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
|
|
350
350
|
|
|
351
351
|
|
|
352
|
+
### I.scrollTo
|
|
353
|
+
|
|
354
|
+
scrolls until the element is in view
|
|
355
|
+
|
|
356
|
+
I.scrollTo(<locator>)
|
|
357
|
+
|
|
358
|
+
Scrolls every scrollable ancestor of the target, so it reaches an element inside a container
|
|
359
|
+
that has its own scrollbar. I.scrollPageToBottom() moves only the page itself.
|
|
360
|
+
|
|
361
|
+
<example>
|
|
362
|
+
I.scrollTo('.rows > *:last-child');
|
|
363
|
+
I.scrollTo({ role: 'listitem', text: 'Last entry' });
|
|
364
|
+
I.scrollPageToBottom();
|
|
365
|
+
</example>
|
|
366
|
+
|
|
367
|
+
|
|
352
368
|
### I.fillField
|
|
353
369
|
|
|
354
370
|
fills the field with the given value
|
package/src/ai/scout.ts
CHANGED
|
@@ -56,13 +56,20 @@ export class Scout implements Agent {
|
|
|
56
56
|
agentName: 'scout',
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
const responseText = invokeResult?.response?.text;
|
|
60
|
+
if (responseText?.trim()) {
|
|
61
|
+
finishFromText(responseText);
|
|
62
|
+
stop();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
if (!invokeResult?.toolExecutions?.length) {
|
|
60
|
-
finishFromText(invokeResult?.response?.text);
|
|
61
67
|
stop();
|
|
62
68
|
return;
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
if (iteration >= MAX_ITERATIONS) {
|
|
71
|
+
if (iteration >= MAX_ITERATIONS - 1) {
|
|
72
|
+
conversation.addUserText('Exploration time is over. Report your findings now as your final message.');
|
|
66
73
|
const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
|
|
67
74
|
finishFromText(final?.response?.text);
|
|
68
75
|
stop();
|
package/src/ai/tools.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { tool } from 'ai';
|
|
2
2
|
import dedent from 'dedent';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import type { ExecutedStep } from '../action.ts';
|
|
5
4
|
import { ActionResult, type PageDiff, type ToolResultMetadata } from '../action-result.ts';
|
|
5
|
+
import type { ExecutedStep } from '../action.ts';
|
|
6
6
|
import { type ExperienceTracker, renderExperienceRecipes } from '../experience-tracker.ts';
|
|
7
7
|
import { Stats } from '../stats.ts';
|
|
8
8
|
import { type Task, TestResult } from '../test-plan.js';
|
|
9
|
+
import { ariaRefSelector, describeRef, refIsGone } from '../utils/aria-ref.ts';
|
|
9
10
|
import { LARGE_ARIA_CHANGE_THRESHOLD } from '../utils/aria.ts';
|
|
10
11
|
import { isFatalBrowserError } from '../utils/browser-errors.ts';
|
|
11
12
|
import { cleanHtmlSnippet } from '../utils/html.ts';
|
|
12
13
|
import { createDebug, tag } from '../utils/logger.js';
|
|
13
|
-
import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/strings.ts';
|
|
14
14
|
import { pause } from '../utils/loop.js';
|
|
15
|
-
import {
|
|
15
|
+
import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/strings.ts';
|
|
16
16
|
import { WebElement } from '../utils/web-element.ts';
|
|
17
17
|
import type { ToolDeps } from './agent.ts';
|
|
18
18
|
import { Navigator } from './navigator.ts';
|
|
@@ -133,7 +133,13 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
136
|
-
|
|
136
|
+
const data: Record<string, any> = { ...toolResult, attempts, code: command };
|
|
137
|
+
const notExecuted = commands.slice(i + 1);
|
|
138
|
+
if (notExecuted.length) {
|
|
139
|
+
data.notExecuted = notExecuted;
|
|
140
|
+
data.suggestion = `SKIPPED: ${notExecuted.join('; ')}`;
|
|
141
|
+
}
|
|
142
|
+
return successToolResult('click', data, action);
|
|
137
143
|
}
|
|
138
144
|
}
|
|
139
145
|
|
|
@@ -368,6 +374,7 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
368
374
|
- Working with iframes (switch context with I.switchTo)
|
|
369
375
|
- Performing multiple form actions in a single batch
|
|
370
376
|
- Complex interactions requiring sequential commands
|
|
377
|
+
- Reaching items further down a list (I.scrollTo)
|
|
371
378
|
|
|
372
379
|
Example - filling a form with context (PREFERRED):
|
|
373
380
|
I.fillField('Username', 'John', '.login-form')
|
|
@@ -435,10 +442,10 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
435
442
|
|
|
436
443
|
if (!hasObservablePageChange(toolResult)) {
|
|
437
444
|
activeNote.commit(TestResult.FAILED);
|
|
438
|
-
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.', {
|
|
439
446
|
...toolResult,
|
|
440
447
|
code: codeBlock,
|
|
441
|
-
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.',
|
|
442
449
|
});
|
|
443
450
|
}
|
|
444
451
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
@@ -1232,7 +1239,10 @@ export function successToolResult(action: string, data?: Record<string, any>, so
|
|
|
1232
1239
|
}
|
|
1233
1240
|
|
|
1234
1241
|
export function isMajorPageChange(pageDiff: PageDiff): boolean {
|
|
1235
|
-
|
|
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;
|
|
1236
1246
|
}
|
|
1237
1247
|
|
|
1238
1248
|
export function hasFailedRequest(pageDiff: PageDiff): boolean {
|
|
@@ -1265,7 +1275,7 @@ export async function failedToolResult(action: string, message: string, data?: R
|
|
|
1265
1275
|
const errorTexts = [message, ...(data?.attempts?.map((a: any) => a.error || '') || [])];
|
|
1266
1276
|
if (errorTexts.some((t: string) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
|
|
1267
1277
|
const matched = await extractWebElements(error);
|
|
1268
|
-
result.suggestion = getMultipleElementsSuggestion(
|
|
1278
|
+
result.suggestion = getMultipleElementsSuggestion();
|
|
1269
1279
|
result.multipleElementsDetected = true;
|
|
1270
1280
|
result.elements = formatElementList(matched);
|
|
1271
1281
|
return result;
|
|
@@ -1280,16 +1290,12 @@ export async function failedToolResult(action: string, message: string, data?: R
|
|
|
1280
1290
|
return result;
|
|
1281
1291
|
}
|
|
1282
1292
|
|
|
1283
|
-
function getMultipleElementsSuggestion(
|
|
1284
|
-
const visible = (matched || []).filter((element) => element.visible !== false);
|
|
1285
|
-
let onlyVisible = '';
|
|
1286
|
-
if (matched && visible.length === 1) onlyVisible = `\nOnly element ${matched.indexOf(visible[0]) + 1} is on screen, so that is the one to act on.`;
|
|
1287
|
-
|
|
1293
|
+
function getMultipleElementsSuggestion(): string {
|
|
1288
1294
|
return dedent`
|
|
1289
1295
|
Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
|
|
1290
1296
|
Read the numbered elements list and act on the one you meant by its number:
|
|
1291
1297
|
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1292
|
-
A match reported as not visible can never be acted on — pick one that is
|
|
1298
|
+
A match reported as not visible can never be acted on — pick one that is.
|
|
1293
1299
|
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1294
1300
|
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1295
1301
|
`;
|
|
@@ -1365,6 +1371,8 @@ function formatElementList(matched: MatchedElement[] | null): string {
|
|
|
1365
1371
|
.map((el, i) => {
|
|
1366
1372
|
const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
|
|
1367
1373
|
if (el.visible !== undefined) lines.push(`Visible: ${el.visible}`);
|
|
1374
|
+
const wrapped = matched.map((_, j) => j).filter((j) => j !== i && matched[j].xpath.startsWith(`${el.xpath}/`));
|
|
1375
|
+
if (wrapped.length) lines.push(`Wraps: element ${wrapped.map((j) => j + 1).join(', ')}`);
|
|
1368
1376
|
lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
|
|
1369
1377
|
return lines.join('\n');
|
|
1370
1378
|
})
|
package/src/api/spec-reader.ts
CHANGED
|
@@ -51,7 +51,7 @@ export function extractEndpointDefinition(schema: any, endpoint: string, baseEnd
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const basePath = toBasePath(baseEndpoint);
|
|
54
|
-
const matched =
|
|
54
|
+
const matched = collectEndpointPaths(schema, basePath, endpoint);
|
|
55
55
|
|
|
56
56
|
if (!Object.keys(matched).length) {
|
|
57
57
|
const available = listNormalizedPaths(schema, basePath);
|
|
@@ -61,6 +61,35 @@ export function extractEndpointDefinition(schema: any, endpoint: string, baseEnd
|
|
|
61
61
|
return safeStringify(matched);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
export function resolveEndpoints(schema: any, pattern: string, baseEndpoint?: string): string[] {
|
|
65
|
+
if (!schema?.paths) {
|
|
66
|
+
throw new Error('OpenAPI spec has no paths defined');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const basePath = toBasePath(baseEndpoint);
|
|
70
|
+
const normalized = Object.keys(schema.paths).map((specPath) => stripBasePath(specPath, basePath));
|
|
71
|
+
const matched = normalized.filter((specPath) => matchesPattern(specPath, pattern));
|
|
72
|
+
|
|
73
|
+
if (!matched.length) {
|
|
74
|
+
throw new Error(`Endpoint "${pattern}" not found in spec. Available: ${listNormalizedPaths(schema, basePath)}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const roots = matched.map((specPath) => toCollection(specPath, normalized, pattern));
|
|
78
|
+
const resolved = [...new Set(roots.map((root) => fillParameters(root, pattern)))];
|
|
79
|
+
const endpoints = resolved.filter((specPath) => !specPath.includes('{'));
|
|
80
|
+
|
|
81
|
+
if (!endpoints.length) {
|
|
82
|
+
throw new Error(`Endpoint "${pattern}" leaves ${listParameters(resolved)} unresolved. Give the value in the endpoint or in the base endpoint.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const skipped = resolved.filter((specPath) => specPath.includes('{'));
|
|
86
|
+
if (skipped.length) {
|
|
87
|
+
tag('warning').log(`Skipped, no value for their parameters: ${skipped.join(', ')}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return endpoints;
|
|
91
|
+
}
|
|
92
|
+
|
|
64
93
|
export function searchEndpoints(schema: any, query: string, baseEndpoint?: string): string {
|
|
65
94
|
if (!schema?.paths) return 'No endpoints available';
|
|
66
95
|
|
|
@@ -166,6 +195,82 @@ function stripBasePath(specPath: string, basePath: string): string {
|
|
|
166
195
|
return `/${specSegments.slice(i).join('/')}`;
|
|
167
196
|
}
|
|
168
197
|
|
|
198
|
+
function collectEndpointPaths(schema: any, basePath: string, endpoint: string): Record<string, any> {
|
|
199
|
+
const normalized = Object.keys(schema.paths).map((specPath) => stripBasePath(specPath, basePath));
|
|
200
|
+
const roots = resolveEndpoint(normalized, endpoint);
|
|
201
|
+
|
|
202
|
+
if (!roots.length) return collectMatchingPaths(schema, basePath, (path) => matchesEndpoint(path, endpoint));
|
|
203
|
+
|
|
204
|
+
return collectMatchingPaths(schema, basePath, (path) => roots.some((root) => path === root || path.startsWith(`${root}/`)));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function resolveEndpoint(specPaths: string[], endpoint: string): string[] {
|
|
208
|
+
const wanted = toSegments(endpoint);
|
|
209
|
+
if (!wanted.length) return [];
|
|
210
|
+
|
|
211
|
+
const matched = specPaths.filter((specPath) => {
|
|
212
|
+
const segments = toSegments(specPath);
|
|
213
|
+
if (segments.length !== wanted.length) return false;
|
|
214
|
+
return segmentsMatch(segments, wanted);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const literals = matched.map((specPath) => toSegments(specPath).filter((segment, i) => segment === wanted[i]).length);
|
|
218
|
+
const best = Math.max(0, ...literals);
|
|
219
|
+
return matched.filter((_, i) => literals[i] === best);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function matchesPattern(specPath: string, pattern: string): boolean {
|
|
223
|
+
const wanted = toSegments(pattern);
|
|
224
|
+
const segments = toSegments(specPath);
|
|
225
|
+
if (segments.length < wanted.length) return false;
|
|
226
|
+
return segmentsMatch(segments, wanted);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function segmentsMatch(segments: string[], wanted: string[]): boolean {
|
|
230
|
+
return wanted.every((want, i) => want === '*' || segments[i] === want || segments[i].startsWith('{'));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function toCollection(specPath: string, specPaths: string[], pattern: string): string {
|
|
234
|
+
const segments = toSegments(specPath);
|
|
235
|
+
const filled = toSegments(fillParameters(specPath, pattern));
|
|
236
|
+
|
|
237
|
+
let deepest = segments.length;
|
|
238
|
+
const unfilled = filled.findIndex((segment) => segment.startsWith('{'));
|
|
239
|
+
if (unfilled >= 0) deepest = unfilled;
|
|
240
|
+
|
|
241
|
+
for (let i = Math.max(1, Math.min(toSegments(pattern).length, deepest)); i <= deepest; i++) {
|
|
242
|
+
const prefix = `/${segments.slice(0, i).join('/')}`;
|
|
243
|
+
if (specPaths.includes(prefix)) return prefix;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return specPath;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function fillParameters(specPath: string, pattern: string): string {
|
|
250
|
+
const wanted = toSegments(pattern);
|
|
251
|
+
const segments = toSegments(specPath);
|
|
252
|
+
for (let i = 0; i < wanted.length && i < segments.length; i++) {
|
|
253
|
+
if (wanted[i] === '*') continue;
|
|
254
|
+
if (!segments[i].startsWith('{')) continue;
|
|
255
|
+
segments[i] = wanted[i];
|
|
256
|
+
}
|
|
257
|
+
return `/${segments.join('/')}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function listParameters(specPaths: string[]): string {
|
|
261
|
+
const found = new Set<string>();
|
|
262
|
+
for (const specPath of specPaths) {
|
|
263
|
+
for (const segment of toSegments(specPath)) {
|
|
264
|
+
if (segment.startsWith('{')) found.add(segment);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return [...found].join(', ');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function toSegments(path: string): string[] {
|
|
271
|
+
return path.split('/').filter(Boolean);
|
|
272
|
+
}
|
|
273
|
+
|
|
169
274
|
function matchesEndpoint(specPath: string, endpoint: string): boolean {
|
|
170
275
|
if (specPath === endpoint) return true;
|
|
171
276
|
if (specPath.startsWith(`${endpoint}/`)) return true;
|
|
@@ -15,7 +15,7 @@ export interface Suggestion {
|
|
|
15
15
|
hint: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export abstract class BaseCommand {
|
|
18
|
+
export abstract class BaseCommand<T = ExplorBot> {
|
|
19
19
|
abstract name: string;
|
|
20
20
|
abstract description: string;
|
|
21
21
|
aliases: string[] = [];
|
|
@@ -23,9 +23,9 @@ export abstract class BaseCommand {
|
|
|
23
23
|
tuiEnabled = true;
|
|
24
24
|
suggestions: Suggestion[] = [];
|
|
25
25
|
|
|
26
|
-
protected explorBot:
|
|
26
|
+
protected explorBot: T;
|
|
27
27
|
|
|
28
|
-
constructor(explorBot:
|
|
28
|
+
constructor(explorBot: T) {
|
|
29
29
|
this.explorBot = explorBot;
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -39,7 +39,7 @@ ${moduleExport}
|
|
|
39
39
|
`;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function envTemplate(provider: string): string {
|
|
42
|
+
export function envTemplate(provider: string): string {
|
|
43
43
|
const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
|
|
44
44
|
if (name === provider) return `${envKey}=`;
|
|
45
45
|
return `# ${envKey}=`;
|
|
@@ -253,7 +253,7 @@ async function renderLocalProviderWizard(): Promise<string | null> {
|
|
|
253
253
|
});
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
function modelLines(provider: string): string {
|
|
256
|
+
export function modelLines(provider: string, only?: ModelRole[]): string {
|
|
257
257
|
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
258
258
|
const roles: Array<[ModelRole, string]> = [
|
|
259
259
|
['model', 'fast model with tool calling capabilities'],
|
|
@@ -261,7 +261,10 @@ function modelLines(provider: string): string {
|
|
|
261
261
|
['agenticModel', 'agentic model for decision making'],
|
|
262
262
|
];
|
|
263
263
|
|
|
264
|
-
|
|
264
|
+
let selected = roles;
|
|
265
|
+
if (only) selected = roles.filter(([role]) => only.includes(role));
|
|
266
|
+
|
|
267
|
+
return selected.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
|
|
265
268
|
}
|
|
266
269
|
|
|
267
270
|
function globalConfigTemplate(provider: string): string {
|
|
@@ -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/explorer.ts
CHANGED
|
@@ -263,7 +263,7 @@ class Explorer {
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
private convertToCodeceptConfig(config: ExplorbotConfig): any {
|
|
266
|
-
const playwrightConfig = { ...config.playwright };
|
|
266
|
+
const playwrightConfig = { visibleLocator: true, ...config.playwright };
|
|
267
267
|
|
|
268
268
|
if (this.options?.show !== undefined) {
|
|
269
269
|
playwrightConfig.show = this.options.show;
|
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 = {
|
package/src/utils/html-diff.ts
CHANGED
|
@@ -37,7 +37,7 @@ const IGNORED_PATHS = new Set(['html[1]', 'html[1]/head[1]', 'html[1]/body[1]'])
|
|
|
37
37
|
const SHELL_RATIO = 0.8;
|
|
38
38
|
const ROOT_CONTENT_RATIO = 0.8;
|
|
39
39
|
|
|
40
|
-
const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
|
|
40
|
+
const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log', 'tooltip']);
|
|
41
41
|
const TEXT_LINE_PREFIX = 'TEXT:';
|
|
42
42
|
const MESSAGE_MAX_LENGTH = 200;
|
|
43
43
|
const MESSAGE_LIMIT = 8;
|
|
@@ -549,6 +549,8 @@ function semanticSelectorFor(element: ElementNode, allElements: NodeMap): string
|
|
|
549
549
|
while (current) {
|
|
550
550
|
const selector = buildContainerSelector(current, allElements);
|
|
551
551
|
if (selector) return selector;
|
|
552
|
+
const classAttr = (current.attrs ?? []).find((a) => a.name === 'class')?.value;
|
|
553
|
+
if (classAttr && filterContainerClasses(classAttr.split(/\s+/).filter(Boolean)).length > 0) return undefined;
|
|
552
554
|
current = dominantChild(current);
|
|
553
555
|
}
|
|
554
556
|
return undefined;
|
|
@@ -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
|
+
}
|