explorbot 0.1.27 → 0.1.29
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/README.md +83 -245
- package/bin/explorbot-cli.ts +1 -0
- package/dist/bin/explorbot-cli.js +1 -0
- package/dist/package.json +8 -6
- package/dist/rules/navigator/verification-actions.md +2 -0
- package/dist/src/action-result.js +2 -0
- package/dist/src/action.js +88 -7
- package/dist/src/ai/captain/file-tools.js +100 -0
- package/dist/src/ai/captain/idle-mode.js +70 -6
- package/dist/src/ai/captain/web-mode.js +36 -6
- package/dist/src/ai/captain.js +87 -19
- package/dist/src/ai/fisherman.js +14 -3
- package/dist/src/ai/historian/screencast.js +11 -2
- package/dist/src/ai/navigator.js +5 -2
- package/dist/src/ai/pilot.js +52 -9
- package/dist/src/ai/planner.js +16 -5
- package/dist/src/ai/provider.js +53 -18
- package/dist/src/ai/researcher/coordinates.js +2 -3
- package/dist/src/ai/researcher/deep-analysis.js +3 -4
- package/dist/src/ai/researcher/locators.js +1 -2
- package/dist/src/ai/researcher.js +24 -19
- package/dist/src/ai/rules.js +44 -0
- package/dist/src/ai/task-agent.js +1 -0
- package/dist/src/ai/tester.js +161 -46
- package/dist/src/ai/tools.js +84 -8
- package/dist/src/commands/explore-command.js +6 -1
- package/dist/src/components/LogPane.js +4 -3
- package/dist/src/explorbot.js +7 -2
- package/dist/src/explorer.js +270 -35
- package/dist/src/stats.js +16 -0
- package/dist/src/utils/aria.js +66 -6
- package/dist/src/utils/browser-errors.js +23 -0
- package/dist/src/utils/error-page.js +17 -2
- package/dist/src/utils/logger.js +2 -2
- package/package.json +8 -6
- package/rules/navigator/verification-actions.md +2 -0
- package/src/action-result.ts +2 -0
- package/src/action.ts +83 -7
- package/src/ai/captain/file-tools.ts +126 -0
- package/src/ai/captain/idle-mode.ts +72 -6
- package/src/ai/captain/mixin.ts +1 -1
- package/src/ai/captain/web-mode.ts +40 -5
- package/src/ai/captain.ts +94 -20
- package/src/ai/fisherman.ts +14 -3
- package/src/ai/historian/screencast.ts +11 -2
- package/src/ai/navigator.ts +6 -2
- package/src/ai/pilot.ts +53 -9
- package/src/ai/planner.ts +16 -5
- package/src/ai/provider.ts +51 -19
- package/src/ai/researcher/coordinates.ts +2 -3
- package/src/ai/researcher/deep-analysis.ts +3 -4
- package/src/ai/researcher/locators.ts +1 -2
- package/src/ai/researcher.ts +25 -19
- package/src/ai/rules.ts +46 -0
- package/src/ai/task-agent.ts +1 -1
- package/src/ai/tester.ts +175 -48
- package/src/ai/tools.ts +97 -8
- package/src/commands/explore-command.ts +6 -1
- package/src/components/LogPane.tsx +4 -3
- package/src/config.ts +1 -0
- package/src/explorbot.ts +6 -2
- package/src/explorer.ts +295 -38
- package/src/state-manager.ts +2 -0
- package/src/stats.ts +18 -0
- package/src/utils/aria.ts +63 -6
- package/src/utils/browser-errors.ts +25 -0
- package/src/utils/error-page.ts +16 -3
- package/src/utils/logger.ts +3 -3
package/dist/src/ai/fisherman.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createDebug, tag } from "../utils/logger.js";
|
|
|
4
4
|
const debugLog = createDebug('explorbot:fisherman');
|
|
5
5
|
import { loop } from "../utils/loop.js";
|
|
6
6
|
import { createFishermanTools } from "./fisherman-tools.js";
|
|
7
|
+
import { dataProtectionRules } from "./rules.js";
|
|
7
8
|
const MAX_ITERATIONS = 15;
|
|
8
9
|
const MAX_TOOL_ROUNDTRIPS = 5;
|
|
9
10
|
export class Fisherman {
|
|
@@ -65,7 +66,7 @@ export class Fisherman {
|
|
|
65
66
|
spec: this.spec,
|
|
66
67
|
baseEndpoint: this.baseEndpoint,
|
|
67
68
|
});
|
|
68
|
-
const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, scopeUrl), 'fisherman');
|
|
69
|
+
const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
|
|
69
70
|
conversation.addUserText(this.buildTaskPrompt(instructions));
|
|
70
71
|
await loop(async ({ stop, iteration }) => {
|
|
71
72
|
debugLog(`iteration ${iteration}`);
|
|
@@ -149,7 +150,7 @@ export class Fisherman {
|
|
|
149
150
|
}
|
|
150
151
|
return lines.join('\n');
|
|
151
152
|
}
|
|
152
|
-
buildSystemPrompt(endpointList, scopeUrl) {
|
|
153
|
+
buildSystemPrompt(endpointList, toolNames, scopeUrl) {
|
|
153
154
|
const scopeBlock = scopeUrl ? `\n\nSCOPE: You are operating within ${scopeUrl}.\nAll created items must belong to this scope.` : '';
|
|
154
155
|
return dedent `
|
|
155
156
|
You are Fisherman — a data preparation agent. You create test data by making API requests.
|
|
@@ -158,6 +159,11 @@ export class Fisherman {
|
|
|
158
159
|
${endpointList}
|
|
159
160
|
${scopeBlock}
|
|
160
161
|
|
|
162
|
+
AVAILABLE TOOLS:
|
|
163
|
+
${toolNames.join(', ')}.
|
|
164
|
+
Use tool names exactly as listed. Do not invent aliases, combined names, or names with channel markers such as "commentary".
|
|
165
|
+
Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
|
|
166
|
+
|
|
161
167
|
WORKFLOW:
|
|
162
168
|
1. Call getEndpointSpec to see the request body example for the endpoint
|
|
163
169
|
2. Make requests — the response automatically extracts IDs, names, and status fields
|
|
@@ -169,6 +175,8 @@ export class Fisherman {
|
|
|
169
175
|
- Chain requests logically — create parent resources before children
|
|
170
176
|
- If a request fails, try once more with adjusted data before reporting failure
|
|
171
177
|
- Use realistic but unique data for each item (vary names, titles)
|
|
178
|
+
|
|
179
|
+
${dataProtectionRules}
|
|
172
180
|
`;
|
|
173
181
|
}
|
|
174
182
|
buildTaskPrompt(instructions) {
|
|
@@ -177,7 +185,10 @@ export class Fisherman {
|
|
|
177
185
|
|
|
178
186
|
${instructions}
|
|
179
187
|
|
|
180
|
-
|
|
188
|
+
${dataProtectionRules}
|
|
189
|
+
|
|
190
|
+
If data preparation is allowed by these rules, execute the necessary API requests to create this data.
|
|
191
|
+
When done, call finish with the summary. If data preparation is forbidden, call stop with the reason.
|
|
181
192
|
`;
|
|
182
193
|
}
|
|
183
194
|
}
|
|
@@ -7,6 +7,7 @@ import { tag } from "../../utils/logger.js";
|
|
|
7
7
|
import { relativeToCwd } from "../../utils/next-steps.js";
|
|
8
8
|
import { safeFilename } from "../../utils/strings.js";
|
|
9
9
|
import { debugLog } from "./mixin.js";
|
|
10
|
+
const FATAL_SCREENCAST_STOP_ERRORS = /Target page, context or browser has been closed|Target closed|Session closed|Protocol error/i;
|
|
10
11
|
export function WithScreencast(Base) {
|
|
11
12
|
return class extends Base {
|
|
12
13
|
screencastPage = null;
|
|
@@ -100,18 +101,26 @@ export function WithScreencast(Base) {
|
|
|
100
101
|
return;
|
|
101
102
|
const path = this.screencastPath;
|
|
102
103
|
const task = this.screencastTask;
|
|
104
|
+
let stopped = false;
|
|
103
105
|
try {
|
|
104
106
|
await this.screencastPage.screencast.stop();
|
|
107
|
+
stopped = true;
|
|
105
108
|
}
|
|
106
109
|
catch (err) {
|
|
107
|
-
|
|
110
|
+
const message = err.message;
|
|
111
|
+
if (FATAL_SCREENCAST_STOP_ERRORS.test(message)) {
|
|
112
|
+
tag('operation').log('Screencast skipped: browser was closed before recording could be finalized');
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
tag('operation').log(`Screencast stop failed: ${message}`);
|
|
116
|
+
}
|
|
108
117
|
}
|
|
109
118
|
this.screencastActive = false;
|
|
110
119
|
this.screencastPage = null;
|
|
111
120
|
this.screencastPath = null;
|
|
112
121
|
this.screencastTask = null;
|
|
113
122
|
this.screencastLastChapter = null;
|
|
114
|
-
if (path) {
|
|
123
|
+
if (path && stopped) {
|
|
115
124
|
this.savedFiles.add(path);
|
|
116
125
|
task?.addArtifact?.(path);
|
|
117
126
|
tag('operation').log(`Saved screencast: ${relativeToCwd(path)}`);
|
package/dist/src/ai/navigator.js
CHANGED
|
@@ -121,6 +121,9 @@ class Navigator {
|
|
|
121
121
|
return normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
|
|
122
122
|
}
|
|
123
123
|
async visit(url) {
|
|
124
|
+
return this.explorer.runWithBrowserRecovery('navigator.visit', () => this.visitOnce(url));
|
|
125
|
+
}
|
|
126
|
+
async visitOnce(url) {
|
|
124
127
|
try {
|
|
125
128
|
const action = this.explorer.createAction();
|
|
126
129
|
await action.execute(`I.amOnPage('${url}')`);
|
|
@@ -150,7 +153,7 @@ class Navigator {
|
|
|
150
153
|
throw new Error(`Navigation to ${url} failed: ${action.lastError?.message}`);
|
|
151
154
|
}
|
|
152
155
|
}
|
|
153
|
-
await
|
|
156
|
+
await this.explorer.capturePageWithScreenshot();
|
|
154
157
|
await this.hooksRunner.runAfterHook('navigator', url);
|
|
155
158
|
}
|
|
156
159
|
catch (error) {
|
|
@@ -349,7 +352,7 @@ class Navigator {
|
|
|
349
352
|
// URL did not transition to expectedUrl within timeout
|
|
350
353
|
}
|
|
351
354
|
}
|
|
352
|
-
const freshState = await
|
|
355
|
+
const freshState = await this.explorer.capturePageState();
|
|
353
356
|
const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
|
|
354
357
|
const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
|
|
355
358
|
const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -10,6 +10,7 @@ import { ErrorPageError } from "../utils/error-page.js";
|
|
|
10
10
|
import { createDebug, tag } from "../utils/logger.js";
|
|
11
11
|
const debugLog = createDebug('explorbot:pilot');
|
|
12
12
|
import { truncateJson } from "../utils/strings.js";
|
|
13
|
+
import { capabilityGroundingRule, dataProtectionRules } from "./rules.js";
|
|
13
14
|
import { isInteractive } from "./task-agent.js";
|
|
14
15
|
const CHECK_TOOLS = ['verify', 'see', 'research', 'context'];
|
|
15
16
|
const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
|
|
@@ -50,7 +51,7 @@ export class Pilot {
|
|
|
50
51
|
return this.reviewDecision('finish', task, currentState, testerConversation, navigator);
|
|
51
52
|
}
|
|
52
53
|
async reviewCompletion(task, currentState, testerConversation, navigator) {
|
|
53
|
-
const verdictType =
|
|
54
|
+
const verdictType = this.hasCompletionEvidence(task, currentState, testerConversation) ? 'finish' : 'stop';
|
|
54
55
|
return this.reviewDecision(verdictType, task, currentState, testerConversation, navigator);
|
|
55
56
|
}
|
|
56
57
|
async finalReview(task, currentState, testerConversation, navigator) {
|
|
@@ -67,13 +68,13 @@ export class Pilot {
|
|
|
67
68
|
tag('substep').log(`Pilot reviewing ${type} verdict...`);
|
|
68
69
|
const sessionLog = this.formatSessionLog(testerConversation);
|
|
69
70
|
const stateContext = this.buildStateContext(currentState);
|
|
71
|
+
const successfulAssertions = this.formatSuccessfulAssertions(currentState, testerConversation);
|
|
70
72
|
const notes = task.notesToString() || 'No notes recorded.';
|
|
71
73
|
let visualAnalysis = '';
|
|
72
74
|
let screenshotState = null;
|
|
73
|
-
if (this.provider.hasVision()) {
|
|
75
|
+
if (type === 'finish' && this.provider.hasVision()) {
|
|
74
76
|
try {
|
|
75
|
-
|
|
76
|
-
screenshotState = await action.caputrePageWithScreenshot();
|
|
77
|
+
screenshotState = await this.explorer.capturePageWithScreenshot();
|
|
77
78
|
if (screenshotState.screenshot) {
|
|
78
79
|
visualAnalysis = (await this.researcher.answerQuestionAboutScreenshot(screenshotState, `Describe current page state relevant to: ${task.scenario}`)) || '';
|
|
79
80
|
}
|
|
@@ -102,6 +103,10 @@ export class Pilot {
|
|
|
102
103
|
|
|
103
104
|
${this.formatExpectations(task)}
|
|
104
105
|
|
|
106
|
+
<successful_assertions>
|
|
107
|
+
${successfulAssertions || 'None'}
|
|
108
|
+
</successful_assertions>
|
|
109
|
+
|
|
105
110
|
<notes>
|
|
106
111
|
${notes}
|
|
107
112
|
</notes>
|
|
@@ -135,7 +140,7 @@ export class Pilot {
|
|
|
135
140
|
try {
|
|
136
141
|
const response = await this.provider.generateObject(messages, schema, this.provider.getAgenticModel('pilot'), {
|
|
137
142
|
agentName: 'pilot',
|
|
138
|
-
|
|
143
|
+
telemetry: { functionId: 'pilot.reviewVerdict' },
|
|
139
144
|
});
|
|
140
145
|
const result = response?.object;
|
|
141
146
|
if (!result) {
|
|
@@ -225,7 +230,7 @@ export class Pilot {
|
|
|
225
230
|
try {
|
|
226
231
|
const response = await this.provider.generateObject(messages, schema, this.provider.getAgenticModel('pilot'), {
|
|
227
232
|
agentName: 'pilot',
|
|
228
|
-
|
|
233
|
+
telemetry: { functionId: 'pilot.reviewReset' },
|
|
229
234
|
});
|
|
230
235
|
const result = response?.object;
|
|
231
236
|
if (!result) {
|
|
@@ -329,6 +334,8 @@ export class Pilot {
|
|
|
329
334
|
You are Pilot — final decision maker for test pass/fail. Tester requested ${type}. Review the
|
|
330
335
|
evidence and commit to a verdict; "continue" only when evidence is genuinely insufficient.
|
|
331
336
|
|
|
337
|
+
${capabilityGroundingRule}
|
|
338
|
+
|
|
332
339
|
${this.buildSharedEvidenceRules(task)}
|
|
333
340
|
|
|
334
341
|
DECISION:
|
|
@@ -337,6 +344,8 @@ export class Pilot {
|
|
|
337
344
|
Pick assertions DOM can express; for non-DOM regions (iframes, canvas, Monaco/CodeMirror), target a
|
|
338
345
|
stable landmark (container, ARIA role) instead of literal inner text. Your "pass" stands even if the
|
|
339
346
|
DOM assertion can't be made.
|
|
347
|
+
Do not pass when Tester achieved only a related navigation/filter/tab/status outcome instead of the
|
|
348
|
+
requested action, workflow, or entity detail goal.
|
|
340
349
|
- "fail": scenario was attempted but the goal was not achieved.
|
|
341
350
|
- "skipped": scenario is irrelevant to the app, OR systematic infrastructure failures (LLM errors,
|
|
342
351
|
crashes) prevented testing. NOT for "test failed to interact" — that's "fail" or "continue".
|
|
@@ -370,6 +379,10 @@ export class Pilot {
|
|
|
370
379
|
|
|
371
380
|
FIRST: Decide if precondition() is needed.
|
|
372
381
|
|
|
382
|
+
${capabilityGroundingRule}
|
|
383
|
+
|
|
384
|
+
${dataProtectionRules}
|
|
385
|
+
|
|
373
386
|
Call precondition() WHEN:
|
|
374
387
|
- The scenario edits/deletes/modifies an item, and you want a DISPOSABLE item to act on safely
|
|
375
388
|
- The scenario needs specific data clearly NOT on the current page (e.g., items with specific statuses for filtering)
|
|
@@ -494,7 +507,7 @@ export class Pilot {
|
|
|
494
507
|
maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
|
|
495
508
|
agentName: 'pilot',
|
|
496
509
|
stopWhen: opts.task ? () => opts.task.hasFinished : undefined,
|
|
497
|
-
|
|
510
|
+
telemetry: { functionId },
|
|
498
511
|
});
|
|
499
512
|
const text = result?.response?.text || '';
|
|
500
513
|
const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => e.output.content);
|
|
@@ -587,8 +600,7 @@ export class Pilot {
|
|
|
587
600
|
async checkDataAvailability(task, requestedData, fishermanReason) {
|
|
588
601
|
if (!this.provider.hasVision())
|
|
589
602
|
return null;
|
|
590
|
-
const
|
|
591
|
-
const screenshotState = await action.caputrePageWithScreenshot().catch(() => null);
|
|
603
|
+
const screenshotState = await this.explorer.capturePageWithScreenshot().catch(() => null);
|
|
592
604
|
if (!screenshotState?.screenshot)
|
|
593
605
|
return null;
|
|
594
606
|
const question = dedent `
|
|
@@ -791,6 +803,31 @@ export class Pilot {
|
|
|
791
803
|
}
|
|
792
804
|
return parts.join('\n\n');
|
|
793
805
|
}
|
|
806
|
+
hasCompletionEvidence(task, currentState, testerConversation) {
|
|
807
|
+
if (task.hasAchievedAny())
|
|
808
|
+
return true;
|
|
809
|
+
return this.hasSuccessfulCheckEvidence(currentState, testerConversation);
|
|
810
|
+
}
|
|
811
|
+
hasSuccessfulCheckEvidence(currentState, testerConversation) {
|
|
812
|
+
if (Object.values(currentState.verifications ?? {}).some(Boolean))
|
|
813
|
+
return true;
|
|
814
|
+
return testerConversation.getToolExecutions().some((t) => CHECK_TOOLS.includes(t.toolName) && t.wasSuccessful);
|
|
815
|
+
}
|
|
816
|
+
formatSuccessfulAssertions(currentState, testerConversation) {
|
|
817
|
+
const lines = [];
|
|
818
|
+
for (const [assertion, passed] of Object.entries(currentState.verifications ?? {})) {
|
|
819
|
+
if (passed)
|
|
820
|
+
lines.push(`PASS state verification: ${assertion}`);
|
|
821
|
+
}
|
|
822
|
+
for (const exec of testerConversation.getToolExecutions()) {
|
|
823
|
+
if (!CHECK_TOOLS.includes(exec.toolName) || !exec.wasSuccessful)
|
|
824
|
+
continue;
|
|
825
|
+
const description = exec.input?.assertion || exec.input?.request || truncateJson(exec.input);
|
|
826
|
+
const result = exec.output?.message || exec.output?.analysis || exec.output?.result;
|
|
827
|
+
lines.push(`PASS ${exec.toolName}: ${description}${result ? ` -> ${result}` : ''}`);
|
|
828
|
+
}
|
|
829
|
+
return [...new Set(lines)].join('\n');
|
|
830
|
+
}
|
|
794
831
|
formatActions(toolCalls) {
|
|
795
832
|
return toolCalls
|
|
796
833
|
.map((t) => {
|
|
@@ -902,9 +939,13 @@ export class Pilot {
|
|
|
902
939
|
|
|
903
940
|
Tester tools: click, pressKey, form, see, verify, context, research, xpathCheck, visualClick,
|
|
904
941
|
back, getVisitedStates, reset, stop, finish, record.
|
|
942
|
+
Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
|
|
943
|
+
|
|
944
|
+
${capabilityGroundingRule}
|
|
905
945
|
|
|
906
946
|
YOUR Pilot-only tool: precondition(description) — create FRESH disposable test data via API. Never
|
|
907
947
|
request users. Use when:
|
|
948
|
+
|
|
908
949
|
- Scenario edits/deletes/modifies an item → create a disposable target ("1 post").
|
|
909
950
|
- Scenario needs auxiliary data (labels, categories, statuses for filtering).
|
|
910
951
|
- Tester failed because required data is missing (empty dropdown, empty list).
|
|
@@ -914,6 +955,8 @@ export class Pilot {
|
|
|
914
955
|
- Current page already shows the exact data needed.
|
|
915
956
|
- Scenario tests navigation, search UI, or viewing.
|
|
916
957
|
|
|
958
|
+
${dataProtectionRules}
|
|
959
|
+
|
|
917
960
|
Describe WHAT to create, not what exists. RIGHT: precondition("1 test"). WRONG:
|
|
918
961
|
precondition("1 test suite named Updated Suite with existing tests"). Keep descriptions short.
|
|
919
962
|
|
package/dist/src/ai/planner.js
CHANGED
|
@@ -19,7 +19,7 @@ import { WithSubPages, getPlannedByStateHash, getRegisteredPlan, registerPlan }
|
|
|
19
19
|
import { POSSIBLE_SECTIONS, Researcher } from "./researcher.js";
|
|
20
20
|
import { findSimilarStateHash } from "./researcher/cache.js";
|
|
21
21
|
import { hasFocusedSection } from "./researcher/focus.js";
|
|
22
|
-
import {
|
|
22
|
+
import { capabilityGroundingRule, dataProtectionRules, fileUploadRule } from "./rules.js";
|
|
23
23
|
const debugLog = createDebug('explorbot:planner');
|
|
24
24
|
const TasksSchema = z.object({
|
|
25
25
|
planName: z.string().describe('Short descriptive name for the test plan (e.g., "User Authentication Testing", "Product Catalog Navigation", "Form Validation Tests")'),
|
|
@@ -27,7 +27,7 @@ const TasksSchema = z.object({
|
|
|
27
27
|
.array(z.object({
|
|
28
28
|
scenario: z.string().describe('A single sentence describing what to test'),
|
|
29
29
|
priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
|
|
30
|
-
startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL
|
|
30
|
+
startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL. Use only stable feature/list/detail pages, not transient create/edit/modal URLs unless the scenario specifically starts inside that form.'),
|
|
31
31
|
steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Open the form", "Enter required data", "Submit the form"). Keep steps atomic and actionable.'),
|
|
32
32
|
expectedOutcomes: z
|
|
33
33
|
.array(z.string())
|
|
@@ -73,6 +73,9 @@ export class Planner extends PlannerBase {
|
|
|
73
73
|
const featureDirective = feature
|
|
74
74
|
? `\n IMPORTANT: The user requested to focus specifically on: "${feature}"\n ALL scenarios MUST be directly related to this feature. Do not propose generic page tests unrelated to it.\n Use the user's exact wording to guide scenario names — do not substitute different entities (e.g., do not plan "suite" actions when user said "test").`
|
|
75
75
|
: '';
|
|
76
|
+
const focusExistingDataDirective = feature
|
|
77
|
+
? '\n If this focus asks for search, filter, tabs, sorting, or list behavior involving existing items, only use item names/values visible in the provided page research. If no concrete visible item names/values are present, do NOT propose scenarios that require an existing known item; propose no-match search, empty-state, clear-search, tab/filter empty-list, or other read-only list behavior instead.'
|
|
78
|
+
: '';
|
|
76
79
|
return dedent `
|
|
77
80
|
<role>
|
|
78
81
|
You are ISTQB certified senior manual QA planning exploratory testing session of a web application.
|
|
@@ -96,7 +99,7 @@ export class Planner extends PlannerBase {
|
|
|
96
99
|
Bad: "Open delete dropdown" + "Confirm deletion" — these are ONE test, not two.
|
|
97
100
|
Bad: "Search for X" + "Verify search results" — searching and verifying is ONE test.
|
|
98
101
|
Bad: "Leave field empty" + "Click submit" — that's one negative test, not two.
|
|
99
|
-
If two scenarios cannot run independently (one requires the other to run first), merge them into one.${featureDirective}
|
|
102
|
+
If two scenarios cannot run independently (one requires the other to run first), merge them into one.${featureDirective}${focusExistingDataDirective}
|
|
100
103
|
</task>
|
|
101
104
|
|
|
102
105
|
${customPrompt || ''}
|
|
@@ -301,6 +304,11 @@ export class Planner extends PlannerBase {
|
|
|
301
304
|
If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible or API preconditions can create it.
|
|
302
305
|
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.
|
|
303
306
|
Do not assume hidden data exists just because a control is present.
|
|
307
|
+
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.
|
|
308
|
+
If the list is empty or no concrete item names are visible, do not invent "known" or "existing" items. Prefer empty-state, no-match search, clear-search, or read-only list behavior scenarios.
|
|
309
|
+
Search, filter, sorting, tab, and list scenarios must start from a stable page where those controls are visible; avoid transient create/edit/new URLs unless the scenario tests that form.
|
|
310
|
+
For option values and list items, use only visible or previously observed data; do not add create/update/delete setup unless the user explicitly requests that workflow.
|
|
311
|
+
Detail-view scenarios must target visible data entities from list rows, cards, tree nodes, or detail links; do not use filter tabs, counters, status tabs, breadcrumbs, or navigation controls as detail targets.
|
|
304
312
|
DO NOT propose "verification-only" tests that merely open a UI element (modal, dropdown, panel) and check it exists.
|
|
305
313
|
Every test must complete a meaningful action that changes application state or produces a business outcome.
|
|
306
314
|
Opening a modal is NOT a test — performing an action INSIDE the modal IS a test.
|
|
@@ -311,7 +319,8 @@ export class Planner extends PlannerBase {
|
|
|
311
319
|
Tests that only switch views, toggle filters, or paginate are LESS valuable — propose them only after data-changing tests are covered.
|
|
312
320
|
If multiple ways to create or modify data exist (different types, different forms), propose a separate test for each.
|
|
313
321
|
</priority_order>
|
|
314
|
-
${
|
|
322
|
+
${capabilityGroundingRule}
|
|
323
|
+
${dataProtectionRules}
|
|
315
324
|
${fileUploadRule}
|
|
316
325
|
</rules>
|
|
317
326
|
|
|
@@ -466,7 +475,9 @@ export class Planner extends PlannerBase {
|
|
|
466
475
|
.join('\n')}
|
|
467
476
|
|
|
468
477
|
You MAY propose tests starting from these pages if they are relevant to the plan "${this.currentPlan.title}".
|
|
469
|
-
Set startUrl for such tests
|
|
478
|
+
Set startUrl for such tests only when the page is a stable feature/list/detail page.
|
|
479
|
+
Do not use create/edit/new/modal URLs as startUrl for scenarios that need the underlying page.
|
|
480
|
+
Ignore pages that belong to a different feature area.
|
|
470
481
|
</context_from_previous_tests>
|
|
471
482
|
|
|
472
483
|
Propose ONLY new scenarios that are NOT in the existing tests list.
|
package/dist/src/ai/provider.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { OpenTelemetry } from '@ai-sdk/otel';
|
|
1
2
|
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
2
3
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
3
|
-
import { generateObject, generateText,
|
|
4
|
+
import { generateObject, generateText, isStepCount, registerTelemetry } from 'ai';
|
|
4
5
|
import { clearActivity, setActivity } from "../activity.js";
|
|
5
6
|
import { executionController } from "../execution-controller.js";
|
|
6
7
|
import { Observability } from "../observability.js";
|
|
@@ -16,10 +17,11 @@ class AiError extends Error {
|
|
|
16
17
|
}
|
|
17
18
|
export class ContextLengthError extends Error {
|
|
18
19
|
}
|
|
20
|
+
let telemetryRegistered = false;
|
|
19
21
|
function extractCachedTokens(usage) {
|
|
20
22
|
if (!usage)
|
|
21
23
|
return 0;
|
|
22
|
-
const direct = usage.
|
|
24
|
+
const direct = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens;
|
|
23
25
|
if (typeof direct === 'number')
|
|
24
26
|
return direct;
|
|
25
27
|
const raw = usage.raw;
|
|
@@ -78,7 +80,7 @@ export class Provider {
|
|
|
78
80
|
await generateText({
|
|
79
81
|
model: this.config.model,
|
|
80
82
|
prompt: 'hi',
|
|
81
|
-
|
|
83
|
+
maxOutputTokens: 1,
|
|
82
84
|
});
|
|
83
85
|
}
|
|
84
86
|
catch (error) {
|
|
@@ -100,6 +102,18 @@ export class Provider {
|
|
|
100
102
|
}
|
|
101
103
|
return this.config.agenticModel || this.config.model;
|
|
102
104
|
}
|
|
105
|
+
getConfiguredModels() {
|
|
106
|
+
const models = { model: this.getModelName(this.config.model) };
|
|
107
|
+
if (this.config.agenticModel)
|
|
108
|
+
models.agenticModel = this.getModelName(this.config.agenticModel);
|
|
109
|
+
if (this.config.visionModel)
|
|
110
|
+
models.visionModel = this.getModelName(this.config.visionModel);
|
|
111
|
+
for (const [agent, agentConfig] of Object.entries(this.config.agents || {})) {
|
|
112
|
+
if (agentConfig?.model)
|
|
113
|
+
models[agent] = this.getModelName(agentConfig.model);
|
|
114
|
+
}
|
|
115
|
+
return models;
|
|
116
|
+
}
|
|
103
117
|
getSystemPromptForAgent(agentName, currentUrl) {
|
|
104
118
|
const agentConfig = this.config.agents?.[agentName];
|
|
105
119
|
const parts = [];
|
|
@@ -116,6 +130,12 @@ export class Provider {
|
|
|
116
130
|
const agentConfig = this.config.agents?.[agentName];
|
|
117
131
|
return agentConfig?.providerOptions;
|
|
118
132
|
}
|
|
133
|
+
getReasoningForAgent(agentName) {
|
|
134
|
+
if (!agentName)
|
|
135
|
+
return undefined;
|
|
136
|
+
const agentConfig = this.config.agents?.[agentName];
|
|
137
|
+
return agentConfig?.reasoning;
|
|
138
|
+
}
|
|
119
139
|
getRetryOptions(options = {}) {
|
|
120
140
|
return {
|
|
121
141
|
...this.defaultRetryOptions,
|
|
@@ -133,6 +153,13 @@ export class Provider {
|
|
|
133
153
|
providerOptions: { ...config.providerOptions, ...agentOptions },
|
|
134
154
|
};
|
|
135
155
|
}
|
|
156
|
+
finalizeConfig(config, options, telemetry) {
|
|
157
|
+
if (telemetry)
|
|
158
|
+
config.telemetry = telemetry;
|
|
159
|
+
const reasoning = this.getReasoningForAgent(options.agentName);
|
|
160
|
+
if (reasoning)
|
|
161
|
+
config.reasoning ??= reasoning;
|
|
162
|
+
}
|
|
136
163
|
initLangfuse() {
|
|
137
164
|
const langfuseConfig = this.config.langfuse;
|
|
138
165
|
const publicKey = langfuseConfig?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
@@ -152,6 +179,10 @@ export class Provider {
|
|
|
152
179
|
instrumentations: [],
|
|
153
180
|
});
|
|
154
181
|
void this.otelSdk.start();
|
|
182
|
+
if (!telemetryRegistered) {
|
|
183
|
+
registerTelemetry(new OpenTelemetry());
|
|
184
|
+
telemetryRegistered = true;
|
|
185
|
+
}
|
|
155
186
|
this.telemetryEnabled = true;
|
|
156
187
|
}
|
|
157
188
|
getTelemetry(options) {
|
|
@@ -159,18 +190,18 @@ export class Provider {
|
|
|
159
190
|
return undefined;
|
|
160
191
|
}
|
|
161
192
|
const runTelemetry = Observability.getTelemetry();
|
|
162
|
-
if (!options.
|
|
163
|
-
return runTelemetry
|
|
193
|
+
if (!options.telemetry) {
|
|
194
|
+
return runTelemetry;
|
|
164
195
|
}
|
|
165
196
|
if (!runTelemetry) {
|
|
166
|
-
return options.
|
|
197
|
+
return options.telemetry;
|
|
167
198
|
}
|
|
168
199
|
return {
|
|
169
200
|
...runTelemetry,
|
|
170
|
-
...options.
|
|
201
|
+
...options.telemetry,
|
|
171
202
|
metadata: {
|
|
172
203
|
...runTelemetry.metadata,
|
|
173
|
-
...options.
|
|
204
|
+
...options.telemetry.metadata,
|
|
174
205
|
},
|
|
175
206
|
};
|
|
176
207
|
}
|
|
@@ -210,13 +241,14 @@ export class Provider {
|
|
|
210
241
|
promptLog(`Using model: ${modelName}`);
|
|
211
242
|
const telemetry = this.getTelemetry(options);
|
|
212
243
|
const config = this.mergeProviderOptions({
|
|
213
|
-
|
|
244
|
+
maxOutputTokens: 16384,
|
|
245
|
+
allowSystemInMessages: true,
|
|
214
246
|
...(this.config.config || {}),
|
|
215
247
|
...options,
|
|
216
|
-
...(telemetry ? { experimental_telemetry: telemetry } : {}),
|
|
217
248
|
model,
|
|
218
249
|
abortSignal: executionController.getAbortSignal(),
|
|
219
250
|
}, options.agentName);
|
|
251
|
+
this.finalizeConfig(config, options, telemetry);
|
|
220
252
|
promptLog(messages[messages.length - 1].content);
|
|
221
253
|
try {
|
|
222
254
|
const response = await withRetry(async () => {
|
|
@@ -224,7 +256,7 @@ export class Provider {
|
|
|
224
256
|
if (!result.text) {
|
|
225
257
|
debugLog(result);
|
|
226
258
|
if (result.finishReason === 'length') {
|
|
227
|
-
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase
|
|
259
|
+
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
|
|
228
260
|
}
|
|
229
261
|
throw new Error('No response text from AI');
|
|
230
262
|
}
|
|
@@ -277,21 +309,22 @@ export class Provider {
|
|
|
277
309
|
const telemetry = this.getTelemetry(options);
|
|
278
310
|
const maxRoundtrips = options.maxToolRoundtrips ?? 5;
|
|
279
311
|
const extraStop = options.stopWhen;
|
|
280
|
-
const stopConditions = [
|
|
312
|
+
const stopConditions = [isStepCount(maxRoundtrips)];
|
|
281
313
|
if (extraStop)
|
|
282
314
|
stopConditions.push(extraStop);
|
|
283
315
|
const { stopWhen: _ignoredStopWhen, ...optionsWithoutStop } = options;
|
|
284
316
|
const config = this.mergeProviderOptions({
|
|
285
317
|
tools,
|
|
286
|
-
|
|
318
|
+
maxOutputTokens: 16384,
|
|
287
319
|
toolChoice: 'auto',
|
|
320
|
+
allowSystemInMessages: true,
|
|
288
321
|
...(this.config.config || {}),
|
|
289
322
|
...optionsWithoutStop,
|
|
290
323
|
stopWhen: stopConditions,
|
|
291
|
-
...(telemetry ? { experimental_telemetry: telemetry } : {}),
|
|
292
324
|
model,
|
|
293
325
|
abortSignal: executionController.getAbortSignal(),
|
|
294
326
|
}, options.agentName);
|
|
327
|
+
this.finalizeConfig(config, options, telemetry);
|
|
295
328
|
try {
|
|
296
329
|
const response = await withRetry(async () => {
|
|
297
330
|
const timeout = config.timeout || 30000;
|
|
@@ -306,7 +339,7 @@ export class Provider {
|
|
|
306
339
|
]));
|
|
307
340
|
const hasToolCall = (result.toolCalls?.length || 0) > 0;
|
|
308
341
|
if (!result.text && !hasToolCall && result.finishReason === 'length') {
|
|
309
|
-
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase
|
|
342
|
+
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
|
|
310
343
|
}
|
|
311
344
|
return result;
|
|
312
345
|
}
|
|
@@ -365,12 +398,13 @@ export class Provider {
|
|
|
365
398
|
const telemetry = this.getTelemetry(options);
|
|
366
399
|
const config = this.mergeProviderOptions({
|
|
367
400
|
schema,
|
|
401
|
+
allowSystemInMessages: true,
|
|
368
402
|
...(this.config.config || {}),
|
|
369
403
|
...options,
|
|
370
|
-
...(telemetry ? { experimental_telemetry: telemetry } : {}),
|
|
371
404
|
model: modelToUse,
|
|
372
405
|
abortSignal: executionController.getAbortSignal(),
|
|
373
406
|
}, options.agentName);
|
|
407
|
+
this.finalizeConfig(config, options, telemetry);
|
|
374
408
|
try {
|
|
375
409
|
promptLog(messages[messages.length - 1].content);
|
|
376
410
|
const response = await withRetry(async () => {
|
|
@@ -552,12 +586,13 @@ export class Provider {
|
|
|
552
586
|
];
|
|
553
587
|
const telemetry = this.getTelemetry({});
|
|
554
588
|
const config = {
|
|
555
|
-
|
|
589
|
+
maxOutputTokens: 16384,
|
|
556
590
|
...(this.config.config || {}),
|
|
557
|
-
...(telemetry ? { experimental_telemetry: telemetry } : {}),
|
|
558
591
|
model: this.config.visionModel,
|
|
559
592
|
abortSignal: executionController.getAbortSignal(),
|
|
560
593
|
};
|
|
594
|
+
if (telemetry)
|
|
595
|
+
config.telemetry = telemetry;
|
|
561
596
|
try {
|
|
562
597
|
promptLog(`Processing image with prompt: ${prompt}`);
|
|
563
598
|
const response = await withRetry(async () => {
|
|
@@ -67,7 +67,7 @@ export function WithCoordinates(Base) {
|
|
|
67
67
|
return this._analyzeScreenshotForVisualProps();
|
|
68
68
|
}
|
|
69
69
|
async visuallyAnnotateElements(opts) {
|
|
70
|
-
return
|
|
70
|
+
return this.explorer.visuallyAnnotateElements(opts);
|
|
71
71
|
}
|
|
72
72
|
async _analyzeScreenshotForVisualProps() {
|
|
73
73
|
const elements = new Map();
|
|
@@ -177,7 +177,6 @@ export function WithCoordinates(Base) {
|
|
|
177
177
|
debugLog(`Merged visual props for ${merged} elements`);
|
|
178
178
|
}
|
|
179
179
|
async backfillCoordinates(result) {
|
|
180
|
-
const page = this.explorer.playwrightHelper.page;
|
|
181
180
|
const sections = parseResearchSections(result.text);
|
|
182
181
|
const eidxWithoutCoords = [];
|
|
183
182
|
for (const section of sections) {
|
|
@@ -188,7 +187,7 @@ export function WithCoordinates(Base) {
|
|
|
188
187
|
}
|
|
189
188
|
if (eidxWithoutCoords.length === 0)
|
|
190
189
|
return;
|
|
191
|
-
const webElements = await WebElement.fromEidxList(page, eidxWithoutCoords);
|
|
190
|
+
const webElements = await this.explorer.runWithBrowserRecovery('backfillCoordinates', () => WebElement.fromEidxList(this.explorer.playwrightHelper.page, eidxWithoutCoords));
|
|
192
191
|
if (webElements.length === 0)
|
|
193
192
|
return;
|
|
194
193
|
const rectMap = new Map(webElements.map((w) => [w.eidx, w]));
|
|
@@ -312,10 +312,9 @@ export function WithDeepAnalysis(Base) {
|
|
|
312
312
|
const isCoordinateClick = el.commands[0].startsWith('I.clickXY(');
|
|
313
313
|
if (!isCoordinateClick) {
|
|
314
314
|
const hoverCmd = el.commands[0].replace('I.click(', 'I.moveCursorTo(');
|
|
315
|
-
|
|
316
|
-
await hoverAction.attempt(hoverCmd, undefined, false);
|
|
315
|
+
await this.explorer.attemptAction(hoverCmd, undefined, false);
|
|
317
316
|
await new Promise((r) => setTimeout(r, 500));
|
|
318
|
-
await this.explorer.
|
|
317
|
+
await this.explorer.capturePageState();
|
|
319
318
|
const hoverAR = ActionResult.fromState(this.stateManager.getCurrentState());
|
|
320
319
|
const hoverDiff = await hoverAR.diff(previousState);
|
|
321
320
|
await hoverDiff.calculate();
|
|
@@ -398,7 +397,7 @@ export function WithDeepAnalysis(Base) {
|
|
|
398
397
|
async _restorePageState(url, originalAria) {
|
|
399
398
|
try {
|
|
400
399
|
await this.cancelInUi();
|
|
401
|
-
await this.explorer.
|
|
400
|
+
await this.explorer.capturePageState();
|
|
402
401
|
const currentAria = this.stateManager.getCurrentState()?.ariaSnapshot || '';
|
|
403
402
|
if (!diffAriaSnapshots(originalAria, currentAria))
|
|
404
403
|
return;
|
|
@@ -173,8 +173,7 @@ export function WithLocators(Base) {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
if (needsXpath.length > 0) {
|
|
176
|
-
const
|
|
177
|
-
const webElements = await WebElement.fromEidxList(page, needsXpath);
|
|
176
|
+
const webElements = await this.explorer.runWithBrowserRecovery('backfillBrokenLocators', () => WebElement.fromEidxList(this.explorer.playwrightHelper.page, needsXpath));
|
|
178
177
|
const changedSections = new Set();
|
|
179
178
|
for (const w of webElements) {
|
|
180
179
|
const entry = needsXpathEls.get(w.eidx);
|