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/src/ai/fisherman.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { dataProtectionRules } from './rules.ts';
|
|
|
13
13
|
|
|
14
14
|
const MAX_ITERATIONS = 15;
|
|
15
15
|
const MAX_TOOL_ROUNDTRIPS = 5;
|
|
16
|
+
const REPEATED_FAILURE_LIMIT = 4;
|
|
16
17
|
|
|
17
18
|
export class Fisherman implements Agent {
|
|
18
19
|
emoji = '🎣';
|
|
@@ -20,21 +21,22 @@ export class Fisherman implements Agent {
|
|
|
20
21
|
private apiClient: ApiClient;
|
|
21
22
|
private requestStore: RequestStore;
|
|
22
23
|
private specLoader: () => Promise<any | null>;
|
|
23
|
-
private
|
|
24
|
+
private browserHeaderProvider: () => Promise<Record<string, string>>;
|
|
24
25
|
private configHeaders: Record<string, string>;
|
|
25
26
|
private sessionName?: string;
|
|
26
27
|
private baseEndpoint: string;
|
|
27
28
|
private spec: any | null = null;
|
|
28
29
|
private mode: 'replicate' | 'achieve' | 'disabled' = 'disabled';
|
|
29
30
|
private hasApiConfig: boolean;
|
|
31
|
+
private scopeDegraded = false;
|
|
30
32
|
|
|
31
|
-
constructor(provider: Provider, apiClient: ApiClient, requestStore: RequestStore, specLoader: () => Promise<any | null>, baseEndpoint: string,
|
|
33
|
+
constructor(provider: Provider, apiClient: ApiClient, requestStore: RequestStore, specLoader: () => Promise<any | null>, baseEndpoint: string, browserHeaderProvider: () => Promise<Record<string, string>>, configHeaders: Record<string, string> = {}, hasApiConfig = false) {
|
|
32
34
|
this.provider = provider;
|
|
33
35
|
this.apiClient = apiClient;
|
|
34
36
|
this.requestStore = requestStore;
|
|
35
37
|
this.specLoader = specLoader;
|
|
36
38
|
this.baseEndpoint = baseEndpoint;
|
|
37
|
-
this.
|
|
39
|
+
this.browserHeaderProvider = browserHeaderProvider;
|
|
38
40
|
this.configHeaders = configHeaders;
|
|
39
41
|
this.hasApiConfig = hasApiConfig;
|
|
40
42
|
this.mode = hasApiConfig ? 'achieve' : 'replicate';
|
|
@@ -77,10 +79,11 @@ export class Fisherman implements Agent {
|
|
|
77
79
|
await this.refreshAuth();
|
|
78
80
|
debugLog(`auth headers: ${Object.keys(this.apiClient.getHeaders()).join(', ')}`);
|
|
79
81
|
|
|
80
|
-
const { tools, getResult, isFinished } = createFishermanTools(this.apiClient, this.requestStore, {
|
|
82
|
+
const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, {
|
|
81
83
|
spec: this.spec,
|
|
82
84
|
baseEndpoint: this.baseEndpoint,
|
|
83
85
|
});
|
|
86
|
+
const ledgerStart = this.requestStore.getMadeRequests().length;
|
|
84
87
|
|
|
85
88
|
const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
|
|
86
89
|
conversation.addUserText(this.buildTaskPrompt(instructions));
|
|
@@ -90,7 +93,6 @@ export class Fisherman implements Agent {
|
|
|
90
93
|
debugLog(`iteration ${iteration}`);
|
|
91
94
|
const invokeResult = await this.provider.invokeConversation(conversation, tools, {
|
|
92
95
|
maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
|
|
93
|
-
toolChoice: 'required',
|
|
94
96
|
agentName: 'fisherman',
|
|
95
97
|
});
|
|
96
98
|
debugLog(`iteration ${iteration} done, text: ${invokeResult?.response?.text?.slice(0, 200) || '(none)'}`);
|
|
@@ -100,6 +102,19 @@ export class Fisherman implements Agent {
|
|
|
100
102
|
return;
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
if (!invokeResult?.toolExecutions?.length) {
|
|
106
|
+
debugLog('no tool call in this turn — treating as finish');
|
|
107
|
+
finishFromText(invokeResult?.response?.text);
|
|
108
|
+
stop();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (this.isStuckOnEndpoint(ledgerStart)) {
|
|
113
|
+
tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
|
|
114
|
+
stop();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
103
118
|
if (iteration >= MAX_ITERATIONS) {
|
|
104
119
|
tag('warning').log('Fisherman: max iterations reached');
|
|
105
120
|
stop();
|
|
@@ -145,14 +160,16 @@ export class Fisherman implements Agent {
|
|
|
145
160
|
}
|
|
146
161
|
|
|
147
162
|
private async refreshAuth(): Promise<void> {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
163
|
+
if (this.mode === 'replicate') {
|
|
164
|
+
const xhrHeaders = this.requestStore.extractAuthHeaders();
|
|
165
|
+
if (Object.keys(xhrHeaders).length > 0) {
|
|
166
|
+
this.apiClient.setHeaders(xhrHeaders);
|
|
167
|
+
}
|
|
152
168
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
const browserHeaders = await this.browserHeaderProvider();
|
|
170
|
+
if (Object.keys(browserHeaders).length > 0) {
|
|
171
|
+
this.apiClient.setHeaders(browserHeaders);
|
|
172
|
+
}
|
|
156
173
|
}
|
|
157
174
|
|
|
158
175
|
if (Object.keys(this.configHeaders).length > 0) {
|
|
@@ -161,31 +178,25 @@ export class Fisherman implements Agent {
|
|
|
161
178
|
}
|
|
162
179
|
|
|
163
180
|
private buildEndpointList(scopeUrl?: string): string {
|
|
181
|
+
this.scopeDegraded = false;
|
|
164
182
|
if (this.mode === 'achieve' && this.spec) {
|
|
165
183
|
const specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
|
|
166
184
|
if (specEndpoints) return specEndpoints;
|
|
167
185
|
}
|
|
168
186
|
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
writeRequests = this.requestStore.getWriteRequestsForScope('/');
|
|
172
|
-
}
|
|
187
|
+
const scoped = this.requestStore.toEndpointList(scopeUrl || '/');
|
|
188
|
+
if (scoped) return scoped;
|
|
173
189
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
for (const req of writeRequests) {
|
|
178
|
-
const key = `${req.method} ${req.path}`;
|
|
179
|
-
if (seen.has(key)) continue;
|
|
180
|
-
seen.add(key);
|
|
181
|
-
lines.push(key);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return lines.join('\n');
|
|
190
|
+
this.scopeDegraded = true;
|
|
191
|
+
return this.requestStore.toEndpointList();
|
|
185
192
|
}
|
|
186
193
|
|
|
187
194
|
private buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string {
|
|
188
|
-
|
|
195
|
+
let scopeBlock = '';
|
|
196
|
+
if (scopeUrl) {
|
|
197
|
+
scopeBlock = `\n\nSCOPE: You are operating within ${scopeUrl}.\nAll created items must belong to this scope.`;
|
|
198
|
+
if (this.scopeDegraded) scopeBlock += '\nThe endpoint list could not be narrowed to this scope and may include endpoints belonging to other scopes. Before writing, confirm the target belongs to this scope.';
|
|
199
|
+
}
|
|
189
200
|
|
|
190
201
|
return dedent`
|
|
191
202
|
You are Fisherman — a data preparation agent. You create test data by making API requests.
|
|
@@ -210,12 +221,21 @@ export class Fisherman implements Agent {
|
|
|
210
221
|
- Chain requests logically — create parent resources before children
|
|
211
222
|
- Use the response category and error text to decide what failed: validation requires corrected data, authorization requires valid access, not_found requires a valid path or parent, and conflict requires resolving the conflicting state
|
|
212
223
|
- Retry temporary or server failures once. Retry other failures only when the specification or error text gives a concrete correction
|
|
224
|
+
- Create only the resource types that were requested. If no endpoint creates a requested type, call stop — never create a different type as a substitute
|
|
213
225
|
- Use realistic but unique data for each item (vary names, titles)
|
|
214
226
|
|
|
215
227
|
${dataProtectionRules}
|
|
216
228
|
`;
|
|
217
229
|
}
|
|
218
230
|
|
|
231
|
+
private isStuckOnEndpoint(ledgerStart: number): boolean {
|
|
232
|
+
const made = this.requestStore.getMadeRequests().slice(ledgerStart);
|
|
233
|
+
if (made.length < REPEATED_FAILURE_LIMIT) return false;
|
|
234
|
+
const recent = made.slice(-REPEATED_FAILURE_LIMIT);
|
|
235
|
+
const first = recent[0];
|
|
236
|
+
return recent.every((r) => (r.status >= 400 || r.error) && r.method === first.method && r.path === first.path);
|
|
237
|
+
}
|
|
238
|
+
|
|
219
239
|
private buildTaskPrompt(instructions: string): string {
|
|
220
240
|
return dedent`
|
|
221
241
|
Prepare the following test data:
|
|
@@ -64,7 +64,7 @@ export function WithCodeceptJS<T extends Constructor>(Base: T) {
|
|
|
64
64
|
lines.push(`Feature('${escapeString(plan.title)}')`);
|
|
65
65
|
lines.push('');
|
|
66
66
|
|
|
67
|
-
const startUrl = plan.
|
|
67
|
+
const startUrl = plan.startUrl;
|
|
68
68
|
if (startUrl) {
|
|
69
69
|
lines.push('Before(({ I }) => {');
|
|
70
70
|
lines.push(` I.amOnPage('${escapeString(startUrl)}');`);
|
|
@@ -98,7 +98,7 @@ export function WithPlaywright<T extends Constructor>(Base: T) {
|
|
|
98
98
|
lines.push('');
|
|
99
99
|
lines.push(`test.describe('${escapeString(plan.title)}', () => {`);
|
|
100
100
|
|
|
101
|
-
const startUrl = plan.
|
|
101
|
+
const startUrl = plan.startUrl;
|
|
102
102
|
if (startUrl) {
|
|
103
103
|
lines.push(' test.beforeEach(async ({ page }) => {');
|
|
104
104
|
lines.push(` await page.goto('${escapeString(startUrl)}');`);
|
package/src/ai/navigator.ts
CHANGED
|
@@ -575,7 +575,7 @@ class Navigator implements Agent {
|
|
|
575
575
|
}
|
|
576
576
|
|
|
577
577
|
const currentActionResult = actionResult || ActionResult.fromState(state);
|
|
578
|
-
const research = Researcher.getCachedResearch(
|
|
578
|
+
const research = Researcher.getCachedResearch(currentActionResult) || '';
|
|
579
579
|
const combinedHtml = await currentActionResult.combinedHtml();
|
|
580
580
|
|
|
581
581
|
const history = stateManager.getStateHistory();
|
package/src/ai/pilot.ts
CHANGED
|
@@ -765,6 +765,7 @@ export class Pilot implements Agent {
|
|
|
765
765
|
const parts = [c.type];
|
|
766
766
|
if (c.title) parts.push(`"${c.title}"`);
|
|
767
767
|
if (c.id) parts.push(`(id: ${c.id})`);
|
|
768
|
+
if (c.request) parts.push(`via ${c.request}`);
|
|
768
769
|
return parts.join(' ');
|
|
769
770
|
});
|
|
770
771
|
const stepText = `Precondition: created ${items.join(', ')}`;
|
|
@@ -827,7 +828,13 @@ export class Pilot implements Agent {
|
|
|
827
828
|
|
|
828
829
|
const focusArea = state.overlay;
|
|
829
830
|
if (focusArea.detected) {
|
|
830
|
-
|
|
831
|
+
let line = `modal: ${focusArea.name || focusArea.type}`;
|
|
832
|
+
if (focusArea.root) line += ` (root: ${focusArea.root})`;
|
|
833
|
+
lines.push(line);
|
|
834
|
+
} else if (focusArea.present) {
|
|
835
|
+
let line = `region: ${focusArea.name || 'unnamed'} (inline`;
|
|
836
|
+
if (focusArea.root) line += `, root: ${focusArea.root}`;
|
|
837
|
+
lines.push(`${line})`);
|
|
831
838
|
} else {
|
|
832
839
|
lines.push('modal: none');
|
|
833
840
|
}
|
|
@@ -1133,6 +1140,7 @@ export class Pilot implements Agent {
|
|
|
1133
1140
|
Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
|
|
1134
1141
|
- Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
|
|
1135
1142
|
- "modal: none" but Tester targets a modal → modal closed; re-trigger.
|
|
1143
|
+
- "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
|
|
1136
1144
|
- Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
|
|
1137
1145
|
- MultipleElementsFound → xpathCheck() to identify the right one, then precise locator or visualClick().
|
|
1138
1146
|
- Wrong page (settings vs feature) → getVisitedStates() then back() or reset(). Don't try breadcrumbs (SPA back-nav is unreliable).
|
package/src/ai/planner.ts
CHANGED
|
@@ -114,11 +114,15 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
114
114
|
Tests must be relevant to the page
|
|
115
115
|
Tests must be achievable from UI
|
|
116
116
|
Tests must be verifiable from UI
|
|
117
|
-
|
|
117
|
+
One test verifies ONE business operation: the steps that reach it, the action itself, and its verification.
|
|
118
|
+
Steps that only reach the action — opening a form, expanding a panel, creating or locating the item to act on — belong to that test. A second operation with its own verification does not.
|
|
118
119
|
Bad: "Open delete dropdown" + "Confirm deletion" — these are ONE test, not two.
|
|
119
120
|
Bad: "Search for X" + "Verify search results" — searching and verifying is ONE test.
|
|
120
121
|
Bad: "Leave field empty" + "Click submit" — that's one negative test, not two.
|
|
121
|
-
|
|
122
|
+
Bad: "Create a record, rename it, delete it" — three verified operations, so THREE tests, not one.
|
|
123
|
+
Good: "Rename existing record and verify the new title" — ONE test; creating is skipped, we assume record already exists, only the rename is verified.
|
|
124
|
+
You may rely on another test having run first in case we deal with empty state and no relevant data was created yet and we expect another our test creates it
|
|
125
|
+
When the page reports a record is missing or unavailable, it is not a testable surface — plan list-level or recovery behavior instead of operations on that record.${featureDirective}${focusExistingDataDirective}
|
|
122
126
|
</task>
|
|
123
127
|
|
|
124
128
|
${customPrompt || ''}
|
|
@@ -195,10 +199,6 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
195
199
|
throw new Error('No tasks were created successfully');
|
|
196
200
|
}
|
|
197
201
|
|
|
198
|
-
if (aiResult.object.scenarios.length === 0 && !this.currentPlan) {
|
|
199
|
-
throw new Error('No tasks were created successfully');
|
|
200
|
-
}
|
|
201
|
-
|
|
202
202
|
const defaultStartUrl = this.getDefaultStartUrl(state);
|
|
203
203
|
const fromPlanning = aiResult.object.scenarios.map((s: any) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || defaultStartUrl, s.steps || []));
|
|
204
204
|
|
|
@@ -335,6 +335,7 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
335
335
|
<task>
|
|
336
336
|
Based on the page research, create ${this.MIN_TASKS}-${this.MAX_TASKS} exploratory testing scenarios.
|
|
337
337
|
For each scenario provide specific steps and expected outcomes.
|
|
338
|
+
Exception: if the page reports the requested resource is missing, shows a failure state, or holds no content and no controls, return an empty scenarios list. Never invent tests for a page with nothing to exercise.
|
|
338
339
|
</task>
|
|
339
340
|
|
|
340
341
|
<rules>
|
|
@@ -348,13 +349,13 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
348
349
|
Focus on error or success messages as outcome.
|
|
349
350
|
Focus on URL page change or data persistency after page reload.
|
|
350
351
|
If there are subpages (pages with same URL path) plan testing of those subpages as well
|
|
351
|
-
|
|
352
|
+
Plan CRUD operations in order: create, read, update, delete.
|
|
352
353
|
Do not invent specific route names, success messages, validation texts, badge counts, or welcome messages unless they are visible in research, visited pages, or prior observed flows.
|
|
353
354
|
When validation placement or wording was not observed, require feedback associated with the invalid input without inventing a specific location or message.
|
|
354
355
|
If exact wording is unknown, describe the expected result generically, for example "an authentication error is shown" or "the user stays on the login page" instead of guessing the literal text.
|
|
355
356
|
If exact redirect destination is unknown, describe the destination by visible page identity, for example "the dashboard page opens" or "the current workspace home page opens" instead of inventing a URL slug.
|
|
356
357
|
Only propose scenarios whose prerequisites are evident from page research, visited pages, or API data preparation context.
|
|
357
|
-
If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible
|
|
358
|
+
If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible, API preconditions can create it, or the scenario itself creates the record as its setup.
|
|
358
359
|
If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks write controls, prefer read-only scenarios such as opening panels, inspecting visible lists, filtering, searching, or verifying current state.
|
|
359
360
|
Do not assume hidden data exists just because a control is present.
|
|
360
361
|
For scenarios that act on existing items or search/filter by existing values, use only item names or values visible in research, visited pages, or prior observed flows.
|
package/src/ai/provider.ts
CHANGED
|
@@ -426,10 +426,20 @@ export class Provider {
|
|
|
426
426
|
const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
427
427
|
let attemptMessages = messages;
|
|
428
428
|
let invalidRequestFeedbackAdded = false;
|
|
429
|
+
const executedStepMessages: ModelMessage[] = [];
|
|
429
430
|
try {
|
|
430
431
|
const response = await this.withModelRequestSlot(() =>
|
|
431
432
|
withRetry(async () => {
|
|
432
|
-
const
|
|
433
|
+
const stepMessages: ModelMessage[] = [];
|
|
434
|
+
const onStepEnd = (step: any) => {
|
|
435
|
+
stepMessages.push(...(step.response?.messages || []));
|
|
436
|
+
};
|
|
437
|
+
const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal, onStepEnd }), config.timeout || 30000).catch((error) => {
|
|
438
|
+
if (stepMessages.length > 0) {
|
|
439
|
+
tag('warning').log(`Keeping ${stepMessages.length} messages from tool steps that already ran before the failure`);
|
|
440
|
+
executedStepMessages.push(...stepMessages);
|
|
441
|
+
attemptMessages = [...attemptMessages, ...stepMessages];
|
|
442
|
+
}
|
|
433
443
|
if (!invalidRequestFeedbackAdded) {
|
|
434
444
|
const amended = withInvalidRequestFeedback(attemptMessages, error);
|
|
435
445
|
invalidRequestFeedbackAdded = amended !== attemptMessages;
|
|
@@ -448,6 +458,8 @@ export class Provider {
|
|
|
448
458
|
|
|
449
459
|
clearActivity();
|
|
450
460
|
|
|
461
|
+
withExecutedSteps(response, executedStepMessages);
|
|
462
|
+
|
|
451
463
|
// Log tool usage summary
|
|
452
464
|
if (response.toolCalls && response.toolCalls.length > 0) {
|
|
453
465
|
responseLog(response.toolCalls);
|
|
@@ -462,12 +474,13 @@ export class Provider {
|
|
|
462
474
|
} catch (error: any) {
|
|
463
475
|
clearActivity();
|
|
464
476
|
if (error?.message?.includes('Tool choice is required')) {
|
|
465
|
-
return { text: '', toolCalls: [], toolResults: [],
|
|
477
|
+
return { text: '', toolCalls: [], toolResults: [], responseMessages: executedStepMessages, usage: null };
|
|
466
478
|
}
|
|
467
479
|
if (error?.name === 'AbortError') throw error;
|
|
468
480
|
if (error instanceof ContextLengthError) throw error;
|
|
469
481
|
if (Provider.isContextLengthError(error)) {
|
|
470
|
-
|
|
482
|
+
const recovered = await this.recoverFromContextLength(error, attemptMessages, options, (m, o) => this.generateWithTools(m, model, tools, o));
|
|
483
|
+
return withExecutedSteps(recovered, executedStepMessages);
|
|
471
484
|
}
|
|
472
485
|
if (error.constructor?.name === 'AI_APICallError') {
|
|
473
486
|
responseLog(error.message);
|
|
@@ -703,6 +716,11 @@ function repairToolCall(options: ToolCallRepairOptions): any | null {
|
|
|
703
716
|
return repairHarmonyChannel(options);
|
|
704
717
|
}
|
|
705
718
|
|
|
719
|
+
function withExecutedSteps(result: any, executed: ModelMessage[]): any {
|
|
720
|
+
if (executed.length === 0) return result;
|
|
721
|
+
return Object.defineProperty(result, 'responseMessages', { value: [...executed, ...(result.responseMessages || [])], configurable: true, enumerable: true });
|
|
722
|
+
}
|
|
723
|
+
|
|
706
724
|
function withInvalidRequestFeedback(messages: ModelMessage[], error: unknown): ModelMessage[] {
|
|
707
725
|
if (!(error instanceof APICallError) || error.statusCode !== 400) return messages;
|
|
708
726
|
tag('warning').log('Provider rejected the request as invalid — relaying its reason before the retry');
|
|
@@ -89,8 +89,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
89
89
|
|
|
90
90
|
async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
|
|
91
91
|
const focusArea = current.overlay;
|
|
92
|
-
if (!focusArea.
|
|
93
|
-
if (focusArea.type !== 'dialog' && focusArea.type !== 'modal') return null;
|
|
92
|
+
if (!focusArea.present || !focusArea.name) return null;
|
|
94
93
|
|
|
95
94
|
const cached = getCachedResearch(pageStateHash);
|
|
96
95
|
if (!cached) return null;
|
package/src/ai/researcher.ts
CHANGED
|
@@ -76,7 +76,8 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
static getCachedResearch(state: WebPageState): string {
|
|
79
|
-
return getCachedResearch(state.
|
|
79
|
+
if (state instanceof ActionResult) return getCachedResearch(state.baseHash);
|
|
80
|
+
return getCachedResearch(ActionResult.fromState(state).baseHash);
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
getSystemMessage(): string {
|
|
@@ -96,7 +97,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
96
97
|
const maxRetries = (this.config.ai?.agents?.researcher as any)?.retries ?? 2;
|
|
97
98
|
let retriesLeft = opts._retriesLeft ?? maxRetries;
|
|
98
99
|
this.actionResult = ActionResult.fromState(state);
|
|
99
|
-
const stateHash =
|
|
100
|
+
const stateHash = this.actionResult.baseHash;
|
|
100
101
|
const researchState = { ...state, hash: stateHash };
|
|
101
102
|
|
|
102
103
|
if (!force && stateHash) {
|
|
@@ -268,7 +269,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
268
269
|
|
|
269
270
|
if (!interrupted() && deep) {
|
|
270
271
|
try {
|
|
271
|
-
await this.performDeepAnalysis(
|
|
272
|
+
await this.performDeepAnalysis(researchState, result);
|
|
272
273
|
} catch (err) {
|
|
273
274
|
tag('warning').log(`Deep analysis failed, continuing with best-effort research: ${err instanceof Error ? err.message : err}`);
|
|
274
275
|
}
|
package/src/ai/rules.ts
CHANGED
|
@@ -154,11 +154,11 @@ export const protectionRule = dedent`
|
|
|
154
154
|
|
|
155
155
|
Pre-existing data on the page belongs to the application, not the test.
|
|
156
156
|
Items that were not created inside the current test scenario must not be deleted, removed, emptied, reset, archived, or otherwise destroyed.
|
|
157
|
-
If a scenario needs to verify destructive behaviour, the same scenario must first create
|
|
157
|
+
If a scenario needs to verify destructive behaviour, the same scenario must first create its own target and then destroy that specific target — never operate on data that was already there when the test started.
|
|
158
158
|
|
|
159
159
|
The resource that the current page URL represents is "under test".
|
|
160
160
|
The test must not destroy the resource it is running against — doing so invalidates every subsequent scenario that starts on the same URL.
|
|
161
|
-
Do not propose or perform delete/remove/archive actions on the entity that owns the current URL; propose such actions only on
|
|
161
|
+
Do not propose or perform delete/remove/archive actions on the entity that owns the current URL; propose such actions only on children created within the scenario itself.
|
|
162
162
|
</important>
|
|
163
163
|
`;
|
|
164
164
|
|
|
@@ -174,7 +174,7 @@ export const dataProtectionRules = dedent`
|
|
|
174
174
|
filter, tab, or list-inspection constraint. Use visible existing data when it is available.
|
|
175
175
|
If no suitable data exists, report the missing precondition instead of creating data.
|
|
176
176
|
|
|
177
|
-
Destructive actions are allowed only against
|
|
177
|
+
Destructive actions are allowed only against data created by the current scenario
|
|
178
178
|
or prepared for that scenario by Fisherman/API preconditions. Existing application data must
|
|
179
179
|
remain unchanged.
|
|
180
180
|
</data_protection_rules>
|
package/src/ai/tester.ts
CHANGED
|
@@ -63,6 +63,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
63
63
|
private seenUiMapUrls = new Set<string>();
|
|
64
64
|
private lastAnalyzedStateHash: string | null = null;
|
|
65
65
|
private stalledIterations = 0;
|
|
66
|
+
private previousRegionPresent: boolean | null = null;
|
|
67
|
+
private regionTransitioned = false;
|
|
66
68
|
private readonly MAX_STALLED_ITERATIONS = 3;
|
|
67
69
|
|
|
68
70
|
private skipResearch = (err: Error): string => {
|
|
@@ -117,6 +119,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
117
119
|
this.seenUiMapUrls.clear();
|
|
118
120
|
this.lastAnalyzedStateHash = null;
|
|
119
121
|
this.stalledIterations = 0;
|
|
122
|
+
this.previousRegionPresent = null;
|
|
123
|
+
this.regionTransitioned = false;
|
|
120
124
|
this.stateManager.clearHistory();
|
|
121
125
|
this.resetFailureCount();
|
|
122
126
|
this.pilot?.reset();
|
|
@@ -246,6 +250,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
246
250
|
const codeceptjsTools = createCodeceptJSTools(this.toolDeps, task);
|
|
247
251
|
let assertionPerformed = false;
|
|
248
252
|
let extensions = 0;
|
|
253
|
+
let deadlineReached = false;
|
|
249
254
|
let shouldContinue = true;
|
|
250
255
|
|
|
251
256
|
while (shouldContinue) {
|
|
@@ -253,6 +258,12 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
253
258
|
|
|
254
259
|
await loop(
|
|
255
260
|
async ({ stop, pause, iteration, userInput }) => {
|
|
261
|
+
if (opts.deadline != null && Date.now() >= opts.deadline) {
|
|
262
|
+
deadlineReached = true;
|
|
263
|
+
task.addNote('Time budget reached. Stopped');
|
|
264
|
+
stop();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
256
267
|
debugLog('iteration', iteration);
|
|
257
268
|
if (!(await this.explorer.recover()).ok) {
|
|
258
269
|
task.addNote('Browser page is unavailable');
|
|
@@ -420,6 +431,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
420
431
|
);
|
|
421
432
|
|
|
422
433
|
if (task.hasFinished) break;
|
|
434
|
+
if (deadlineReached) break;
|
|
423
435
|
|
|
424
436
|
if (!(await this.explorer.recover()).ok) break;
|
|
425
437
|
|
|
@@ -460,6 +472,10 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
460
472
|
}
|
|
461
473
|
|
|
462
474
|
private shouldAnalyzeProgress(iteration: number, currentState: ActionResult): boolean {
|
|
475
|
+
if (this.regionTransitioned) {
|
|
476
|
+
this.regionTransitioned = false;
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
463
479
|
if (this.consecutiveFailures >= 3) return true;
|
|
464
480
|
if (this.consecutiveEmptyResults >= 2) return true;
|
|
465
481
|
if (iteration % this.progressCheckInterval !== 0) return false;
|
|
@@ -530,6 +546,12 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
530
546
|
const currentStateHash = currentState.hash;
|
|
531
547
|
|
|
532
548
|
const isNewUrl = this.previousUrl !== currentUrl;
|
|
549
|
+
const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
|
|
550
|
+
|
|
551
|
+
if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.present) {
|
|
552
|
+
this.regionTransitioned = true;
|
|
553
|
+
}
|
|
554
|
+
this.previousRegionPresent = currentState.overlay.present;
|
|
533
555
|
|
|
534
556
|
this.previousUrl = currentUrl;
|
|
535
557
|
this.previousStateHash = currentStateHash;
|
|
@@ -557,9 +579,11 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
557
579
|
|
|
558
580
|
if (focusArea.detected) {
|
|
559
581
|
const areaName = focusArea.name ? ` "${focusArea.name}"` : '';
|
|
582
|
+
let rootHint = '';
|
|
583
|
+
if (focusArea.root) rootHint = `\nIts content lives inside \`${focusArea.root}\` — scope locators to it.`;
|
|
560
584
|
context += dedent`
|
|
561
585
|
<focus_scope>
|
|
562
|
-
A ${focusArea.type}${areaName} is currently open above the page
|
|
586
|
+
A ${focusArea.type}${areaName} is currently open above the page.${rootHint}
|
|
563
587
|
Scope all interactions to elements inside this ${focusArea.type}.
|
|
564
588
|
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}.
|
|
565
589
|
Use <page_aria> to confirm the element you target is actually inside the ${focusArea.type}.
|
|
@@ -567,6 +591,18 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
567
591
|
`;
|
|
568
592
|
}
|
|
569
593
|
|
|
594
|
+
if (!focusArea.detected && focusArea.present && isNewState) {
|
|
595
|
+
let rootHint = '';
|
|
596
|
+
if (focusArea.root) rootHint = `\nIt lives inside \`${focusArea.root}\`.`;
|
|
597
|
+
context += dedent`
|
|
598
|
+
<area_of_interest>
|
|
599
|
+
A large new area "${focusArea.name || 'unnamed area'}" appeared on this page without navigation.${rootHint}
|
|
600
|
+
The scenario most likely continues inside this area — prefer its elements for your next actions.
|
|
601
|
+
The rest of the page (navigation, menus, filters) is still interactive and remains available.
|
|
602
|
+
</area_of_interest>
|
|
603
|
+
`;
|
|
604
|
+
}
|
|
605
|
+
|
|
570
606
|
if (currentState.isInsideIframe) {
|
|
571
607
|
const iframeInfo = currentState.iframeURL || 'iframe context active';
|
|
572
608
|
context += dedent`
|
|
@@ -589,7 +625,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
589
625
|
if (!alreadySeenUiMap) {
|
|
590
626
|
research = await this.researcher.research(currentState).catch(this.skipResearch);
|
|
591
627
|
}
|
|
592
|
-
this.pageStateHash =
|
|
628
|
+
this.pageStateHash = currentState.baseHash;
|
|
593
629
|
this.pageActionResult = currentState;
|
|
594
630
|
let uiMapSection = '';
|
|
595
631
|
if (research) {
|
|
@@ -627,7 +663,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
627
663
|
return context;
|
|
628
664
|
}
|
|
629
665
|
|
|
630
|
-
if (focusArea.
|
|
666
|
+
if (focusArea.present && focusArea.name && this.pageStateHash && this.pageActionResult) {
|
|
631
667
|
const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
|
|
632
668
|
if (overlaySection) {
|
|
633
669
|
context += dedent`
|
|
@@ -1172,4 +1208,5 @@ interface TestSessionHandlers {
|
|
|
1172
1208
|
|
|
1173
1209
|
export interface TestOptions {
|
|
1174
1210
|
startOnCurrentPage?: boolean;
|
|
1211
|
+
deadline?: number;
|
|
1175
1212
|
}
|
package/src/ai/tools.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { Stats } from '../stats.ts';
|
|
|
8
8
|
import { type Task, TestResult } from '../test-plan.js';
|
|
9
9
|
import { LARGE_ARIA_CHANGE_THRESHOLD } from '../utils/aria.ts';
|
|
10
10
|
import { isFatalBrowserError } from '../utils/browser-errors.ts';
|
|
11
|
+
import { cleanHtmlSnippet } from '../utils/html.ts';
|
|
11
12
|
import { createDebug, tag } from '../utils/logger.js';
|
|
12
|
-
import { compactErrorMessage } from '../utils/strings.ts';
|
|
13
|
+
import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/strings.ts';
|
|
13
14
|
import { pause } from '../utils/loop.js';
|
|
14
15
|
import { WebElement } from '../utils/web-element.ts';
|
|
15
16
|
import type { ToolDeps } from './agent.ts';
|
|
@@ -1332,20 +1333,23 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
|
|
|
1332
1333
|
}
|
|
1333
1334
|
|
|
1334
1335
|
const MAX_DISAMBIGUATE_ELEMENTS = 10;
|
|
1336
|
+
const MAX_DISAMBIGUATE_TEXT = 80;
|
|
1337
|
+
const MAX_DISAMBIGUATE_HTML = 300;
|
|
1335
1338
|
const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
|
|
1336
1339
|
|
|
1337
|
-
async function extractWebElements(error: Error | null | undefined): Promise<Array<{ xpath: string; html: string }> | null> {
|
|
1340
|
+
async function extractWebElements(error: Error | null | undefined): Promise<Array<{ xpath: string; html: string; text: string }> | null> {
|
|
1338
1341
|
if (!error || error.name !== 'MultipleElementsFound') return null;
|
|
1339
1342
|
|
|
1340
|
-
const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>;
|
|
1343
|
+
const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toOuterHTML: () => Promise<string>; getText: () => Promise<string | null> }> | undefined;
|
|
1341
1344
|
if (!elements?.length) return null;
|
|
1342
1345
|
|
|
1343
|
-
const result: Array<{ xpath: string; html: string }> = [];
|
|
1346
|
+
const result: Array<{ xpath: string; html: string; text: string }> = [];
|
|
1344
1347
|
for (let i = 0; i < Math.min(elements.length, MAX_DISAMBIGUATE_ELEMENTS); i++) {
|
|
1345
1348
|
try {
|
|
1346
1349
|
const xpath = await elements[i].toAbsoluteXPath();
|
|
1347
|
-
const html = await elements[i].
|
|
1348
|
-
|
|
1350
|
+
const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
|
|
1351
|
+
const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
|
|
1352
|
+
result.push({ xpath, html, text });
|
|
1349
1353
|
} catch (e) {
|
|
1350
1354
|
debugLog('Failed to get details for element %d: %s', i, e);
|
|
1351
1355
|
}
|
|
@@ -1353,17 +1357,21 @@ async function extractWebElements(error: Error | null | undefined): Promise<Arra
|
|
|
1353
1357
|
return result.length > 0 ? result : null;
|
|
1354
1358
|
}
|
|
1355
1359
|
|
|
1356
|
-
|
|
1360
|
+
function formatElementList(details: Array<{ xpath: string; html: string; text: string }>): string {
|
|
1361
|
+
return details.map((el, i) => `Element ${i + 1}:\nText: "${el.text}"\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
export async function formatMatchedElements(error: Error | null | undefined): Promise<string | null> {
|
|
1357
1365
|
const details = await extractWebElements(error);
|
|
1358
1366
|
if (!details) return 'Could not fetch element details. Repeat the action to get better info.';
|
|
1359
|
-
return details
|
|
1367
|
+
return formatElementList(details);
|
|
1360
1368
|
}
|
|
1361
1369
|
|
|
1362
1370
|
async function disambiguateElements(error: Error | null | undefined, explanation: string, provider: AIProvider): Promise<{ position: number; xpath: string } | null> {
|
|
1363
1371
|
const elementDetails = await extractWebElements(error);
|
|
1364
1372
|
if (!elementDetails) return null;
|
|
1365
1373
|
|
|
1366
|
-
const elementList = elementDetails
|
|
1374
|
+
const elementList = formatElementList(elementDetails);
|
|
1367
1375
|
|
|
1368
1376
|
const schema = z.object({
|
|
1369
1377
|
position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
|
|
@@ -85,6 +85,10 @@ export class RequestResult {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
get isWrite(): boolean {
|
|
89
|
+
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(this.method);
|
|
90
|
+
}
|
|
91
|
+
|
|
88
92
|
save(outputDir: string): void {
|
|
89
93
|
const requestsDir = path.join(outputDir, 'requests');
|
|
90
94
|
if (!existsSync(requestsDir)) {
|
|
@@ -169,7 +173,7 @@ export class RequestResult {
|
|
|
169
173
|
statusText: meta.statusText || '',
|
|
170
174
|
responseHeaders: meta.responseHeaders || {},
|
|
171
175
|
timing: Number.parseInt(meta.timing) || 0,
|
|
172
|
-
timestamp: new Date(meta.timestamp ||
|
|
176
|
+
timestamp: new Date(meta.timestamp || 0),
|
|
173
177
|
});
|
|
174
178
|
|
|
175
179
|
result.requestFile = requestFile;
|
|
@@ -178,8 +182,12 @@ export class RequestResult {
|
|
|
178
182
|
return result;
|
|
179
183
|
}
|
|
180
184
|
|
|
185
|
+
toEndpoint(): string {
|
|
186
|
+
return `${this.method} ${this.path}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
181
189
|
toSummary(): string {
|
|
182
|
-
return `${this.
|
|
190
|
+
return `${this.toEndpoint()} → ${this.status} (${this.timing}ms)`;
|
|
183
191
|
}
|
|
184
192
|
|
|
185
193
|
extractIdAndTitle(): { id?: string | number; title?: string } {
|