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
package/dist/src/ai/tester.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export declare class Tester extends TaskAgent implements Agent {
|
|
|
32
32
|
seenUiMapUrls: Set<string>;
|
|
33
33
|
lastAnalyzedStateHash: string | null;
|
|
34
34
|
stalledIterations: number;
|
|
35
|
+
previousRegionPresent: boolean | null;
|
|
36
|
+
regionTransitioned: boolean;
|
|
35
37
|
readonly MAX_STALLED_ITERATIONS = 3;
|
|
36
38
|
skipResearch: (err: Error) => string;
|
|
37
39
|
constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any);
|
|
@@ -96,5 +98,6 @@ interface TestSessionHandlers {
|
|
|
96
98
|
}
|
|
97
99
|
export interface TestOptions {
|
|
98
100
|
startOnCurrentPage?: boolean;
|
|
101
|
+
deadline?: number;
|
|
99
102
|
}
|
|
100
103
|
export {};
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -50,6 +50,8 @@ export class Tester extends TaskAgent {
|
|
|
50
50
|
seenUiMapUrls = new Set();
|
|
51
51
|
lastAnalyzedStateHash = null;
|
|
52
52
|
stalledIterations = 0;
|
|
53
|
+
previousRegionPresent = null;
|
|
54
|
+
regionTransitioned = false;
|
|
53
55
|
MAX_STALLED_ITERATIONS = 3;
|
|
54
56
|
skipResearch = (err) => {
|
|
55
57
|
if (err.name === 'AbortError')
|
|
@@ -95,6 +97,8 @@ export class Tester extends TaskAgent {
|
|
|
95
97
|
this.seenUiMapUrls.clear();
|
|
96
98
|
this.lastAnalyzedStateHash = null;
|
|
97
99
|
this.stalledIterations = 0;
|
|
100
|
+
this.previousRegionPresent = null;
|
|
101
|
+
this.regionTransitioned = false;
|
|
98
102
|
this.stateManager.clearHistory();
|
|
99
103
|
this.resetFailureCount();
|
|
100
104
|
this.pilot?.reset();
|
|
@@ -214,10 +218,17 @@ export class Tester extends TaskAgent {
|
|
|
214
218
|
const codeceptjsTools = createCodeceptJSTools(this.toolDeps, task);
|
|
215
219
|
let assertionPerformed = false;
|
|
216
220
|
let extensions = 0;
|
|
221
|
+
let deadlineReached = false;
|
|
217
222
|
let shouldContinue = true;
|
|
218
223
|
while (shouldContinue) {
|
|
219
224
|
shouldContinue = false;
|
|
220
225
|
await loop(async ({ stop, pause, iteration, userInput }) => {
|
|
226
|
+
if (opts.deadline != null && Date.now() >= opts.deadline) {
|
|
227
|
+
deadlineReached = true;
|
|
228
|
+
task.addNote('Time budget reached. Stopped');
|
|
229
|
+
stop();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
221
232
|
debugLog('iteration', iteration);
|
|
222
233
|
if (!(await this.explorer.recover()).ok) {
|
|
223
234
|
task.addNote('Browser page is unavailable');
|
|
@@ -369,6 +380,8 @@ export class Tester extends TaskAgent {
|
|
|
369
380
|
});
|
|
370
381
|
if (task.hasFinished)
|
|
371
382
|
break;
|
|
383
|
+
if (deadlineReached)
|
|
384
|
+
break;
|
|
372
385
|
if (!(await this.explorer.recover()).ok)
|
|
373
386
|
break;
|
|
374
387
|
const finalState = this.getCurrentState();
|
|
@@ -403,6 +416,10 @@ export class Tester extends TaskAgent {
|
|
|
403
416
|
};
|
|
404
417
|
}
|
|
405
418
|
shouldAnalyzeProgress(iteration, currentState) {
|
|
419
|
+
if (this.regionTransitioned) {
|
|
420
|
+
this.regionTransitioned = false;
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
406
423
|
if (this.consecutiveFailures >= 3)
|
|
407
424
|
return true;
|
|
408
425
|
if (this.consecutiveEmptyResults >= 2)
|
|
@@ -468,6 +485,11 @@ export class Tester extends TaskAgent {
|
|
|
468
485
|
const currentUrl = currentState.url;
|
|
469
486
|
const currentStateHash = currentState.hash;
|
|
470
487
|
const isNewUrl = this.previousUrl !== currentUrl;
|
|
488
|
+
const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
|
|
489
|
+
if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.present) {
|
|
490
|
+
this.regionTransitioned = true;
|
|
491
|
+
}
|
|
492
|
+
this.previousRegionPresent = currentState.overlay.present;
|
|
471
493
|
this.previousUrl = currentUrl;
|
|
472
494
|
this.previousStateHash = currentStateHash;
|
|
473
495
|
let context = '';
|
|
@@ -491,13 +513,28 @@ export class Tester extends TaskAgent {
|
|
|
491
513
|
}
|
|
492
514
|
if (focusArea.detected) {
|
|
493
515
|
const areaName = focusArea.name ? ` "${focusArea.name}"` : '';
|
|
516
|
+
let rootHint = '';
|
|
517
|
+
if (focusArea.root)
|
|
518
|
+
rootHint = `\nIts content lives inside \`${focusArea.root}\` — scope locators to it.`;
|
|
494
519
|
context += dedent `
|
|
495
520
|
<focus_scope>
|
|
496
|
-
A ${focusArea.type}${areaName} is currently open above the page
|
|
521
|
+
A ${focusArea.type}${areaName} is currently open above the page.${rootHint}
|
|
497
522
|
Scope all interactions to elements inside this ${focusArea.type}.
|
|
498
523
|
Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it — prefer the locator inside the ${focusArea.type}.
|
|
499
524
|
Use <page_aria> to confirm the element you target is actually inside the ${focusArea.type}.
|
|
500
525
|
</focus_scope>
|
|
526
|
+
`;
|
|
527
|
+
}
|
|
528
|
+
if (!focusArea.detected && focusArea.present && isNewState) {
|
|
529
|
+
let rootHint = '';
|
|
530
|
+
if (focusArea.root)
|
|
531
|
+
rootHint = `\nIt lives inside \`${focusArea.root}\`.`;
|
|
532
|
+
context += dedent `
|
|
533
|
+
<area_of_interest>
|
|
534
|
+
A large new area "${focusArea.name || 'unnamed area'}" appeared on this page without navigation.${rootHint}
|
|
535
|
+
The scenario most likely continues inside this area — prefer its elements for your next actions.
|
|
536
|
+
The rest of the page (navigation, menus, filters) is still interactive and remains available.
|
|
537
|
+
</area_of_interest>
|
|
501
538
|
`;
|
|
502
539
|
}
|
|
503
540
|
if (currentState.isInsideIframe) {
|
|
@@ -520,7 +557,7 @@ export class Tester extends TaskAgent {
|
|
|
520
557
|
if (!alreadySeenUiMap) {
|
|
521
558
|
research = await this.researcher.research(currentState).catch(this.skipResearch);
|
|
522
559
|
}
|
|
523
|
-
this.pageStateHash =
|
|
560
|
+
this.pageStateHash = currentState.baseHash;
|
|
524
561
|
this.pageActionResult = currentState;
|
|
525
562
|
let uiMapSection = '';
|
|
526
563
|
if (research) {
|
|
@@ -557,7 +594,7 @@ export class Tester extends TaskAgent {
|
|
|
557
594
|
`;
|
|
558
595
|
return context;
|
|
559
596
|
}
|
|
560
|
-
if (focusArea.
|
|
597
|
+
if (focusArea.present && focusArea.name && this.pageStateHash && this.pageActionResult) {
|
|
561
598
|
const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
|
|
562
599
|
if (overlaySection) {
|
|
563
600
|
context += dedent `
|
package/dist/src/ai/tools.d.ts
CHANGED
|
@@ -69,4 +69,5 @@ export declare function withdrawVisionTools(tools: Record<string, any>): void;
|
|
|
69
69
|
export declare function clickFailureSuggestion(attempts: Array<{
|
|
70
70
|
error?: string;
|
|
71
71
|
}>): string;
|
|
72
|
+
export declare function formatMatchedElements(error: Error | null | undefined): Promise<string | null>;
|
|
72
73
|
export {};
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -7,8 +7,9 @@ import { Stats } from "../stats.js";
|
|
|
7
7
|
import { TestResult } from '../test-plan.js';
|
|
8
8
|
import { LARGE_ARIA_CHANGE_THRESHOLD } from "../utils/aria.js";
|
|
9
9
|
import { isFatalBrowserError } from "../utils/browser-errors.js";
|
|
10
|
+
import { cleanHtmlSnippet } from "../utils/html.js";
|
|
10
11
|
import { createDebug, tag } from '../utils/logger.js';
|
|
11
|
-
import { compactErrorMessage } from "../utils/strings.js";
|
|
12
|
+
import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
|
|
12
13
|
import { pause } from '../utils/loop.js';
|
|
13
14
|
import { WebElement } from "../utils/web-element.js";
|
|
14
15
|
import { sectionContextRule } from "./rules.js";
|
|
@@ -1161,6 +1162,8 @@ export function clickFailureSuggestion(attempts) {
|
|
|
1161
1162
|
return "Try xpathCheck() to find the element's actual position, see() for visual analysis, or visualClick() to click by visual appearance.";
|
|
1162
1163
|
}
|
|
1163
1164
|
const MAX_DISAMBIGUATE_ELEMENTS = 10;
|
|
1165
|
+
const MAX_DISAMBIGUATE_TEXT = 80;
|
|
1166
|
+
const MAX_DISAMBIGUATE_HTML = 300;
|
|
1164
1167
|
const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
|
|
1165
1168
|
async function extractWebElements(error) {
|
|
1166
1169
|
if (!error || error.name !== 'MultipleElementsFound')
|
|
@@ -1172,8 +1175,9 @@ async function extractWebElements(error) {
|
|
|
1172
1175
|
for (let i = 0; i < Math.min(elements.length, MAX_DISAMBIGUATE_ELEMENTS); i++) {
|
|
1173
1176
|
try {
|
|
1174
1177
|
const xpath = await elements[i].toAbsoluteXPath();
|
|
1175
|
-
const html = await elements[i].
|
|
1176
|
-
|
|
1178
|
+
const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
|
|
1179
|
+
const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
|
|
1180
|
+
result.push({ xpath, html, text });
|
|
1177
1181
|
}
|
|
1178
1182
|
catch (e) {
|
|
1179
1183
|
debugLog('Failed to get details for element %d: %s', i, e);
|
|
@@ -1181,17 +1185,20 @@ async function extractWebElements(error) {
|
|
|
1181
1185
|
}
|
|
1182
1186
|
return result.length > 0 ? result : null;
|
|
1183
1187
|
}
|
|
1184
|
-
|
|
1188
|
+
function formatElementList(details) {
|
|
1189
|
+
return details.map((el, i) => `Element ${i + 1}:\nText: "${el.text}"\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
|
|
1190
|
+
}
|
|
1191
|
+
export async function formatMatchedElements(error) {
|
|
1185
1192
|
const details = await extractWebElements(error);
|
|
1186
1193
|
if (!details)
|
|
1187
1194
|
return 'Could not fetch element details. Repeat the action to get better info.';
|
|
1188
|
-
return details
|
|
1195
|
+
return formatElementList(details);
|
|
1189
1196
|
}
|
|
1190
1197
|
async function disambiguateElements(error, explanation, provider) {
|
|
1191
1198
|
const elementDetails = await extractWebElements(error);
|
|
1192
1199
|
if (!elementDetails)
|
|
1193
1200
|
return null;
|
|
1194
|
-
const elementList = elementDetails
|
|
1201
|
+
const elementList = formatElementList(elementDetails);
|
|
1195
1202
|
const schema = z.object({
|
|
1196
1203
|
position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
|
|
1197
1204
|
});
|
|
@@ -33,8 +33,10 @@ export declare class RequestResult {
|
|
|
33
33
|
get rawResponseBody(): string;
|
|
34
34
|
set rawResponseBodyValue(value: string);
|
|
35
35
|
get responseBody(): any;
|
|
36
|
+
get isWrite(): boolean;
|
|
36
37
|
save(outputDir: string): void;
|
|
37
38
|
static load(requestFile: string): RequestResult;
|
|
39
|
+
toEndpoint(): string;
|
|
38
40
|
toSummary(): string;
|
|
39
41
|
extractIdAndTitle(): {
|
|
40
42
|
id?: string | number;
|
|
@@ -64,6 +64,9 @@ export class RequestResult {
|
|
|
64
64
|
return raw;
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
get isWrite() {
|
|
68
|
+
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(this.method);
|
|
69
|
+
}
|
|
67
70
|
save(outputDir) {
|
|
68
71
|
const requestsDir = path.join(outputDir, 'requests');
|
|
69
72
|
if (!existsSync(requestsDir)) {
|
|
@@ -137,14 +140,17 @@ export class RequestResult {
|
|
|
137
140
|
statusText: meta.statusText || '',
|
|
138
141
|
responseHeaders: meta.responseHeaders || {},
|
|
139
142
|
timing: Number.parseInt(meta.timing) || 0,
|
|
140
|
-
timestamp: new Date(meta.timestamp ||
|
|
143
|
+
timestamp: new Date(meta.timestamp || 0),
|
|
141
144
|
});
|
|
142
145
|
result.requestFile = requestFile;
|
|
143
146
|
result.responseFile = responseFile;
|
|
144
147
|
return result;
|
|
145
148
|
}
|
|
149
|
+
toEndpoint() {
|
|
150
|
+
return `${this.method} ${this.path}`;
|
|
151
|
+
}
|
|
146
152
|
toSummary() {
|
|
147
|
-
return `${this.
|
|
153
|
+
return `${this.toEndpoint()} → ${this.status} (${this.timing}ms)`;
|
|
148
154
|
}
|
|
149
155
|
extractIdAndTitle() {
|
|
150
156
|
const body = this.responseBody;
|
|
@@ -5,6 +5,7 @@ export declare class RequestStore {
|
|
|
5
5
|
failedRequests: RequestResult[];
|
|
6
6
|
onFailedListeners: Array<(r: RequestResult) => void>;
|
|
7
7
|
outputDir: string;
|
|
8
|
+
sessionStartedAt: Date;
|
|
8
9
|
constructor(outputDir: string);
|
|
9
10
|
addCapturedRequest(result: RequestResult): void;
|
|
10
11
|
addFailedRequest(result: RequestResult): void;
|
|
@@ -19,9 +20,9 @@ export declare class RequestStore {
|
|
|
19
20
|
getRequestsByEndpoint(pathPrefix: string): RequestResult[];
|
|
20
21
|
getRequestsByMethod(method: string): RequestResult[];
|
|
21
22
|
getRequestsByStatus(status: number): RequestResult[];
|
|
22
|
-
toEndpointList(): string;
|
|
23
|
+
toEndpointList(scopePath?: string): string;
|
|
23
24
|
extractAuthHeaders(): Record<string, string>;
|
|
24
|
-
findCapturedRequest(method: string,
|
|
25
|
+
findCapturedRequest(method: string, searchPath: string): RequestResult | undefined;
|
|
25
26
|
toLog(): string;
|
|
26
27
|
loadFromDisk(): void;
|
|
27
28
|
getWriteRequestsForScope(scopePath: string): RequestResult[];
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { isDynamicSegment } from "../utils/url-matcher.js";
|
|
3
4
|
import { RequestResult } from "./request-result.js";
|
|
4
|
-
const AUTH_HEADERS = ['authorization', '
|
|
5
|
+
const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
|
|
5
6
|
export class RequestStore {
|
|
6
7
|
capturedRequests = [];
|
|
7
8
|
madeRequests = [];
|
|
8
9
|
failedRequests = [];
|
|
9
10
|
onFailedListeners = [];
|
|
10
11
|
outputDir;
|
|
12
|
+
sessionStartedAt = new Date();
|
|
11
13
|
constructor(outputDir) {
|
|
12
14
|
this.outputDir = outputDir;
|
|
13
15
|
}
|
|
@@ -61,12 +63,14 @@ export class RequestStore {
|
|
|
61
63
|
getRequestsByStatus(status) {
|
|
62
64
|
return this.madeRequests.filter((r) => r.status === status);
|
|
63
65
|
}
|
|
64
|
-
toEndpointList() {
|
|
66
|
+
toEndpointList(scopePath) {
|
|
67
|
+
let requests = this.capturedRequests;
|
|
68
|
+
if (scopePath)
|
|
69
|
+
requests = this.getWriteRequestsForScope(scopePath);
|
|
65
70
|
const seen = new Set();
|
|
66
71
|
const lines = [];
|
|
67
|
-
for (const req of
|
|
68
|
-
const
|
|
69
|
-
const key = `${req.method} ${normalized}`;
|
|
72
|
+
for (const req of requests) {
|
|
73
|
+
const key = `${req.method} ${normalizePathPattern(req.path)}`;
|
|
70
74
|
if (seen.has(key))
|
|
71
75
|
continue;
|
|
72
76
|
seen.add(key);
|
|
@@ -76,21 +80,42 @@ export class RequestStore {
|
|
|
76
80
|
}
|
|
77
81
|
extractAuthHeaders() {
|
|
78
82
|
const headers = {};
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
const sessionCaptures = this.capturedRequests.filter((r) => r.timestamp >= this.sessionStartedAt).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
84
|
+
for (const req of sessionCaptures) {
|
|
81
85
|
for (const [key, value] of Object.entries(req.requestHeaders)) {
|
|
82
86
|
if (AUTH_HEADERS.includes(key.toLowerCase()) && !headers[key]) {
|
|
83
87
|
headers[key] = value;
|
|
84
88
|
}
|
|
85
89
|
}
|
|
86
|
-
if (AUTH_HEADERS.every((h) => Object.keys(headers).some((k) => k.toLowerCase() === h)))
|
|
87
|
-
break;
|
|
88
90
|
}
|
|
89
91
|
return headers;
|
|
90
92
|
}
|
|
91
|
-
findCapturedRequest(method,
|
|
93
|
+
findCapturedRequest(method, searchPath) {
|
|
92
94
|
const upper = method.toUpperCase();
|
|
93
|
-
|
|
95
|
+
const search = normalizePathPattern(searchPath).split('/').filter(Boolean);
|
|
96
|
+
let best;
|
|
97
|
+
let bestScore = -1;
|
|
98
|
+
for (const req of this.capturedRequests) {
|
|
99
|
+
if (req.method !== upper)
|
|
100
|
+
continue;
|
|
101
|
+
const segments = normalizePathPattern(req.path).split('/').filter(Boolean);
|
|
102
|
+
if (segments.length < search.length)
|
|
103
|
+
continue;
|
|
104
|
+
if (!search.every((segment, i) => segment === segments[i]))
|
|
105
|
+
continue;
|
|
106
|
+
let score = 0;
|
|
107
|
+
if (segments.length === search.length)
|
|
108
|
+
score += 4;
|
|
109
|
+
if (req.status < 400)
|
|
110
|
+
score += 2;
|
|
111
|
+
if (score < bestScore)
|
|
112
|
+
continue;
|
|
113
|
+
if (score === bestScore && best && req.timestamp <= best.timestamp)
|
|
114
|
+
continue;
|
|
115
|
+
best = req;
|
|
116
|
+
bestScore = score;
|
|
117
|
+
}
|
|
118
|
+
return best;
|
|
94
119
|
}
|
|
95
120
|
toLog() {
|
|
96
121
|
return this.madeRequests.map((r, i) => `${i + 1}. ${r.toSummary()} [${r.id}]`).join('\n');
|
|
@@ -100,7 +125,7 @@ export class RequestStore {
|
|
|
100
125
|
if (!existsSync(requestsDir))
|
|
101
126
|
return;
|
|
102
127
|
const existingIds = new Set(this.capturedRequests.map((r) => r.id));
|
|
103
|
-
const files = readdirSync(requestsDir).filter((f) => f.endsWith('.request.yaml'));
|
|
128
|
+
const files = readdirSync(requestsDir).filter((f) => f.startsWith('xhr_') && f.endsWith('.request.yaml'));
|
|
104
129
|
for (const file of files) {
|
|
105
130
|
try {
|
|
106
131
|
const result = RequestResult.load(path.join(requestsDir, file));
|
|
@@ -115,7 +140,31 @@ export class RequestStore {
|
|
|
115
140
|
}
|
|
116
141
|
getWriteRequestsForScope(scopePath) {
|
|
117
142
|
const writeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
118
|
-
|
|
143
|
+
const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
|
|
144
|
+
const scopeSegments = scopePath.split('/').filter(Boolean);
|
|
145
|
+
if (scopeSegments.length === 0)
|
|
146
|
+
return writes;
|
|
147
|
+
let scoped = [];
|
|
148
|
+
let fewest = Number.POSITIVE_INFINITY;
|
|
149
|
+
let ambiguous = false;
|
|
150
|
+
for (const segment of scopeSegments) {
|
|
151
|
+
if (isDynamicSegment(segment))
|
|
152
|
+
continue;
|
|
153
|
+
const matches = writes.filter((r) => r.path.split('/').includes(segment));
|
|
154
|
+
if (matches.length === 0 || matches.length > fewest)
|
|
155
|
+
continue;
|
|
156
|
+
if (matches.length === fewest) {
|
|
157
|
+
if (!scoped.every((r, i) => r.id === matches[i].id))
|
|
158
|
+
ambiguous = true;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
scoped = matches;
|
|
162
|
+
fewest = matches.length;
|
|
163
|
+
ambiguous = false;
|
|
164
|
+
}
|
|
165
|
+
if (ambiguous)
|
|
166
|
+
return [];
|
|
167
|
+
return scoped;
|
|
119
168
|
}
|
|
120
169
|
clear() {
|
|
121
170
|
this.capturedRequests = [];
|
|
@@ -124,5 +173,8 @@ export class RequestStore {
|
|
|
124
173
|
}
|
|
125
174
|
}
|
|
126
175
|
function normalizePathPattern(urlPath) {
|
|
127
|
-
return urlPath
|
|
176
|
+
return urlPath
|
|
177
|
+
.split('/')
|
|
178
|
+
.map((segment) => (segment && isDynamicSegment(segment) ? '{id}' : segment))
|
|
179
|
+
.join('/');
|
|
128
180
|
}
|
|
@@ -5,7 +5,7 @@ import { dereference } from '@scalar/openapi-parser';
|
|
|
5
5
|
import { tag } from "../utils/logger.js";
|
|
6
6
|
export function validateSpecs(specs) {
|
|
7
7
|
if (!specs?.length) {
|
|
8
|
-
throw new Error('API spec is required.
|
|
8
|
+
throw new Error('API spec is required. Pass --spec, set EXPLORBOT_API_SPEC, or set api.spec in your config file.');
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
export async function loadSpec(specPaths, outputDir) {
|
|
@@ -32,7 +32,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
32
32
|
const dirs = {};
|
|
33
33
|
if (options.root) {
|
|
34
34
|
for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
|
|
35
|
-
dirs[name] = path.
|
|
35
|
+
dirs[name] = path.resolve(options.root, dir);
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
const env = {};
|
|
@@ -21,7 +21,7 @@ export class DrillCommand extends BaseCommand {
|
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
23
|
parseKnowledgeArg(args) {
|
|
24
|
-
const match = args.match(/--knowledge\s+(\S+)/);
|
|
24
|
+
const match = args.match(/--save-knowledge\s+(\S+)/);
|
|
25
25
|
return match ? match[1] : undefined;
|
|
26
26
|
}
|
|
27
27
|
parseMaxArg(args) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type Plan, type Test } from '../test-plan.js';
|
|
2
2
|
import { BaseCommand, type Suggestion } from './base-command.js';
|
|
3
|
+
export declare const DEADLINE_RESERVE_MS: number;
|
|
4
|
+
export declare const DEADLINE_TEST_ALLOWANCE_MS: number;
|
|
3
5
|
export declare class ExploreCommand extends BaseCommand {
|
|
4
6
|
name: string;
|
|
5
7
|
description: string;
|
|
@@ -9,8 +11,11 @@ export declare class ExploreCommand extends BaseCommand {
|
|
|
9
11
|
}[];
|
|
10
12
|
suggestions: Suggestion[];
|
|
11
13
|
maxTests?: number;
|
|
14
|
+
maxDurationMinutes?: number;
|
|
15
|
+
hardDeadlineAt?: number;
|
|
12
16
|
dryRun: boolean;
|
|
13
17
|
testsRun: number;
|
|
18
|
+
deadlineLogged: boolean;
|
|
14
19
|
completedPlans: Plan[];
|
|
15
20
|
failedSubPages: Set<string>;
|
|
16
21
|
oldTestRefs: Set<Test>;
|
|
@@ -36,6 +41,7 @@ export declare class ExploreCommand extends BaseCommand {
|
|
|
36
41
|
printResults(): void;
|
|
37
42
|
printNextSteps(savedPlanPath?: string | null): void;
|
|
38
43
|
isLimitReached(): boolean;
|
|
44
|
+
isDeadlineReached(): boolean;
|
|
39
45
|
runPendingTests(): Promise<void>;
|
|
40
46
|
runOneTest(test: Test): Promise<void>;
|
|
41
47
|
}
|
|
@@ -13,11 +13,14 @@ import { safeFilename } from "../utils/strings.js";
|
|
|
13
13
|
import { BaseCommand } from './base-command.js';
|
|
14
14
|
const MAX_SUB_PAGE_ATTEMPTS = 30;
|
|
15
15
|
const PRIORITY_ORDER = { critical: 0, important: 1, high: 2, normal: 3, low: 4 };
|
|
16
|
+
export const DEADLINE_RESERVE_MS = 3 * 60_000;
|
|
17
|
+
export const DEADLINE_TEST_ALLOWANCE_MS = 5 * 60_000;
|
|
16
18
|
export class ExploreCommand extends BaseCommand {
|
|
17
19
|
name = 'explore';
|
|
18
20
|
description = 'Start web exploration';
|
|
19
21
|
options = [
|
|
20
22
|
{ flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
|
|
23
|
+
{ flags: '--max-duration <minutes>', description: 'Wall-clock budget in minutes; wraps up the session before the limit is hit' },
|
|
21
24
|
{ flags: '--focus <feature>', description: 'Focus area for exploration' },
|
|
22
25
|
{ flags: '--configure <spec>', description: 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"' },
|
|
23
26
|
{ flags: '--dry-run', description: 'Mark picked tests as skipped without executing or generating new ones' },
|
|
@@ -28,8 +31,11 @@ export class ExploreCommand extends BaseCommand {
|
|
|
28
31
|
{ command: 'plan <feature>', hint: 'plan testing' },
|
|
29
32
|
];
|
|
30
33
|
maxTests;
|
|
34
|
+
maxDurationMinutes;
|
|
35
|
+
hardDeadlineAt;
|
|
31
36
|
dryRun = false;
|
|
32
37
|
testsRun = 0;
|
|
38
|
+
deadlineLogged = false;
|
|
33
39
|
completedPlans = [];
|
|
34
40
|
failedSubPages = new Set();
|
|
35
41
|
oldTestRefs = new Set();
|
|
@@ -43,6 +49,12 @@ export class ExploreCommand extends BaseCommand {
|
|
|
43
49
|
if (opts.maxTests) {
|
|
44
50
|
this.maxTests = Number.parseInt(opts.maxTests, 10);
|
|
45
51
|
}
|
|
52
|
+
if (opts.maxDuration) {
|
|
53
|
+
this.maxDurationMinutes = Number.parseInt(opts.maxDuration, 10);
|
|
54
|
+
}
|
|
55
|
+
if (this.hardDeadlineAt == null && this.maxDurationMinutes != null) {
|
|
56
|
+
this.hardDeadlineAt = Date.now() + this.maxDurationMinutes * 60_000 - DEADLINE_RESERVE_MS;
|
|
57
|
+
}
|
|
46
58
|
const feature = opts.focus || remaining.join(' ') || undefined;
|
|
47
59
|
const cfg = this.parseConfigure(opts.configure);
|
|
48
60
|
if (cfg.priorities)
|
|
@@ -244,9 +256,15 @@ export class ExploreCommand extends BaseCommand {
|
|
|
244
256
|
tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
|
|
245
257
|
try {
|
|
246
258
|
await this.explorBot.visit(pick.url);
|
|
259
|
+
const errorPage = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
|
|
260
|
+
if (errorPage) {
|
|
261
|
+
tag('warning').log(`Skipping sub-page: ${errorPage.message}`);
|
|
262
|
+
this.failedSubPages.add(normalizeUrl(pick.url));
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
247
265
|
await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans, styles);
|
|
248
266
|
const subPlan = this.explorBot.getCurrentPlan();
|
|
249
|
-
if (subPlan && !this.completedPlans.includes(subPlan)) {
|
|
267
|
+
if (subPlan?.tests.length && !this.completedPlans.includes(subPlan)) {
|
|
250
268
|
this.completedPlans.push(subPlan);
|
|
251
269
|
}
|
|
252
270
|
knownUrls.add(normalizeUrl(pick.url));
|
|
@@ -285,6 +303,11 @@ export class ExploreCommand extends BaseCommand {
|
|
|
285
303
|
if (this.dryRun)
|
|
286
304
|
opts.noSave = true;
|
|
287
305
|
await this.planWithRetry(feature, opts, pageUrl);
|
|
306
|
+
const plan = this.explorBot.getCurrentPlan();
|
|
307
|
+
if (plan && plan.tests.length === 0) {
|
|
308
|
+
tag('warning').log('Nothing to test on this page, moving on');
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
288
311
|
await this.runPendingTests();
|
|
289
312
|
this.rememberCurrentPlan();
|
|
290
313
|
fresh = false;
|
|
@@ -515,7 +538,20 @@ export class ExploreCommand extends BaseCommand {
|
|
|
515
538
|
printNextSteps(sections);
|
|
516
539
|
}
|
|
517
540
|
isLimitReached() {
|
|
518
|
-
|
|
541
|
+
if (this.maxTests != null && this.testsRun >= this.maxTests)
|
|
542
|
+
return true;
|
|
543
|
+
return this.isDeadlineReached();
|
|
544
|
+
}
|
|
545
|
+
isDeadlineReached() {
|
|
546
|
+
if (this.hardDeadlineAt == null)
|
|
547
|
+
return false;
|
|
548
|
+
if (Date.now() < this.hardDeadlineAt - DEADLINE_TEST_ALLOWANCE_MS)
|
|
549
|
+
return false;
|
|
550
|
+
if (!this.deadlineLogged) {
|
|
551
|
+
this.deadlineLogged = true;
|
|
552
|
+
tag('info').log(`Time budget reached after ${this.testsRun} test(s): stopping new work and finishing the session`);
|
|
553
|
+
}
|
|
554
|
+
return true;
|
|
519
555
|
}
|
|
520
556
|
async runPendingTests() {
|
|
521
557
|
const plan = this.explorBot.getCurrentPlan();
|
|
@@ -539,7 +575,7 @@ export class ExploreCommand extends BaseCommand {
|
|
|
539
575
|
test.finish(TestResult.SKIPPED);
|
|
540
576
|
}
|
|
541
577
|
else {
|
|
542
|
-
await this.explorBot.agentTester().test(test);
|
|
578
|
+
await this.explorBot.agentTester().test(test, { deadline: this.hardDeadlineAt });
|
|
543
579
|
}
|
|
544
580
|
this.testsRun++;
|
|
545
581
|
}
|
|
@@ -4,7 +4,7 @@ import { Stats } from '../stats.js';
|
|
|
4
4
|
import { tag } from '../utils/logger.js';
|
|
5
5
|
import { loop } from '../utils/loop.js';
|
|
6
6
|
import { BaseCommand } from './base-command.js';
|
|
7
|
-
import { ExploreCommand } from './explore-command.js';
|
|
7
|
+
import { DEADLINE_RESERVE_MS, DEADLINE_TEST_ALLOWANCE_MS, ExploreCommand } from './explore-command.js';
|
|
8
8
|
export class FreesailCommand extends BaseCommand {
|
|
9
9
|
name = 'freesail';
|
|
10
10
|
description = 'Continuously explore and navigate to new pages autonomously';
|
|
@@ -15,6 +15,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
15
15
|
{ flags: '--shallow', description: 'Use shallow navigation strategy' },
|
|
16
16
|
{ flags: '--scope <url>', description: 'Limit navigation to URLs starting with this prefix' },
|
|
17
17
|
{ flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
|
|
18
|
+
{ flags: '--max-duration <number>', description: 'Wall-clock budget in minutes for the whole run' },
|
|
18
19
|
];
|
|
19
20
|
async execute(args) {
|
|
20
21
|
Stats.mode = 'freesail';
|
|
@@ -26,11 +27,17 @@ export class FreesailCommand extends BaseCommand {
|
|
|
26
27
|
strategy = 'shallow';
|
|
27
28
|
const scope = opts.scope;
|
|
28
29
|
const maxTests = opts.maxTests ? Number.parseInt(opts.maxTests, 10) : undefined;
|
|
30
|
+
const maxDuration = opts.maxDuration ? Number.parseInt(opts.maxDuration, 10) : undefined;
|
|
31
|
+
let hardDeadlineAt;
|
|
32
|
+
if (maxDuration != null)
|
|
33
|
+
hardDeadlineAt = Date.now() + maxDuration * 60_000 - DEADLINE_RESERVE_MS;
|
|
29
34
|
await this.explorBot.visitInitialState();
|
|
30
35
|
let testsRun = 0;
|
|
31
36
|
await loop(async (ctx) => {
|
|
32
37
|
if (maxTests != null && testsRun >= maxTests)
|
|
33
38
|
ctx.stop();
|
|
39
|
+
if (hardDeadlineAt != null && Date.now() >= hardDeadlineAt - DEADLINE_TEST_ALLOWANCE_MS)
|
|
40
|
+
ctx.stop();
|
|
34
41
|
const stateManager = this.explorBot.stateManager();
|
|
35
42
|
const state = stateManager.getCurrentState();
|
|
36
43
|
if (state && !Researcher.getCachedResearch(state)) {
|
|
@@ -44,6 +51,8 @@ export class FreesailCommand extends BaseCommand {
|
|
|
44
51
|
const exploreCmd = new ExploreCommand(this.explorBot);
|
|
45
52
|
if (maxTests != null)
|
|
46
53
|
exploreCmd.maxTests = maxTests - testsRun;
|
|
54
|
+
if (hardDeadlineAt != null)
|
|
55
|
+
exploreCmd.hardDeadlineAt = hardDeadlineAt;
|
|
47
56
|
await exploreCmd.execute('');
|
|
48
57
|
const plan = this.explorBot.getCurrentPlan();
|
|
49
58
|
if (plan)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
export declare abstract class BaseOption {
|
|
3
|
+
abstract flags: string;
|
|
4
|
+
abstract description: string;
|
|
5
|
+
collect?: (value: string, previous: any) => any;
|
|
6
|
+
register(program: Command): void;
|
|
7
|
+
abstract apply(options: Record<string, any>, command: Command): void;
|
|
8
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class BaseOption {
|
|
2
|
+
collect;
|
|
3
|
+
register(program) {
|
|
4
|
+
if (this.collect)
|
|
5
|
+
program.option(this.flags, this.description, this.collect);
|
|
6
|
+
if (!this.collect)
|
|
7
|
+
program.option(this.flags, this.description);
|
|
8
|
+
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
9
|
+
this.apply(actionCommand.optsWithGlobals(), actionCommand);
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { KnowledgeTracker } from '../../knowledge-tracker.js';
|
|
2
|
+
import { BaseOption } from './base-option.js';
|
|
3
|
+
export class KnowledgeOption extends BaseOption {
|
|
4
|
+
flags = '--knowledge <text>';
|
|
5
|
+
description = 'Knowledge for this run only, not saved to disk. Markdown text; add url: or endpoint: frontmatter to scope it, otherwise it applies everywhere. Repeatable';
|
|
6
|
+
collect = (value, previous = []) => [...previous, value];
|
|
7
|
+
apply(options) {
|
|
8
|
+
for (const text of options.knowledge || []) {
|
|
9
|
+
KnowledgeTracker.appendSessionKnowledge(text);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|