explorbot 0.1.28 → 0.1.30
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/boat/doc-collector/src/ai/documentarian.ts +37 -13
- package/boat/doc-collector/src/ai/tools.ts +60 -20
- package/boat/doc-collector/src/cli.ts +3 -0
- package/boat/doc-collector/src/config.ts +7 -0
- package/boat/doc-collector/src/docbot.ts +23 -5
- package/boat/doc-collector/src/docs-renderer.ts +14 -1
- package/boat/doc-collector/src/screenshots.ts +126 -0
- package/dist/bin/explorbot-cli.js +1 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +15 -11
- package/dist/boat/doc-collector/src/ai/tools.js +53 -20
- package/dist/boat/doc-collector/src/cli.js +3 -0
- package/dist/boat/doc-collector/src/config.js +3 -0
- package/dist/boat/doc-collector/src/docbot.js +19 -4
- package/dist/boat/doc-collector/src/docs-renderer.js +12 -1
- package/dist/boat/doc-collector/src/screenshots.js +90 -0
- package/dist/package.json +8 -6
- package/dist/rules/navigator/verification-actions.md +2 -0
- package/dist/src/action.js +26 -23
- package/dist/src/ai/fisherman.js +14 -3
- package/dist/src/ai/historian/codeceptjs.js +3 -2
- package/dist/src/ai/historian/experience.js +48 -6
- package/dist/src/ai/historian/playwright.js +2 -1
- package/dist/src/ai/historian/utils.js +1 -19
- package/dist/src/ai/historian.js +1 -1
- package/dist/src/ai/pilot.js +19 -4
- package/dist/src/ai/planner.js +16 -5
- package/dist/src/ai/provider.js +53 -18
- package/dist/src/ai/quartermaster.js +2 -2
- package/dist/src/ai/researcher.js +7 -1
- package/dist/src/ai/rules.js +44 -0
- package/dist/src/ai/tester.js +73 -7
- package/dist/src/ai/tools.js +66 -1
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +14 -3
- package/dist/src/explorer.js +30 -27
- package/dist/src/stats.js +16 -0
- package/dist/src/utils/aria.js +66 -6
- package/dist/src/utils/browser-errors.js +5 -0
- package/dist/src/utils/page-readiness.js +48 -0
- package/dist/src/utils/step-analyzer.js +68 -0
- package/package.json +8 -6
- package/rules/navigator/verification-actions.md +2 -0
- package/src/action.ts +24 -26
- package/src/ai/fisherman.ts +14 -3
- package/src/ai/historian/codeceptjs.ts +3 -2
- package/src/ai/historian/experience.ts +51 -6
- package/src/ai/historian/playwright.ts +2 -1
- package/src/ai/historian/utils.ts +1 -21
- package/src/ai/historian.ts +1 -1
- package/src/ai/pilot.ts +19 -4
- package/src/ai/planner.ts +16 -5
- package/src/ai/provider.ts +51 -19
- package/src/ai/quartermaster.ts +2 -2
- package/src/ai/researcher.ts +8 -1
- package/src/ai/rules.ts +46 -0
- package/src/ai/tester.ts +77 -7
- package/src/ai/tools.ts +79 -1
- package/src/config.ts +2 -0
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +13 -3
- package/src/explorer.ts +28 -27
- package/src/stats.ts +18 -0
- package/src/utils/aria.ts +63 -6
- package/src/utils/browser-errors.ts +6 -0
- package/src/utils/page-readiness.ts +59 -0
- package/src/utils/step-analyzer.ts +73 -0
|
@@ -6,12 +6,12 @@ import type { Reporter, ReporterStep } from '../../reporter.ts';
|
|
|
6
6
|
import type { StateManager } from '../../state-manager.ts';
|
|
7
7
|
import { type Task, Test } from '../../test-plan.ts';
|
|
8
8
|
import { tag } from '../../utils/logger.ts';
|
|
9
|
+
import { isCodeceptToolName, isNonReusableCode, mergeUniqueStepsByCode, stripComments, toReusableSessionStep } from '../../utils/step-analyzer.ts';
|
|
9
10
|
import { extractStatePath } from '../../utils/url-matcher.ts';
|
|
10
11
|
import type { Conversation, ToolExecution } from '../conversation.ts';
|
|
11
12
|
import type { Provider } from '../provider.ts';
|
|
12
|
-
import { CODECEPT_TOOLS } from '../tools.ts';
|
|
13
13
|
import { type Constructor, debugLog } from './mixin.ts';
|
|
14
|
-
import { getExecutionLabel
|
|
14
|
+
import { getExecutionLabel } from './utils.ts';
|
|
15
15
|
|
|
16
16
|
export interface ExperienceMethods {
|
|
17
17
|
saveSession(task: Task, initialState: ActionResult, conversation: Conversation): Promise<void>;
|
|
@@ -38,12 +38,18 @@ export function WithExperience<T extends Constructor>(Base: T) {
|
|
|
38
38
|
task.generatedCode = this.isPlaywrightFramework() ? await this.toPlaywrightCode(conversation, task.description) : this.toCode(conversation, task.description);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const
|
|
41
|
+
const conversationSteps = await this.extractSteps(toolExecutions);
|
|
42
|
+
const taskSteps = this.extractPassedCodeceptSteps(task);
|
|
43
|
+
const steps = mergeUniqueStepsByCode(conversationSteps, taskSteps);
|
|
42
44
|
|
|
43
45
|
const skipExperience = result === 'failed' || (task instanceof Test && (task.hasFailed || task.isSkipped));
|
|
44
46
|
if (!skipExperience) {
|
|
47
|
+
const hasExistingFlow = this.hasRelevantFlowExperience(initialState);
|
|
45
48
|
await this.detectRetryPatterns(toolExecutions, initialState);
|
|
46
|
-
|
|
49
|
+
let body = await this.curateFlow(steps, task, initialState);
|
|
50
|
+
if (!body.trim() && !hasExistingFlow) {
|
|
51
|
+
body = this.renderFlowFromSuccessfulSteps(steps, task);
|
|
52
|
+
}
|
|
47
53
|
if (body.trim()) {
|
|
48
54
|
const relatedUrls = this.extractVisitedUrls(toolExecutions, initialState.url || '');
|
|
49
55
|
this.experienceTracker.writeFlow(initialState, body, relatedUrls);
|
|
@@ -76,7 +82,7 @@ export function WithExperience<T extends Constructor>(Base: T) {
|
|
|
76
82
|
const stepsWithDiffs: Array<{ step: SessionStep; ariaDiff: string | null }> = [];
|
|
77
83
|
|
|
78
84
|
for (const exec of toolExecutions) {
|
|
79
|
-
if (!
|
|
85
|
+
if (!isCodeceptToolName(exec.toolName)) continue;
|
|
80
86
|
if (!exec.output?.code) continue;
|
|
81
87
|
if (!exec.wasSuccessful) continue;
|
|
82
88
|
if (isNonReusableCode(exec.output.code)) continue;
|
|
@@ -96,6 +102,19 @@ export function WithExperience<T extends Constructor>(Base: T) {
|
|
|
96
102
|
return stepsWithDiffs.map((s) => s.step);
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
private hasRelevantFlowExperience(state: ActionResult): boolean {
|
|
106
|
+
return this.experienceTracker.getRelevantExperience(state).some((experience) => experience.content.includes('## FLOW:'));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private extractPassedCodeceptSteps(task: Task): SessionStep[] {
|
|
110
|
+
const steps: SessionStep[] = [];
|
|
111
|
+
for (const step of Object.values(task.steps)) {
|
|
112
|
+
const sessionStep = toReusableSessionStep(step);
|
|
113
|
+
if (sessionStep) steps.push(sessionStep);
|
|
114
|
+
}
|
|
115
|
+
return steps;
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
private async curateFlow(steps: SessionStep[], task: Task, initialState: ActionResult): Promise<string> {
|
|
100
119
|
if (steps.length === 0) return '';
|
|
101
120
|
|
|
@@ -207,6 +226,32 @@ export function WithExperience<T extends Constructor>(Base: T) {
|
|
|
207
226
|
}
|
|
208
227
|
}
|
|
209
228
|
|
|
229
|
+
private renderFlowFromSuccessfulSteps(steps: SessionStep[], task: Task): string {
|
|
230
|
+
if (steps.length === 0) return '';
|
|
231
|
+
|
|
232
|
+
const title = task.description.charAt(0).toLowerCase() + task.description.slice(1);
|
|
233
|
+
const blocks = steps
|
|
234
|
+
.filter((step) => step.code)
|
|
235
|
+
.map((step) => {
|
|
236
|
+
const lines = [`* ${step.message}`];
|
|
237
|
+
lines.push('');
|
|
238
|
+
lines.push('```js');
|
|
239
|
+
lines.push(stripComments(step.code || ''));
|
|
240
|
+
lines.push('```');
|
|
241
|
+
if (step.discovery) {
|
|
242
|
+
lines.push('');
|
|
243
|
+
for (const discovery of step.discovery.split('\n').filter((line) => line.trim())) {
|
|
244
|
+
lines.push(`> ${discovery.trim()}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return lines.join('\n');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (blocks.length === 0) return '';
|
|
251
|
+
|
|
252
|
+
return `## FLOW: ${title}\n\n${blocks.join('\n\n')}\n\n---\n`;
|
|
253
|
+
}
|
|
254
|
+
|
|
210
255
|
private async detectRetryPatterns(toolExecutions: ToolExecution[], initialState: ActionResult): Promise<void> {
|
|
211
256
|
if (!this.experienceTracker || !this.stateManager) return;
|
|
212
257
|
|
|
@@ -214,7 +259,7 @@ export function WithExperience<T extends Constructor>(Base: T) {
|
|
|
214
259
|
const candidates: Array<{ failed: ToolExecution[]; success: ToolExecution }> = [];
|
|
215
260
|
|
|
216
261
|
for (const exec of toolExecutions) {
|
|
217
|
-
if (!
|
|
262
|
+
if (!isCodeceptToolName(exec.toolName)) continue;
|
|
218
263
|
if (!exec.output?.code) continue;
|
|
219
264
|
|
|
220
265
|
if (!exec.wasSuccessful) {
|
|
@@ -8,8 +8,9 @@ import type { Plan } from '../../test-plan.ts';
|
|
|
8
8
|
import { tag } from '../../utils/logger.ts';
|
|
9
9
|
import { relativeToCwd } from '../../utils/next-steps.ts';
|
|
10
10
|
import { safeFilename } from '../../utils/strings.ts';
|
|
11
|
+
import { CODECEPT_TOOLS } from '../../utils/step-analyzer.ts';
|
|
11
12
|
import type { Conversation } from '../conversation.ts';
|
|
12
|
-
import { ASSERTION_TOOLS
|
|
13
|
+
import { ASSERTION_TOOLS } from '../tools.ts';
|
|
13
14
|
import type { Constructor } from './mixin.ts';
|
|
14
15
|
import { escapeString, getExecutionLabel } from './utils.ts';
|
|
15
16
|
|
|
@@ -1,30 +1,10 @@
|
|
|
1
|
-
import { isDynamicId } from '../../utils/xpath.ts';
|
|
2
1
|
import type { ToolExecution } from '../conversation.ts';
|
|
3
|
-
|
|
4
|
-
export function isNonReusableCode(code: string): boolean {
|
|
5
|
-
if (/\bI\.clickXY\s*\(/.test(code)) return true;
|
|
6
|
-
|
|
7
|
-
for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
|
|
8
|
-
if (isDynamicId(m[1])) return true;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
return false;
|
|
12
|
-
}
|
|
2
|
+
export { isNonReusableCode, stripComments } from '../../utils/step-analyzer.ts';
|
|
13
3
|
|
|
14
4
|
export function escapeString(str: string): string {
|
|
15
5
|
return str.replace(/'/g, "\\'").replace(/\n/g, ' ');
|
|
16
6
|
}
|
|
17
7
|
|
|
18
|
-
export function stripComments(code: string): string {
|
|
19
|
-
return code
|
|
20
|
-
.split('\n')
|
|
21
|
-
.filter((line) => {
|
|
22
|
-
const trimmed = line.trim();
|
|
23
|
-
return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*');
|
|
24
|
-
})
|
|
25
|
-
.join('\n');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
8
|
export function getExecutionLabel(exec: ToolExecution, fallback?: string): string {
|
|
29
9
|
return exec.input?.explanation || exec.input?.assertion || exec.input?.note || fallback || '';
|
|
30
10
|
}
|
package/src/ai/historian.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { type PlaywrightMethods, WithPlaywright } from './historian/playwright.t
|
|
|
13
13
|
import { type ScreencastMethods, WithScreencast } from './historian/screencast.ts';
|
|
14
14
|
import type { Provider } from './provider.ts';
|
|
15
15
|
|
|
16
|
-
export { isNonReusableCode } from '
|
|
16
|
+
export { isNonReusableCode } from '../utils/step-analyzer.ts';
|
|
17
17
|
|
|
18
18
|
const HistorianBase = WithScreencast(WithPlaywright(WithCodeceptJS(WithExperience(Object as unknown as new (...args: any[]) => object))));
|
|
19
19
|
|
package/src/ai/pilot.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { Fisherman } from './fisherman.ts';
|
|
|
18
18
|
import type { Navigator } from './navigator.ts';
|
|
19
19
|
import type { Provider } from './provider.ts';
|
|
20
20
|
import type { Researcher } from './researcher.ts';
|
|
21
|
+
import { capabilityGroundingRule, dataProtectionRules } from './rules.ts';
|
|
21
22
|
import { isInteractive } from './task-agent.ts';
|
|
22
23
|
|
|
23
24
|
const CHECK_TOOLS = ['verify', 'see', 'research', 'context'];
|
|
@@ -91,7 +92,7 @@ export class Pilot implements Agent {
|
|
|
91
92
|
|
|
92
93
|
let visualAnalysis = '';
|
|
93
94
|
let screenshotState: ActionResult | null = null;
|
|
94
|
-
if (this.provider.hasVision()) {
|
|
95
|
+
if (type === 'finish' && this.provider.hasVision()) {
|
|
95
96
|
try {
|
|
96
97
|
screenshotState = await this.explorer.capturePageWithScreenshot();
|
|
97
98
|
if (screenshotState.screenshot) {
|
|
@@ -164,7 +165,7 @@ export class Pilot implements Agent {
|
|
|
164
165
|
try {
|
|
165
166
|
const response = await this.provider.generateObject(messages, schema, this.provider.getAgenticModel('pilot'), {
|
|
166
167
|
agentName: 'pilot',
|
|
167
|
-
|
|
168
|
+
telemetry: { functionId: 'pilot.reviewVerdict' },
|
|
168
169
|
});
|
|
169
170
|
|
|
170
171
|
const result = response?.object;
|
|
@@ -266,7 +267,7 @@ export class Pilot implements Agent {
|
|
|
266
267
|
try {
|
|
267
268
|
const response = await this.provider.generateObject(messages, schema, this.provider.getAgenticModel('pilot'), {
|
|
268
269
|
agentName: 'pilot',
|
|
269
|
-
|
|
270
|
+
telemetry: { functionId: 'pilot.reviewReset' },
|
|
270
271
|
});
|
|
271
272
|
|
|
272
273
|
const result = response?.object;
|
|
@@ -378,6 +379,8 @@ export class Pilot implements Agent {
|
|
|
378
379
|
You are Pilot — final decision maker for test pass/fail. Tester requested ${type}. Review the
|
|
379
380
|
evidence and commit to a verdict; "continue" only when evidence is genuinely insufficient.
|
|
380
381
|
|
|
382
|
+
${capabilityGroundingRule}
|
|
383
|
+
|
|
381
384
|
${this.buildSharedEvidenceRules(task)}
|
|
382
385
|
|
|
383
386
|
DECISION:
|
|
@@ -386,6 +389,8 @@ export class Pilot implements Agent {
|
|
|
386
389
|
Pick assertions DOM can express; for non-DOM regions (iframes, canvas, Monaco/CodeMirror), target a
|
|
387
390
|
stable landmark (container, ARIA role) instead of literal inner text. Your "pass" stands even if the
|
|
388
391
|
DOM assertion can't be made.
|
|
392
|
+
Do not pass when Tester achieved only a related navigation/filter/tab/status outcome instead of the
|
|
393
|
+
requested action, workflow, or entity detail goal.
|
|
389
394
|
- "fail": scenario was attempted but the goal was not achieved.
|
|
390
395
|
- "skipped": scenario is irrelevant to the app, OR systematic infrastructure failures (LLM errors,
|
|
391
396
|
crashes) prevented testing. NOT for "test failed to interact" — that's "fail" or "continue".
|
|
@@ -424,6 +429,10 @@ export class Pilot implements Agent {
|
|
|
424
429
|
|
|
425
430
|
FIRST: Decide if precondition() is needed.
|
|
426
431
|
|
|
432
|
+
${capabilityGroundingRule}
|
|
433
|
+
|
|
434
|
+
${dataProtectionRules}
|
|
435
|
+
|
|
427
436
|
Call precondition() WHEN:
|
|
428
437
|
- The scenario edits/deletes/modifies an item, and you want a DISPOSABLE item to act on safely
|
|
429
438
|
- The scenario needs specific data clearly NOT on the current page (e.g., items with specific statuses for filtering)
|
|
@@ -576,7 +585,7 @@ export class Pilot implements Agent {
|
|
|
576
585
|
maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
|
|
577
586
|
agentName: 'pilot',
|
|
578
587
|
stopWhen: opts.task ? () => opts.task!.hasFinished : undefined,
|
|
579
|
-
|
|
588
|
+
telemetry: { functionId },
|
|
580
589
|
});
|
|
581
590
|
const text = result?.response?.text || '';
|
|
582
591
|
const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => e.output.content);
|
|
@@ -1029,9 +1038,13 @@ export class Pilot implements Agent {
|
|
|
1029
1038
|
|
|
1030
1039
|
Tester tools: click, pressKey, form, see, verify, context, research, xpathCheck, visualClick,
|
|
1031
1040
|
back, getVisitedStates, reset, stop, finish, record.
|
|
1041
|
+
Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
|
|
1042
|
+
|
|
1043
|
+
${capabilityGroundingRule}
|
|
1032
1044
|
|
|
1033
1045
|
YOUR Pilot-only tool: precondition(description) — create FRESH disposable test data via API. Never
|
|
1034
1046
|
request users. Use when:
|
|
1047
|
+
|
|
1035
1048
|
- Scenario edits/deletes/modifies an item → create a disposable target ("1 post").
|
|
1036
1049
|
- Scenario needs auxiliary data (labels, categories, statuses for filtering).
|
|
1037
1050
|
- Tester failed because required data is missing (empty dropdown, empty list).
|
|
@@ -1041,6 +1054,8 @@ export class Pilot implements Agent {
|
|
|
1041
1054
|
- Current page already shows the exact data needed.
|
|
1042
1055
|
- Scenario tests navigation, search UI, or viewing.
|
|
1043
1056
|
|
|
1057
|
+
${dataProtectionRules}
|
|
1058
|
+
|
|
1044
1059
|
Describe WHAT to create, not what exists. RIGHT: precondition("1 test"). WRONG:
|
|
1045
1060
|
precondition("1 test suite named Updated Suite with existing tests"). Keep descriptions short.
|
|
1046
1061
|
|
package/src/ai/planner.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type { Provider } from './provider.js';
|
|
|
24
24
|
import { POSSIBLE_SECTIONS, Researcher } from './researcher.ts';
|
|
25
25
|
import { findSimilarStateHash } from './researcher/cache.ts';
|
|
26
26
|
import { hasFocusedSection } from './researcher/focus.ts';
|
|
27
|
-
import {
|
|
27
|
+
import { capabilityGroundingRule, dataProtectionRules, fileUploadRule } from './rules.ts';
|
|
28
28
|
|
|
29
29
|
const debugLog = createDebug('explorbot:planner');
|
|
30
30
|
|
|
@@ -35,7 +35,7 @@ const TasksSchema = z.object({
|
|
|
35
35
|
z.object({
|
|
36
36
|
scenario: z.string().describe('A single sentence describing what to test'),
|
|
37
37
|
priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
|
|
38
|
-
startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL
|
|
38
|
+
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.'),
|
|
39
39
|
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.'),
|
|
40
40
|
expectedOutcomes: z
|
|
41
41
|
.array(z.string())
|
|
@@ -90,6 +90,9 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
90
90
|
const featureDirective = feature
|
|
91
91
|
? `\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").`
|
|
92
92
|
: '';
|
|
93
|
+
const focusExistingDataDirective = feature
|
|
94
|
+
? '\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.'
|
|
95
|
+
: '';
|
|
93
96
|
return dedent`
|
|
94
97
|
<role>
|
|
95
98
|
You are ISTQB certified senior manual QA planning exploratory testing session of a web application.
|
|
@@ -113,7 +116,7 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
113
116
|
Bad: "Open delete dropdown" + "Confirm deletion" — these are ONE test, not two.
|
|
114
117
|
Bad: "Search for X" + "Verify search results" — searching and verifying is ONE test.
|
|
115
118
|
Bad: "Leave field empty" + "Click submit" — that's one negative test, not two.
|
|
116
|
-
If two scenarios cannot run independently (one requires the other to run first), merge them into one.${featureDirective}
|
|
119
|
+
If two scenarios cannot run independently (one requires the other to run first), merge them into one.${featureDirective}${focusExistingDataDirective}
|
|
117
120
|
</task>
|
|
118
121
|
|
|
119
122
|
${customPrompt || ''}
|
|
@@ -341,6 +344,11 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
341
344
|
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.
|
|
342
345
|
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.
|
|
343
346
|
Do not assume hidden data exists just because a control is present.
|
|
347
|
+
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.
|
|
348
|
+
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.
|
|
349
|
+
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.
|
|
350
|
+
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.
|
|
351
|
+
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.
|
|
344
352
|
DO NOT propose "verification-only" tests that merely open a UI element (modal, dropdown, panel) and check it exists.
|
|
345
353
|
Every test must complete a meaningful action that changes application state or produces a business outcome.
|
|
346
354
|
Opening a modal is NOT a test — performing an action INSIDE the modal IS a test.
|
|
@@ -351,7 +359,8 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
351
359
|
Tests that only switch views, toggle filters, or paginate are LESS valuable — propose them only after data-changing tests are covered.
|
|
352
360
|
If multiple ways to create or modify data exist (different types, different forms), propose a separate test for each.
|
|
353
361
|
</priority_order>
|
|
354
|
-
${
|
|
362
|
+
${capabilityGroundingRule}
|
|
363
|
+
${dataProtectionRules}
|
|
355
364
|
${fileUploadRule}
|
|
356
365
|
</rules>
|
|
357
366
|
|
|
@@ -514,7 +523,9 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
514
523
|
.join('\n')}
|
|
515
524
|
|
|
516
525
|
You MAY propose tests starting from these pages if they are relevant to the plan "${this.currentPlan.title}".
|
|
517
|
-
Set startUrl for such tests
|
|
526
|
+
Set startUrl for such tests only when the page is a stable feature/list/detail page.
|
|
527
|
+
Do not use create/edit/new/modal URLs as startUrl for scenarios that need the underlying page.
|
|
528
|
+
Ignore pages that belong to a different feature area.
|
|
518
529
|
</context_from_previous_tests>
|
|
519
530
|
|
|
520
531
|
Propose ONLY new scenarios that are NOT in the existing tests list.
|
package/src/ai/provider.ts
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 type { ModelMessage } from 'ai';
|
|
5
6
|
import { clearActivity, setActivity } from '../activity.ts';
|
|
6
7
|
import type { AIConfig } from '../config.js';
|
|
@@ -19,9 +20,11 @@ const responseLog = createDebug('explorbot:provider:in');
|
|
|
19
20
|
class AiError extends Error {}
|
|
20
21
|
export class ContextLengthError extends Error {}
|
|
21
22
|
|
|
23
|
+
let telemetryRegistered = false;
|
|
24
|
+
|
|
22
25
|
function extractCachedTokens(usage: any): number {
|
|
23
26
|
if (!usage) return 0;
|
|
24
|
-
const direct = usage.
|
|
27
|
+
const direct = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens;
|
|
25
28
|
if (typeof direct === 'number') return direct;
|
|
26
29
|
const raw = usage.raw;
|
|
27
30
|
const fromRaw = raw?.prompt_tokens_details?.cached_tokens ?? raw?.promptTokensDetails?.cachedTokens;
|
|
@@ -86,7 +89,7 @@ export class Provider {
|
|
|
86
89
|
await generateText({
|
|
87
90
|
model: this.config.model,
|
|
88
91
|
prompt: 'hi',
|
|
89
|
-
|
|
92
|
+
maxOutputTokens: 1,
|
|
90
93
|
});
|
|
91
94
|
} catch (error: any) {
|
|
92
95
|
throw new AiError(`AI connection failed: ${error.message}`);
|
|
@@ -110,6 +113,16 @@ export class Provider {
|
|
|
110
113
|
return this.config.agenticModel || this.config.model;
|
|
111
114
|
}
|
|
112
115
|
|
|
116
|
+
getConfiguredModels(): Record<string, string> {
|
|
117
|
+
const models: Record<string, string> = { model: this.getModelName(this.config.model) };
|
|
118
|
+
if (this.config.agenticModel) models.agenticModel = this.getModelName(this.config.agenticModel);
|
|
119
|
+
if (this.config.visionModel) models.visionModel = this.getModelName(this.config.visionModel);
|
|
120
|
+
for (const [agent, agentConfig] of Object.entries(this.config.agents || {})) {
|
|
121
|
+
if (agentConfig?.model) models[agent] = this.getModelName(agentConfig.model);
|
|
122
|
+
}
|
|
123
|
+
return models;
|
|
124
|
+
}
|
|
125
|
+
|
|
113
126
|
getSystemPromptForAgent(agentName: string, currentUrl?: string): string | undefined {
|
|
114
127
|
const agentConfig = this.config.agents?.[agentName as keyof typeof this.config.agents];
|
|
115
128
|
const parts: string[] = [];
|
|
@@ -129,6 +142,12 @@ export class Provider {
|
|
|
129
142
|
return agentConfig?.providerOptions;
|
|
130
143
|
}
|
|
131
144
|
|
|
145
|
+
getReasoningForAgent(agentName?: string): string | undefined {
|
|
146
|
+
if (!agentName) return undefined;
|
|
147
|
+
const agentConfig = this.config.agents?.[agentName as keyof typeof this.config.agents];
|
|
148
|
+
return agentConfig?.reasoning;
|
|
149
|
+
}
|
|
150
|
+
|
|
132
151
|
private getRetryOptions(options: any = {}): RetryOptions {
|
|
133
152
|
return {
|
|
134
153
|
...this.defaultRetryOptions,
|
|
@@ -146,6 +165,12 @@ export class Provider {
|
|
|
146
165
|
};
|
|
147
166
|
}
|
|
148
167
|
|
|
168
|
+
private finalizeConfig(config: Record<string, any>, options: any, telemetry: any): void {
|
|
169
|
+
if (telemetry) config.telemetry = telemetry;
|
|
170
|
+
const reasoning = this.getReasoningForAgent(options.agentName);
|
|
171
|
+
if (reasoning) config.reasoning ??= reasoning;
|
|
172
|
+
}
|
|
173
|
+
|
|
149
174
|
private initLangfuse() {
|
|
150
175
|
const langfuseConfig = this.config.langfuse;
|
|
151
176
|
const publicKey = langfuseConfig?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
@@ -167,6 +192,10 @@ export class Provider {
|
|
|
167
192
|
instrumentations: [],
|
|
168
193
|
});
|
|
169
194
|
void this.otelSdk.start();
|
|
195
|
+
if (!telemetryRegistered) {
|
|
196
|
+
registerTelemetry(new OpenTelemetry());
|
|
197
|
+
telemetryRegistered = true;
|
|
198
|
+
}
|
|
170
199
|
this.telemetryEnabled = true;
|
|
171
200
|
}
|
|
172
201
|
|
|
@@ -177,20 +206,20 @@ export class Provider {
|
|
|
177
206
|
|
|
178
207
|
const runTelemetry = Observability.getTelemetry();
|
|
179
208
|
|
|
180
|
-
if (!options.
|
|
181
|
-
return runTelemetry
|
|
209
|
+
if (!options.telemetry) {
|
|
210
|
+
return runTelemetry;
|
|
182
211
|
}
|
|
183
212
|
|
|
184
213
|
if (!runTelemetry) {
|
|
185
|
-
return options.
|
|
214
|
+
return options.telemetry;
|
|
186
215
|
}
|
|
187
216
|
|
|
188
217
|
return {
|
|
189
218
|
...runTelemetry,
|
|
190
|
-
...options.
|
|
219
|
+
...options.telemetry,
|
|
191
220
|
metadata: {
|
|
192
221
|
...runTelemetry.metadata,
|
|
193
|
-
...options.
|
|
222
|
+
...options.telemetry.metadata,
|
|
194
223
|
},
|
|
195
224
|
};
|
|
196
225
|
}
|
|
@@ -242,15 +271,16 @@ export class Provider {
|
|
|
242
271
|
const telemetry = this.getTelemetry(options);
|
|
243
272
|
const config = this.mergeProviderOptions(
|
|
244
273
|
{
|
|
245
|
-
|
|
274
|
+
maxOutputTokens: 16384,
|
|
275
|
+
allowSystemInMessages: true,
|
|
246
276
|
...(this.config.config || {}),
|
|
247
277
|
...options,
|
|
248
|
-
...(telemetry ? { experimental_telemetry: telemetry } : {}),
|
|
249
278
|
model,
|
|
250
279
|
abortSignal: executionController.getAbortSignal(),
|
|
251
280
|
},
|
|
252
281
|
options.agentName
|
|
253
282
|
);
|
|
283
|
+
this.finalizeConfig(config, options, telemetry);
|
|
254
284
|
|
|
255
285
|
promptLog(messages[messages.length - 1].content);
|
|
256
286
|
try {
|
|
@@ -259,7 +289,7 @@ export class Provider {
|
|
|
259
289
|
if (!result.text) {
|
|
260
290
|
debugLog(result);
|
|
261
291
|
if (result.finishReason === 'length') {
|
|
262
|
-
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase
|
|
292
|
+
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
|
|
263
293
|
}
|
|
264
294
|
throw new Error('No response text from AI');
|
|
265
295
|
}
|
|
@@ -315,23 +345,24 @@ export class Provider {
|
|
|
315
345
|
const telemetry = this.getTelemetry(options);
|
|
316
346
|
const maxRoundtrips = options.maxToolRoundtrips ?? 5;
|
|
317
347
|
const extraStop = options.stopWhen;
|
|
318
|
-
const stopConditions: any[] = [
|
|
348
|
+
const stopConditions: any[] = [isStepCount(maxRoundtrips)];
|
|
319
349
|
if (extraStop) stopConditions.push(extraStop);
|
|
320
350
|
const { stopWhen: _ignoredStopWhen, ...optionsWithoutStop } = options;
|
|
321
351
|
const config = this.mergeProviderOptions(
|
|
322
352
|
{
|
|
323
353
|
tools,
|
|
324
|
-
|
|
354
|
+
maxOutputTokens: 16384,
|
|
325
355
|
toolChoice: 'auto',
|
|
356
|
+
allowSystemInMessages: true,
|
|
326
357
|
...(this.config.config || {}),
|
|
327
358
|
...optionsWithoutStop,
|
|
328
359
|
stopWhen: stopConditions,
|
|
329
|
-
...(telemetry ? { experimental_telemetry: telemetry } : {}),
|
|
330
360
|
model,
|
|
331
361
|
abortSignal: executionController.getAbortSignal(),
|
|
332
362
|
},
|
|
333
363
|
options.agentName
|
|
334
364
|
);
|
|
365
|
+
this.finalizeConfig(config, options, telemetry);
|
|
335
366
|
try {
|
|
336
367
|
const response = await withRetry(async () => {
|
|
337
368
|
const timeout = config.timeout || 30000;
|
|
@@ -346,7 +377,7 @@ export class Provider {
|
|
|
346
377
|
])) as any;
|
|
347
378
|
const hasToolCall = (result.toolCalls?.length || 0) > 0;
|
|
348
379
|
if (!result.text && !hasToolCall && result.finishReason === 'length') {
|
|
349
|
-
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase
|
|
380
|
+
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
|
|
350
381
|
}
|
|
351
382
|
return result;
|
|
352
383
|
} finally {
|
|
@@ -409,14 +440,15 @@ export class Provider {
|
|
|
409
440
|
const config = this.mergeProviderOptions(
|
|
410
441
|
{
|
|
411
442
|
schema,
|
|
443
|
+
allowSystemInMessages: true,
|
|
412
444
|
...(this.config.config || {}),
|
|
413
445
|
...options,
|
|
414
|
-
...(telemetry ? { experimental_telemetry: telemetry } : {}),
|
|
415
446
|
model: modelToUse,
|
|
416
447
|
abortSignal: executionController.getAbortSignal(),
|
|
417
448
|
},
|
|
418
449
|
options.agentName
|
|
419
450
|
);
|
|
451
|
+
this.finalizeConfig(config, options, telemetry);
|
|
420
452
|
|
|
421
453
|
try {
|
|
422
454
|
promptLog(messages[messages.length - 1].content);
|
|
@@ -617,13 +649,13 @@ export class Provider {
|
|
|
617
649
|
];
|
|
618
650
|
|
|
619
651
|
const telemetry = this.getTelemetry({});
|
|
620
|
-
const config = {
|
|
621
|
-
|
|
652
|
+
const config: Record<string, any> = {
|
|
653
|
+
maxOutputTokens: 16384,
|
|
622
654
|
...(this.config.config || {}),
|
|
623
|
-
...(telemetry ? { experimental_telemetry: telemetry } : {}),
|
|
624
655
|
model: this.config.visionModel,
|
|
625
656
|
abortSignal: executionController.getAbortSignal(),
|
|
626
657
|
};
|
|
658
|
+
if (telemetry) config.telemetry = telemetry;
|
|
627
659
|
|
|
628
660
|
try {
|
|
629
661
|
promptLog(`Processing image with prompt: ${prompt}`);
|
package/src/ai/quartermaster.ts
CHANGED
|
@@ -6,9 +6,9 @@ import { ConfigParser } from '../config.ts';
|
|
|
6
6
|
import type { StateManager, StateTransition, WebPageState } from '../state-manager.ts';
|
|
7
7
|
import type { Task } from '../test-plan.ts';
|
|
8
8
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
9
|
+
import { isCodeceptToolName } from '../utils/step-analyzer.ts';
|
|
9
10
|
import type { Conversation, ToolExecution } from './conversation.ts';
|
|
10
11
|
import type { Provider } from './provider.ts';
|
|
11
|
-
import { CODECEPT_TOOLS } from './tools.ts';
|
|
12
12
|
|
|
13
13
|
const debugLog = createDebug('explorbot:quartermaster');
|
|
14
14
|
|
|
@@ -140,7 +140,7 @@ export class Quartermaster {
|
|
|
140
140
|
const pageAnalysis = this.pageAnalyses.get(stateHash);
|
|
141
141
|
|
|
142
142
|
const toolExecutions = conversation.getToolExecutions();
|
|
143
|
-
const codeceptExecutions = toolExecutions.filter((e) =>
|
|
143
|
+
const codeceptExecutions = toolExecutions.filter((e) => isCodeceptToolName(e.toolName));
|
|
144
144
|
|
|
145
145
|
if (codeceptExecutions.length === 0 && !pageAnalysis?.axeViolations.length) {
|
|
146
146
|
debugLog('No interactions or violations to analyze');
|
package/src/ai/researcher.ts
CHANGED
|
@@ -68,6 +68,13 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
68
68
|
this.stateManager = explorer.getStateManager();
|
|
69
69
|
this.experienceTracker = this.stateManager.getExperienceTracker();
|
|
70
70
|
this.hooksRunner = new HooksRunner(explorer, explorer.getConfig());
|
|
71
|
+
|
|
72
|
+
const ai = explorer.getConfig().ai;
|
|
73
|
+
if (ai) {
|
|
74
|
+
ai.agents ??= {};
|
|
75
|
+
ai.agents.researcher ??= {};
|
|
76
|
+
ai.agents.researcher.reasoning ??= 'low';
|
|
77
|
+
}
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
protected getNavigator(): Navigator {
|
|
@@ -177,7 +184,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
177
184
|
} catch (error) {
|
|
178
185
|
if (!(error instanceof ContextLengthError) || retriesLeft <= 0) {
|
|
179
186
|
if (error instanceof ContextLengthError) {
|
|
180
|
-
tag('warning').log('Output truncated. Try lowering reasoning effort or increasing
|
|
187
|
+
tag('warning').log('Output truncated. Try lowering reasoning effort or increasing maxOutputTokens in ai.config.');
|
|
181
188
|
}
|
|
182
189
|
throw error;
|
|
183
190
|
}
|
package/src/ai/rules.ts
CHANGED
|
@@ -153,6 +153,52 @@ export const protectionRule = dedent`
|
|
|
153
153
|
</important>
|
|
154
154
|
`;
|
|
155
155
|
|
|
156
|
+
export const dataProtectionRules = dedent`
|
|
157
|
+
<data_protection_rules>
|
|
158
|
+
${protectionRule}
|
|
159
|
+
|
|
160
|
+
If the user request, scenario, focus, or test instructions explicitly prohibit creating,
|
|
161
|
+
editing, updating, deleting, removing, or otherwise mutating data, do not perform those
|
|
162
|
+
actions through the UI, API preconditions, cleanup, fallback steps, or Fisherman.
|
|
163
|
+
|
|
164
|
+
Do not use Fisherman or API data preparation to bypass a no-mutation, read-only, search,
|
|
165
|
+
filter, tab, or list-inspection constraint. Use visible existing data when it is available.
|
|
166
|
+
If no suitable data exists, report the missing precondition instead of creating data.
|
|
167
|
+
|
|
168
|
+
Destructive actions are allowed only against disposable data created by the current scenario
|
|
169
|
+
or prepared for that scenario by Fisherman/API preconditions. Existing application data must
|
|
170
|
+
remain unchanged.
|
|
171
|
+
</data_protection_rules>
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
export const capabilityGroundingRule = dedent`
|
|
175
|
+
<capability_grounding>
|
|
176
|
+
When a scenario depends on a named action, menu item, status, option, workflow, or feature,
|
|
177
|
+
that capability must be visible or explicitly confirmed in the current research/page context
|
|
178
|
+
for the same target entity type.
|
|
179
|
+
|
|
180
|
+
Do not transfer capabilities between similar entities, rows, lists, detail pages, or menus.
|
|
181
|
+
Do not replace a requested action with a synonym or related action unless the UI explicitly
|
|
182
|
+
shows that action for the target entity.
|
|
183
|
+
|
|
184
|
+
When an action is described as applying to an item, row, card, record, node, or entity,
|
|
185
|
+
the target must be grounded as that kind of data entity in the current context. Do not use
|
|
186
|
+
navigation links, filter tabs, counters, breadcrumbs, headings, toolbar controls, or other
|
|
187
|
+
page controls as the subject of row/entity actions.
|
|
188
|
+
|
|
189
|
+
When a scenario asks to open, view, inspect, or navigate to an entity detail view, success
|
|
190
|
+
requires evidence of that entity detail context. An active filter, selected tab, visible count,
|
|
191
|
+
or filtered list is not enough to prove an entity detail view opened.
|
|
192
|
+
|
|
193
|
+
Do not rewrite a scenario goal to match a similar outcome that happened accidentally. If the
|
|
194
|
+
requested entity detail/action/workflow was not achieved, report that mismatch instead of
|
|
195
|
+
passing the test for a related filter, tab, navigation, or status view.
|
|
196
|
+
|
|
197
|
+
If the required capability is not available for the target entity after reasonable discovery,
|
|
198
|
+
record the missing capability and stop instead of repeatedly trying unrelated locators.
|
|
199
|
+
</capability_grounding>
|
|
200
|
+
`;
|
|
201
|
+
|
|
156
202
|
export const focusedElementRule = dedent`
|
|
157
203
|
<focused_element_actions>
|
|
158
204
|
When a text input element is focused (textbox, combobox, contenteditable):
|