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
package/src/ai/tester.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { Navigator } from './navigator.ts';
|
|
|
25
25
|
import type { Pilot } from './pilot.ts';
|
|
26
26
|
import { Provider } from './provider.ts';
|
|
27
27
|
import { Researcher } from './researcher.ts';
|
|
28
|
-
import { actionRule, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule,
|
|
28
|
+
import { actionRule, capabilityGroundingRule, dataProtectionRules, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, sectionContextRule } from './rules.ts';
|
|
29
29
|
import { TaskAgent } from './task-agent.ts';
|
|
30
30
|
import { createCodeceptJSTools, createSpecialContextTools } from './tools.ts';
|
|
31
31
|
|
|
@@ -43,7 +43,7 @@ const SAMPLE_FILES: Record<string, string> = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
export class Tester extends TaskAgent implements Agent {
|
|
46
|
-
protected readonly ACTION_TOOLS = ['click', 'pressKey', 'form'];
|
|
46
|
+
protected readonly ACTION_TOOLS = ['click', 'hover', 'pressKey', 'form'];
|
|
47
47
|
protected readonly SPECIAL_CONTEXT_ACTION_TOOLS = ['exitIframe'];
|
|
48
48
|
emoji = '๐งช';
|
|
49
49
|
private explorer: Explorer;
|
|
@@ -66,6 +66,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
66
66
|
private hooksRunner: HooksRunner;
|
|
67
67
|
private seenUiMapUrls = new Set<string>();
|
|
68
68
|
private lastAnalyzedStateHash: string | null = null;
|
|
69
|
+
private stalledIterations = 0;
|
|
70
|
+
private readonly MAX_STALLED_ITERATIONS = 3;
|
|
69
71
|
|
|
70
72
|
constructor(explorer: Explorer, provider: Provider, researcher: Researcher, navigator: Navigator, agentTools?: any) {
|
|
71
73
|
super();
|
|
@@ -126,6 +128,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
126
128
|
this.pageActionResult = null;
|
|
127
129
|
this.seenUiMapUrls.clear();
|
|
128
130
|
this.lastAnalyzedStateHash = null;
|
|
131
|
+
this.stalledIterations = 0;
|
|
129
132
|
this.explorer.getStateManager().clearHistory();
|
|
130
133
|
this.resetFailureCount();
|
|
131
134
|
this.pilot?.reset();
|
|
@@ -348,6 +351,11 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
348
351
|
});
|
|
349
352
|
}
|
|
350
353
|
|
|
354
|
+
if (this.shouldStopForStalledExecution(task, currentState, result?.toolExecutions || [])) {
|
|
355
|
+
stop();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
351
359
|
if (assertionPerformed) {
|
|
352
360
|
const message = result?.toolExecutions?.find((execution: any) => execution.toolName === 'verify')?.output?.message || '';
|
|
353
361
|
task.addNote(message, wasSuccessful ? TestResult.PASSED : TestResult.FAILED);
|
|
@@ -456,6 +464,31 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
456
464
|
return true;
|
|
457
465
|
}
|
|
458
466
|
|
|
467
|
+
private shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean {
|
|
468
|
+
if (task.hasFinished) return false;
|
|
469
|
+
|
|
470
|
+
const currentState = this.getCurrentState();
|
|
471
|
+
const stateChanged = previousState.url !== currentState.url || previousState.hash !== currentState.hash;
|
|
472
|
+
const actionTools = [...this.ACTION_TOOLS, ...this.SPECIAL_CONTEXT_ACTION_TOOLS];
|
|
473
|
+
const hasSuccessfulAction = toolExecutions.some((execution) => execution.wasSuccessful && actionTools.includes(execution.toolName));
|
|
474
|
+
const hasSuccessfulAssertion = toolExecutions.some((execution) => execution.wasSuccessful && this.ASSERTION_TOOLS.includes(execution.toolName));
|
|
475
|
+
|
|
476
|
+
if (stateChanged || hasSuccessfulAction || hasSuccessfulAssertion) {
|
|
477
|
+
this.stalledIterations = 0;
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const hasNoBrowserProgress = toolExecutions.length === 0 || toolExecutions.every((execution) => !actionTools.includes(execution.toolName) || !execution.wasSuccessful);
|
|
482
|
+
if (!hasNoBrowserProgress) return false;
|
|
483
|
+
|
|
484
|
+
this.stalledIterations++;
|
|
485
|
+
if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
|
|
486
|
+
|
|
487
|
+
task.addNote('No browser progress after repeated attempts on unchanged page', TestResult.FAILED);
|
|
488
|
+
task.finish(TestResult.FAILED);
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
459
492
|
private async prepareInstructionsForNextStep(task: Test): Promise<string> {
|
|
460
493
|
let outcomeStatus = dedent`
|
|
461
494
|
<task>
|
|
@@ -464,6 +497,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
464
497
|
|
|
465
498
|
<rules>
|
|
466
499
|
Use tools ${this.ACTION_TOOLS.join(', ')} to interact with the page.
|
|
500
|
+
Use tool names exactly as listed in this prompt. Do not invent combined tool names, aliases, or names with channel markers such as "commentary".
|
|
501
|
+
Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
|
|
467
502
|
Do not do unsuccesful clicks again.
|
|
468
503
|
Do not run same tool calls with same parameters again.
|
|
469
504
|
</rules>
|
|
@@ -746,6 +781,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
746
781
|
<rules>
|
|
747
782
|
- Refer to UI Map from <page_ui_map> to understand the page structure and its main elements
|
|
748
783
|
- Use only elements that exist in the provided ARIA tree or HTML, <page_aria> and <page_html>
|
|
784
|
+
- Use tool input schemas exactly as documented. Do not invent parameter names or add fields not listed by the tool schema.
|
|
749
785
|
- Use click() for buttons, links, and clickable elements ONLY - do NOT include I.fillField() or I.type() commands in click() tool
|
|
750
786
|
- click() commands array is for FALLBACK LOCATORS of the SAME element, NOT for clicking different elements in sequence. If you need to click two different elements, make two separate click() calls.
|
|
751
787
|
- Use form() for text input (I.fillField, I.type), dropdown selection (I.selectOption), file uploads (I.attachFile), and multi-step form interactions
|
|
@@ -758,6 +794,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
758
794
|
- NEVER call record(status: "success") if your last verify() or see() call FAILED. A failed check means the outcome is NOT confirmed โ use record(status: "fail") instead, or retry with a different approach.
|
|
759
795
|
- Use finish() to complete the test, not record(). record() is for intermediate notes.
|
|
760
796
|
- Call finish(verify) when all goals are achieved โ provide an assertion to verify
|
|
797
|
+
- NEVER call finish() with a negative assertion that says the goal did NOT happen. If the goal cannot be achieved after real attempts, record the blocker and call stop().
|
|
761
798
|
- ONLY call stop() if the scenario itself is completely irrelevant to this page and no expectations can be achieved
|
|
762
799
|
- Use reset() ONLY as a last resort when the current page cannot host the scenario. Never reset after a successful flow just because an assertion or milestone did not match โ verify differently or record() the finding instead. Reset is destructive and does not undo server-side side effects.
|
|
763
800
|
- Be precise with locators (CSS or XPath)
|
|
@@ -771,6 +808,13 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
771
808
|
- When you interact with form with inputs, ensure that you click corresponding button to save its data
|
|
772
809
|
- Follow <locator_priority> rules when selecting locators for all tools
|
|
773
810
|
- Before retrying your actions check maybe they already achived expected results. Use see() tool for that
|
|
811
|
+
- If the current URL is already a create/edit/new form and the scenario is about creating/editing that entity, fill and submit that form. Do not click the list-page "New" button again from inside the form.
|
|
812
|
+
- If the scenario is about search/filter/sort/tabs/list inspection and the current URL is a create/edit/new form, go back or reset to the stable list page before interacting with list controls.
|
|
813
|
+
- When selecting related entities from a list, do not choose rows/options/cards marked as "0 items", "0 tests", or otherwise empty if the scenario requires selecting real content.
|
|
814
|
+
- In selection pickers, counters such as "Selected 0", "Matched tests 0", or disabled Save/Apply mean the selection did not register. Choose a non-empty item or change filters before submitting.
|
|
815
|
+
- A passed form/click command only means the command executed. If a required field remains empty, submit stays disabled, or the expected text is not visible, treat the action as not completed and correct the missing field/state.
|
|
816
|
+
- For filter/tab scenarios, success requires BOTH: the requested filter/tab is visibly active/selected AND the list content matches that filter. Do not finish from only one of these signals.
|
|
817
|
+
- Empty-state text such as "No matched items" only proves a filter when the requested filter/tab is active and the empty state belongs to the filtered list.
|
|
774
818
|
- When filling complex form with lot of actions performed, use see() to look which fields were filled and which are not
|
|
775
819
|
- When verify() fails, use see() to visually confirm the result โ visual confirmation is equally valid evidence
|
|
776
820
|
- For visual state verification (active tabs, selected items, counts, colors), prefer see() over DOM-based verify()
|
|
@@ -799,12 +843,17 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
799
843
|
|
|
800
844
|
${formRequirementsRule}
|
|
801
845
|
|
|
846
|
+
${capabilityGroundingRule}
|
|
847
|
+
|
|
848
|
+
${dataProtectionRules}
|
|
849
|
+
|
|
802
850
|
${this.provider.getSystemPromptForAgent('tester', this.explorer.getStateManager().getCurrentState()?.url) || ''}
|
|
803
851
|
`;
|
|
804
852
|
}
|
|
805
853
|
|
|
806
854
|
private buildScenarioBlock(task: Test, actionResult: ActionResult): string {
|
|
807
855
|
const knowledge = this.getKnowledge(actionResult);
|
|
856
|
+
const experience = this.getExperience(actionResult);
|
|
808
857
|
|
|
809
858
|
return dedent`
|
|
810
859
|
<task>
|
|
@@ -822,16 +871,21 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
822
871
|
Try to achieve as many goals as possible.
|
|
823
872
|
If goal is not achievable, log that and skip to next one.
|
|
824
873
|
Do not hallucinate that goal was achieved when it was not.
|
|
874
|
+
If the scenario action could not be completed, do not finish with a verification of the failure state.
|
|
825
875
|
When creating or editing items via form() or type() you should include ${task.sessionName} in the value (if it is not restricted by the application logic)
|
|
826
876
|
Initial page URL: ${actionResult.url}
|
|
827
877
|
|
|
828
|
-
${
|
|
878
|
+
${capabilityGroundingRule}
|
|
879
|
+
|
|
880
|
+
${dataProtectionRules}
|
|
829
881
|
|
|
830
882
|
${this.buildDeletionScope(task)}
|
|
831
883
|
|
|
832
884
|
${this.buildAvailableFiles()}
|
|
833
885
|
|
|
834
886
|
${knowledge}
|
|
887
|
+
|
|
888
|
+
${experience}
|
|
835
889
|
`;
|
|
836
890
|
}
|
|
837
891
|
|
|
@@ -963,12 +1017,13 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
963
1017
|
}),
|
|
964
1018
|
stop: tool({
|
|
965
1019
|
description: dedent`
|
|
966
|
-
Stop the current test because
|
|
967
|
-
Use this
|
|
968
|
-
|
|
1020
|
+
Stop the current test because it cannot be completed in the current session.
|
|
1021
|
+
Use this when the scenario is incompatible, required UI/data is absent, or repeated varied attempts
|
|
1022
|
+
show that automation cannot complete the workflow.
|
|
1023
|
+
Do NOT use this immediately after the first failed action โ retry with a materially different approach first.
|
|
969
1024
|
`,
|
|
970
1025
|
inputSchema: z.object({
|
|
971
|
-
reason: z.string().describe('Explanation why the scenario
|
|
1026
|
+
reason: z.string().describe('Explanation why the scenario cannot be completed'),
|
|
972
1027
|
}),
|
|
973
1028
|
execute: async ({ reason }) => {
|
|
974
1029
|
task.addNote(`Stop requested: ${reason}`);
|
|
@@ -1003,6 +1058,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
1003
1058
|
Provide a specific assertion to verify the final state.
|
|
1004
1059
|
The assertion MUST prove that YOUR ACTIONS changed the page state.
|
|
1005
1060
|
Do NOT verify something that was already true before you started testing.
|
|
1061
|
+
Do NOT provide an assertion that verifies absence, failure, an empty state, or that the goal did not happen.
|
|
1006
1062
|
|
|
1007
1063
|
Examples of good assertions:
|
|
1008
1064
|
- "New user 'john@example.com' is visible in the users list"
|
|
@@ -1135,12 +1191,26 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
1135
1191
|
this.resetFailureCount();
|
|
1136
1192
|
this.previousUrl = null;
|
|
1137
1193
|
this.previousStateHash = null;
|
|
1194
|
+
this.stalledIterations = 0;
|
|
1195
|
+
} else if (this.shouldStopAfterStalledLoopError(task)) {
|
|
1196
|
+
return 'stop';
|
|
1138
1197
|
}
|
|
1139
1198
|
|
|
1140
1199
|
this.currentConversation?.addUserText(result.message);
|
|
1141
1200
|
return 'continue';
|
|
1142
1201
|
}
|
|
1143
1202
|
|
|
1203
|
+
private shouldStopAfterStalledLoopError(task: Test): boolean {
|
|
1204
|
+
if (task.hasFinished) return false;
|
|
1205
|
+
|
|
1206
|
+
this.stalledIterations++;
|
|
1207
|
+
if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
|
|
1208
|
+
|
|
1209
|
+
task.addNote('No browser progress after repeated execution errors', TestResult.FAILED);
|
|
1210
|
+
task.finish(TestResult.FAILED);
|
|
1211
|
+
return true;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1144
1214
|
private async cleanupStartedTest(task: Test): Promise<void> {
|
|
1145
1215
|
await this.finishTest(task);
|
|
1146
1216
|
await this.explorer.stopTest(task, {
|
package/src/ai/tools.ts
CHANGED
|
@@ -18,7 +18,6 @@ import { isInteractive } from './task-agent.ts';
|
|
|
18
18
|
|
|
19
19
|
const debugLog = createDebug('explorbot:tools');
|
|
20
20
|
|
|
21
|
-
export const CODECEPT_TOOLS = ['click', 'pressKey', 'form'] as const;
|
|
22
21
|
export const ASSERTION_TOOLS = ['verify'] as const;
|
|
23
22
|
|
|
24
23
|
export function createCodeceptJSTools(explorer: Explorer, task: Task) {
|
|
@@ -160,6 +159,84 @@ export function createCodeceptJSTools(explorer: Explorer, task: Task) {
|
|
|
160
159
|
},
|
|
161
160
|
}),
|
|
162
161
|
|
|
162
|
+
hover: tool({
|
|
163
|
+
description: dedent`
|
|
164
|
+
Move the mouse cursor to an element to reveal hover-only controls.
|
|
165
|
+
|
|
166
|
+
Use this before clicking row actions, icon buttons, menus, or toolbars that appear only
|
|
167
|
+
when the user hovers a list item, table row, card, or tree node.
|
|
168
|
+
|
|
169
|
+
This tool ONLY accepts I.moveCursorTo(locator) commands. It does not click.
|
|
170
|
+
After hovering, use context(), see(), or click() the revealed control.
|
|
171
|
+
`,
|
|
172
|
+
inputSchema: z.object({
|
|
173
|
+
commands: z.array(z.string()).describe(dedent`
|
|
174
|
+
FALLBACK LOCATORS for ONE element to hover.
|
|
175
|
+
Order by reliability:
|
|
176
|
+
1. I.moveCursorTo(text, container)
|
|
177
|
+
2. I.moveCursorTo(ARIA, container)
|
|
178
|
+
3. I.moveCursorTo(CSS, container)
|
|
179
|
+
4. I.moveCursorTo(CSS) or I.moveCursorTo(XPath)
|
|
180
|
+
`),
|
|
181
|
+
explanation: z.string().describe('Why you are hovering this element'),
|
|
182
|
+
}),
|
|
183
|
+
execute: async ({ commands: rawCommands, explanation }) => {
|
|
184
|
+
const activeNote = task.startNote(explanation);
|
|
185
|
+
|
|
186
|
+
if (rawCommands.length === 0) {
|
|
187
|
+
activeNote.commit(TestResult.FAILED);
|
|
188
|
+
return failedToolResult('hover', 'No commands provided');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const invalidCommands = rawCommands.map((cmd) => cmd.trim()).filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.moveCursorTo'));
|
|
192
|
+
|
|
193
|
+
if (invalidCommands.length > 0) {
|
|
194
|
+
activeNote.commit(TestResult.FAILED);
|
|
195
|
+
return failedToolResult('hover', `Invalid commands: ${invalidCommands.join(', ')}. Hover tool only accepts I.moveCursorTo() commands.`, {
|
|
196
|
+
suggestion: 'Use click() to click elements, or form() for typing/selecting.',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const commands = rawCommands.map((cmd) => {
|
|
201
|
+
const trimmed = cmd.trim();
|
|
202
|
+
if (trimmed.startsWith('I.moveCursorTo')) return trimmed;
|
|
203
|
+
return `I.moveCursorTo(${JSON.stringify(trimmed)})`;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
|
|
207
|
+
const action = explorer.createAction();
|
|
208
|
+
const attempts: Array<{ command: string; success: boolean; error?: string }> = [];
|
|
209
|
+
|
|
210
|
+
for (const command of commands) {
|
|
211
|
+
const success = await action.attempt(command, explanation, true);
|
|
212
|
+
attempts.push({
|
|
213
|
+
command,
|
|
214
|
+
success,
|
|
215
|
+
...(action.lastError && { error: action.lastError.toString() }),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (!success) continue;
|
|
219
|
+
|
|
220
|
+
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
|
|
221
|
+
activeNote.commit(TestResult.PASSED);
|
|
222
|
+
return successToolResult('hover', { ...toolResult, attempts, code: command }, action);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, commands[0]);
|
|
226
|
+
activeNote.commit(TestResult.FAILED);
|
|
227
|
+
return failedToolResult(
|
|
228
|
+
'hover',
|
|
229
|
+
'All hover commands failed',
|
|
230
|
+
{
|
|
231
|
+
...toolResult,
|
|
232
|
+
attempts,
|
|
233
|
+
suggestion: 'Use xpathCheck() to locate the row/card/tree node, or visualClick() if the hover target is only visually identifiable.',
|
|
234
|
+
},
|
|
235
|
+
action.lastError
|
|
236
|
+
);
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
|
|
163
240
|
pressKey: tool({
|
|
164
241
|
description: dedent`
|
|
165
242
|
Press a keyboard key or key combination. Use this for special keys like Enter, Escape, Tab, Arrow keys, or key combinations with modifiers.
|
|
@@ -488,6 +565,7 @@ export function createAgentTools({
|
|
|
488
565
|
Check the page contents based on current page state and screenshot.
|
|
489
566
|
This tool will trigger visual research to check the page contents on request.
|
|
490
567
|
Use it to verify the actions were performed correctly and the page is in the expected state.
|
|
568
|
+
Input schema has exactly one field: request. Do not pass text, reason, assertion, or other fields.
|
|
491
569
|
|
|
492
570
|
<example>
|
|
493
571
|
request: "Check current state of the Login form"
|
package/src/config.ts
CHANGED
|
@@ -22,6 +22,7 @@ interface PlaywrightConfig {
|
|
|
22
22
|
waitForAction?: number;
|
|
23
23
|
waitForNavigation?: 'load' | 'domcontentloaded' | 'networkidle';
|
|
24
24
|
waitForTimeout?: number;
|
|
25
|
+
spinnerSelectors?: string[];
|
|
25
26
|
ignoreHTTPSErrors?: boolean;
|
|
26
27
|
userAgent?: string;
|
|
27
28
|
viewport?: {
|
|
@@ -59,6 +60,7 @@ interface AgentConfig extends HooksConfig {
|
|
|
59
60
|
systemPrompt?: string;
|
|
60
61
|
rules?: RuleEntry[];
|
|
61
62
|
providerOptions?: Record<string, any>;
|
|
63
|
+
reasoning?: 'provider-default' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
interface ResearcherAgentConfig extends AgentConfig {
|
|
@@ -3,12 +3,12 @@ import { basename, dirname, join } from 'node:path';
|
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import { type Tokens, marked } from 'marked';
|
|
5
5
|
import type { ActionResult } from './action-result.js';
|
|
6
|
-
import { isNonReusableCode } from './ai/historian/utils.ts';
|
|
7
6
|
import { ConfigParser } from './config.js';
|
|
8
7
|
import { KnowledgeTracker } from './knowledge-tracker.js';
|
|
9
8
|
import type { WebPageState } from './state-manager.js';
|
|
10
9
|
import { createDebug, tag } from './utils/logger.js';
|
|
11
10
|
import { mdq } from './utils/markdown-query.js';
|
|
11
|
+
import { isNonReusableCode } from './utils/step-analyzer.ts';
|
|
12
12
|
import { extractStatePath } from './utils/url-matcher.js';
|
|
13
13
|
|
|
14
14
|
const debugLog = createDebug('explorbot:experience');
|
package/src/explorbot.ts
CHANGED
|
@@ -25,12 +25,13 @@ import { ExperienceTracker } from './experience-tracker.ts';
|
|
|
25
25
|
import Explorer from './explorer.ts';
|
|
26
26
|
import { KnowledgeTracker } from './knowledge-tracker.ts';
|
|
27
27
|
import { WebPageState } from './state-manager.ts';
|
|
28
|
+
import { Stats } from './stats.ts';
|
|
28
29
|
import type { Suite } from './suite.ts';
|
|
29
30
|
import { Plan, type Test } from './test-plan.ts';
|
|
30
|
-
import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
|
|
31
31
|
import { setVerboseMode, tag } from './utils/logger.ts';
|
|
32
32
|
import { relativeToCwd } from './utils/next-steps.ts';
|
|
33
33
|
import { sanitizeFilename } from './utils/strings.ts';
|
|
34
|
+
import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
|
|
34
35
|
|
|
35
36
|
export interface ExplorBotOptions {
|
|
36
37
|
from?: string;
|
|
@@ -220,7 +221,13 @@ export class ExplorBot {
|
|
|
220
221
|
this.agents.tester = this.createAgent(({ ai, explorer }) => {
|
|
221
222
|
const researcher = this.agentResearcher();
|
|
222
223
|
const navigator = this.agentNavigator();
|
|
223
|
-
const
|
|
224
|
+
const stateManager = explorer.getStateManager();
|
|
225
|
+
const experienceTracker = stateManager.getExperienceTracker();
|
|
226
|
+
const getState = () => {
|
|
227
|
+
const state = stateManager.getCurrentState();
|
|
228
|
+
return state ? ActionResult.fromState(state) : null;
|
|
229
|
+
};
|
|
230
|
+
const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
|
|
224
231
|
return new Tester(explorer, ai, researcher, navigator, tools);
|
|
225
232
|
});
|
|
226
233
|
|
|
@@ -494,7 +501,10 @@ export class ExplorBot {
|
|
|
494
501
|
|
|
495
502
|
const reporter = this.explorer?.getReporter();
|
|
496
503
|
if (reporter?.isEnabled()) {
|
|
497
|
-
|
|
504
|
+
let description = markdown;
|
|
505
|
+
const modelsTable = Stats.modelsTable(this.provider.getConfiguredModels());
|
|
506
|
+
if (modelsTable) description = `${markdown}\n\n${modelsTable}`;
|
|
507
|
+
await reporter.setRunDescription(description);
|
|
498
508
|
}
|
|
499
509
|
|
|
500
510
|
this.lastReportedTestCount = tests.length;
|
package/src/explorer.ts
CHANGED
|
@@ -2,10 +2,10 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import path, { join } from 'node:path';
|
|
3
3
|
// @ts-ignore
|
|
4
4
|
import * as codeceptjs from 'codeceptjs';
|
|
5
|
-
import dedent from 'dedent';
|
|
6
5
|
import stepsListener from 'codeceptjs/lib/listener/steps';
|
|
7
6
|
import storeListener from 'codeceptjs/lib/listener/store';
|
|
8
7
|
import { createTest } from 'codeceptjs/lib/mocha/test';
|
|
8
|
+
import dedent from 'dedent';
|
|
9
9
|
import type { BrowserContextOptions } from 'playwright';
|
|
10
10
|
import { ActionResult } from './action-result.ts';
|
|
11
11
|
import Action from './action.js';
|
|
@@ -21,10 +21,11 @@ import { PlaywrightRecorder } from './playwright-recorder.ts';
|
|
|
21
21
|
import { Reporter } from './reporter.ts';
|
|
22
22
|
import { StateManager } from './state-manager.js';
|
|
23
23
|
import { Test, TestResult } from './test-plan.ts';
|
|
24
|
+
import { BrowserRecoveryError, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
|
|
24
25
|
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from './utils/html.ts';
|
|
25
26
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
27
|
+
import { waitForPageReadiness } from './utils/page-readiness.ts';
|
|
26
28
|
import { WebElement } from './utils/web-element.ts';
|
|
27
|
-
import { BrowserRecoveryError, isFatalBrowserError } from './utils/browser-errors.ts';
|
|
28
29
|
|
|
29
30
|
declare global {
|
|
30
31
|
namespace NodeJS {
|
|
@@ -314,13 +315,26 @@ class Explorer {
|
|
|
314
315
|
try {
|
|
315
316
|
return await operation();
|
|
316
317
|
} catch (error) {
|
|
317
|
-
|
|
318
|
+
let recoveryError = error;
|
|
319
|
+
|
|
320
|
+
if (isNavigationTransitionError(error)) {
|
|
321
|
+
tag('warning').log(`${label}: page is still navigating, waiting before retry...`);
|
|
322
|
+
await this.waitForPageReadiness();
|
|
323
|
+
try {
|
|
324
|
+
return await operation();
|
|
325
|
+
} catch (retryError) {
|
|
326
|
+
if (!isNavigationTransitionError(retryError) && !this.isFatalBrowserError(retryError)) throw retryError;
|
|
327
|
+
recoveryError = retryError;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!this.isFatalBrowserError(recoveryError)) throw recoveryError;
|
|
318
332
|
|
|
319
333
|
tag('warning').log(`${label}: browser page is unavailable, recovering...`);
|
|
320
334
|
let recovered = await this.recoverFromBrowserError();
|
|
321
335
|
if (!recovered) recovered = await this.restartBrowser();
|
|
322
|
-
if (!recovered) throw new BrowserRecoveryError(label,
|
|
323
|
-
if (!(await this.
|
|
336
|
+
if (!recovered) throw new BrowserRecoveryError(label, recoveryError, false);
|
|
337
|
+
if (!(await this.waitForPageReadiness())) throw new BrowserRecoveryError(label, recoveryError, true);
|
|
324
338
|
|
|
325
339
|
try {
|
|
326
340
|
return await operation();
|
|
@@ -377,7 +391,7 @@ class Explorer {
|
|
|
377
391
|
const msg = err instanceof Error ? err.message : String(err);
|
|
378
392
|
if (!RECOVERABLE_NAVIGATION_ERRORS.test(msg)) throw err;
|
|
379
393
|
tag('warning').log(`Navigation warning (continuing after load): ${msg.split('\n')[0]}`);
|
|
380
|
-
await this.
|
|
394
|
+
await this.waitForPageReadiness();
|
|
381
395
|
await action.capturePageState();
|
|
382
396
|
}
|
|
383
397
|
}
|
|
@@ -488,11 +502,11 @@ class Explorer {
|
|
|
488
502
|
if (url) {
|
|
489
503
|
tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
|
|
490
504
|
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
491
|
-
return this.
|
|
505
|
+
return this.waitForPageReadiness();
|
|
492
506
|
}
|
|
493
507
|
tag('warning').log('Browser error detected, reloading page');
|
|
494
508
|
await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
495
|
-
return this.
|
|
509
|
+
return this.waitForPageReadiness();
|
|
496
510
|
} catch (err) {
|
|
497
511
|
tag('error').log(`Browser recovery failed: ${err instanceof Error ? err.message : err}`);
|
|
498
512
|
return false;
|
|
@@ -532,7 +546,7 @@ class Explorer {
|
|
|
532
546
|
|
|
533
547
|
if (url) {
|
|
534
548
|
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
535
|
-
if (!(await this.
|
|
549
|
+
if (!(await this.waitForPageReadiness())) return false;
|
|
536
550
|
}
|
|
537
551
|
|
|
538
552
|
tag('success').log('Browser restarted');
|
|
@@ -550,27 +564,14 @@ class Explorer {
|
|
|
550
564
|
}
|
|
551
565
|
}
|
|
552
566
|
|
|
553
|
-
private async
|
|
567
|
+
private async waitForPageReadiness(): Promise<boolean> {
|
|
554
568
|
const page = this.playwrightHelper?.page;
|
|
555
569
|
if (!page) return false;
|
|
556
570
|
|
|
557
|
-
await page
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
() => {
|
|
562
|
-
const body = document.body;
|
|
563
|
-
if (!body) return false;
|
|
564
|
-
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
565
|
-
},
|
|
566
|
-
undefined,
|
|
567
|
-
{ timeout: 5000 }
|
|
568
|
-
)
|
|
569
|
-
.then(() => true)
|
|
570
|
-
.catch(() => false);
|
|
571
|
-
if (!hasUsableDom) return false;
|
|
572
|
-
}
|
|
573
|
-
await page.waitForLoadState?.('networkidle', { timeout: 3000 }).catch(() => {});
|
|
571
|
+
await waitForPageReadiness(page, {
|
|
572
|
+
timeout: this.config.playwright.waitForTimeout,
|
|
573
|
+
spinnerSelectors: this.config.playwright.spinnerSelectors,
|
|
574
|
+
});
|
|
574
575
|
return true;
|
|
575
576
|
}
|
|
576
577
|
|
package/src/stats.ts
CHANGED
|
@@ -54,6 +54,24 @@ export class Stats {
|
|
|
54
54
|
return String(num);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
static modelsTable(roleModels: Record<string, string>): string {
|
|
58
|
+
const usedModels = Object.entries(Stats.models).filter(([, tokens]) => tokens.total > 0);
|
|
59
|
+
if (usedModels.length === 0) return '';
|
|
60
|
+
|
|
61
|
+
const rolesByModel: Record<string, string[]> = {};
|
|
62
|
+
for (const [role, model] of Object.entries(roleModels)) {
|
|
63
|
+
if (!rolesByModel[model]) rolesByModel[model] = [];
|
|
64
|
+
rolesByModel[model].push(role);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rows = usedModels.map(([model, tokens]) => {
|
|
68
|
+
const roles = rolesByModel[model]?.join(', ') || '-';
|
|
69
|
+
return `| ${roles} | ${model} | ${Stats.humanizeTokens(tokens.total)} |`;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return ['## Models', '', '| Role | Model | Tokens |', '| --- | --- | --- |', ...rows].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
static hasActivity(): boolean {
|
|
58
76
|
if (Stats.tests > 0 || Stats.plans > 0 || Stats.researches > 0) return true;
|
|
59
77
|
const totalTokens = Object.values(Stats.models).reduce((sum, m) => sum + m.total, 0);
|
package/src/utils/aria.ts
CHANGED
|
@@ -334,6 +334,54 @@ const detectRenames = (prev: FlatEntry[], curr: FlatEntry[], prevTotals: Map<str
|
|
|
334
334
|
return { added, removed };
|
|
335
335
|
};
|
|
336
336
|
|
|
337
|
+
// Interactive controls keep a stable role+name across a state flip; only an ARIA state
|
|
338
|
+
// attribute changes. Report those flips on their own line so the model always sees
|
|
339
|
+
// "now checked / now collapsed", in both directions, regardless of other page churn.
|
|
340
|
+
const STATE_WORDS: Record<string, { on: string; off: string }> = {
|
|
341
|
+
checked: { on: 'checked', off: 'unchecked' },
|
|
342
|
+
selected: { on: 'selected', off: 'unselected' },
|
|
343
|
+
pressed: { on: 'pressed', off: 'unpressed' },
|
|
344
|
+
expanded: { on: 'expanded', off: 'collapsed' },
|
|
345
|
+
};
|
|
346
|
+
const STATE_ATTRS = Object.keys(STATE_WORDS);
|
|
347
|
+
|
|
348
|
+
const stateWord = (attr: string, value: unknown): string => {
|
|
349
|
+
if (attr === 'checked' && value === 'mixed') return 'partially checked';
|
|
350
|
+
const words = STATE_WORDS[attr];
|
|
351
|
+
if (value === true || value === 'true') return words.on;
|
|
352
|
+
return words.off;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// Pair entries by path; when role and name match but a state attr differs, it's a toggle.
|
|
356
|
+
const detectToggles = (prev: FlatEntry[], curr: FlatEntry[]): { toggled: string[]; togglePaths: Set<string> } => {
|
|
357
|
+
const toggled: string[] = [];
|
|
358
|
+
const togglePaths = new Set<string>();
|
|
359
|
+
const currByPath = new Map(curr.map((e) => [e.path, e]));
|
|
360
|
+
|
|
361
|
+
for (const before of prev) {
|
|
362
|
+
const after = currByPath.get(before.path);
|
|
363
|
+
if (!after) continue;
|
|
364
|
+
if (before.entry.role !== after.entry.role) continue;
|
|
365
|
+
if (before.entry.name !== after.entry.name) continue;
|
|
366
|
+
|
|
367
|
+
const transitions: string[] = [];
|
|
368
|
+
for (const attr of STATE_ATTRS) {
|
|
369
|
+
const was = stateWord(attr, before.entry[attr]);
|
|
370
|
+
const now = stateWord(attr, after.entry[attr]);
|
|
371
|
+
if (was === now) continue;
|
|
372
|
+
transitions.push(`${was} -> ${now}`);
|
|
373
|
+
}
|
|
374
|
+
if (transitions.length === 0) continue;
|
|
375
|
+
|
|
376
|
+
togglePaths.add(before.path);
|
|
377
|
+
let label = String(after.entry.role);
|
|
378
|
+
const name = after.entry.name;
|
|
379
|
+
if (typeof name === 'string' && name.trim()) label += ` "${name.trim()}"`;
|
|
380
|
+
toggled.push(`${label}: ${transitions.join(', ')}`);
|
|
381
|
+
}
|
|
382
|
+
return { toggled, togglePaths };
|
|
383
|
+
};
|
|
384
|
+
|
|
337
385
|
const TOP_DIFF_ITEMS = 10;
|
|
338
386
|
|
|
339
387
|
const formatDiffSection = (label: string, items: string[]): string[] => {
|
|
@@ -358,9 +406,15 @@ const formatDiffSection = (label: string, items: string[]): string[] => {
|
|
|
358
406
|
return lines;
|
|
359
407
|
};
|
|
360
408
|
|
|
361
|
-
const formatDiff = (added: string[], removed: string[]): string | null => {
|
|
362
|
-
if (added.length === 0 && removed.length === 0) return null;
|
|
363
|
-
|
|
409
|
+
const formatDiff = (added: string[], removed: string[], toggled: string[]): string | null => {
|
|
410
|
+
if (added.length === 0 && removed.length === 0 && toggled.length === 0) return null;
|
|
411
|
+
const sections = ['ariaDiff:'];
|
|
412
|
+
if (toggled.length > 0) {
|
|
413
|
+
sections.push(' toggled:');
|
|
414
|
+
for (const line of toggled) sections.push(` - ${line}`);
|
|
415
|
+
}
|
|
416
|
+
sections.push(...formatDiffSection('added', added), ...formatDiffSection('removed', removed));
|
|
417
|
+
return sections.join('\n');
|
|
364
418
|
};
|
|
365
419
|
|
|
366
420
|
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
@@ -437,13 +491,16 @@ export const diffAriaSnapshots = (previous: string | null, current: string | nul
|
|
|
437
491
|
tree = dropEmpty(tree);
|
|
438
492
|
return flatten(tree);
|
|
439
493
|
};
|
|
440
|
-
const
|
|
441
|
-
const
|
|
494
|
+
const prevAll = flat(previous);
|
|
495
|
+
const currAll = flat(current);
|
|
496
|
+
const { toggled, togglePaths } = detectToggles(prevAll, currAll);
|
|
497
|
+
const prev = prevAll.filter((e) => !togglePaths.has(e.path));
|
|
498
|
+
const curr = currAll.filter((e) => !togglePaths.has(e.path));
|
|
442
499
|
const prevTotals = countBy(prev.map((e) => e.summary));
|
|
443
500
|
const currTotals = countBy(curr.map((e) => e.summary));
|
|
444
501
|
const byCount = diffByCount(prevTotals, currTotals);
|
|
445
502
|
const renames = detectRenames(prev, curr, prevTotals, currTotals);
|
|
446
|
-
return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed]);
|
|
503
|
+
return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed], toggled);
|
|
447
504
|
};
|
|
448
505
|
|
|
449
506
|
export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// not typed exceptions. Keep those external message markers in one adapter so
|
|
3
3
|
// recovery decisions are not duplicated across agents/actions.
|
|
4
4
|
const FATAL_BROWSER_ERROR_MARKERS = ['Frame was detached', 'Target closed', 'Target page, context or browser has been closed', 'Execution context was destroyed', 'Protocol error', 'Session closed'];
|
|
5
|
+
const NAVIGATION_TRANSITION_ERROR_MARKERS = ['most likely because of a navigation', 'navigating and changing the content'];
|
|
5
6
|
|
|
6
7
|
export class BrowserRecoveryError extends Error {
|
|
7
8
|
constructor(
|
|
@@ -20,6 +21,11 @@ export function isFatalBrowserError(error: unknown): boolean {
|
|
|
20
21
|
return FATAL_BROWSER_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
export function isNavigationTransitionError(error: unknown): boolean {
|
|
25
|
+
const message = browserErrorMessage(error).toLowerCase();
|
|
26
|
+
return NAVIGATION_TRANSITION_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
export function browserErrorMessage(error: unknown): string {
|
|
24
30
|
return error instanceof Error ? error.message : String(error);
|
|
25
31
|
}
|