explorbot 0.1.12 → 0.1.13

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.
Files changed (59) hide show
  1. package/bin/explorbot-cli.ts +21 -21
  2. package/dist/bin/explorbot-cli.js +3 -3
  3. package/dist/package.json +3 -2
  4. package/dist/rules/researcher/container-rules.md +2 -0
  5. package/dist/src/action-result.js +2 -1
  6. package/dist/src/action.js +0 -6
  7. package/dist/src/ai/captain.js +0 -2
  8. package/dist/src/ai/driller.js +1108 -0
  9. package/dist/src/ai/pilot.js +31 -22
  10. package/dist/src/ai/rules.js +3 -5
  11. package/dist/src/ai/session-analyst.js +117 -0
  12. package/dist/src/ai/tester.js +13 -2
  13. package/dist/src/commands/base-command.js +6 -6
  14. package/dist/src/commands/drill-command.js +3 -2
  15. package/dist/src/commands/exit-command.js +1 -0
  16. package/dist/src/commands/explore-command.js +1 -0
  17. package/dist/src/components/AddRule.js +1 -1
  18. package/dist/src/explorbot.js +48 -8
  19. package/dist/src/explorer.js +9 -8
  20. package/dist/src/reporter.js +64 -3
  21. package/dist/src/state-manager.js +4 -3
  22. package/dist/src/stats.js +5 -0
  23. package/dist/src/utils/aria.js +354 -529
  24. package/dist/src/utils/hooks-runner.js +2 -8
  25. package/dist/src/utils/html.js +371 -0
  26. package/dist/src/utils/unique-names.js +12 -1
  27. package/dist/src/utils/url-matcher.js +6 -1
  28. package/dist/src/utils/web-element.js +27 -24
  29. package/dist/src/utils/xpath.js +1 -1
  30. package/package.json +3 -2
  31. package/rules/researcher/container-rules.md +2 -0
  32. package/src/action-result.ts +2 -1
  33. package/src/action.ts +0 -8
  34. package/src/ai/captain.ts +0 -2
  35. package/src/ai/driller.ts +1194 -0
  36. package/src/ai/pilot.ts +31 -21
  37. package/src/ai/rules.ts +3 -5
  38. package/src/ai/session-analyst.ts +133 -0
  39. package/src/ai/tester.ts +15 -2
  40. package/src/commands/base-command.ts +6 -6
  41. package/src/commands/drill-command.ts +3 -2
  42. package/src/commands/exit-command.ts +1 -0
  43. package/src/commands/explore-command.ts +1 -0
  44. package/src/components/AddRule.tsx +1 -1
  45. package/src/config.ts +4 -0
  46. package/src/explorbot.ts +55 -10
  47. package/src/explorer.ts +9 -8
  48. package/src/reporter.ts +64 -3
  49. package/src/state-manager.ts +4 -3
  50. package/src/stats.ts +7 -0
  51. package/src/utils/aria.ts +367 -537
  52. package/src/utils/hooks-runner.ts +2 -6
  53. package/src/utils/html.ts +381 -0
  54. package/src/utils/unique-names.ts +13 -0
  55. package/src/utils/url-matcher.ts +5 -1
  56. package/src/utils/web-element.ts +31 -28
  57. package/src/utils/xpath.ts +1 -1
  58. package/dist/src/ai/bosun.js +0 -456
  59. package/src/ai/bosun.ts +0 -571
@@ -69,16 +69,17 @@ export class Pilot {
69
69
  const stateContext = this.buildStateContext(currentState);
70
70
  const notes = task.notesToString() || 'No notes recorded.';
71
71
  let visualAnalysis = '';
72
+ let screenshotState = null;
72
73
  if (this.provider.hasVision()) {
73
74
  try {
74
75
  const action = this.explorer.createAction();
75
- const screenshotState = await action.caputrePageWithScreenshot();
76
+ screenshotState = await action.caputrePageWithScreenshot();
76
77
  if (screenshotState.screenshot) {
77
78
  visualAnalysis = (await this.researcher.answerQuestionAboutScreenshot(screenshotState, `Describe current page state relevant to: ${task.scenario}`)) || '';
78
79
  }
79
80
  }
80
81
  catch {
81
- // vision not available, continue without
82
+ screenshotState = null;
82
83
  }
83
84
  }
84
85
  const schema = z.object({
@@ -140,28 +141,24 @@ export class Pilot {
140
141
  task.finish(TestResult.FAILED);
141
142
  return false;
142
143
  }
143
- if (result.requestVerification && navigator) {
144
+ if (result.decision === 'pass' && result.requestVerification && navigator) {
144
145
  tag('substep').log(`Pilot requesting verification: ${result.requestVerification}`);
145
- try {
146
- const verifyResult = await navigator.verifyState(result.requestVerification, currentState);
147
- if (verifyResult.verified) {
148
- if (verifyResult.assertionSteps?.length) {
149
- this.explorer.getPlaywrightRecorder().recordVerification(verifyResult.assertionSteps);
150
- }
151
- tag('substep').log(`Pilot verified: ${result.requestVerification}`);
152
- }
153
- else {
154
- tag('substep').log(`Pilot verification failed: ${result.requestVerification}`);
155
- if (result.decision === 'pass') {
156
- const flipMessage = `Verification "${result.requestVerification}" did not match the page. Adjust approach and re-verify before finishing.`;
157
- result.decision = 'continue';
158
- result.reason = flipMessage;
159
- result.guidance = result.guidance ?? flipMessage;
160
- }
146
+ const verifyResult = await navigator.verifyState(result.requestVerification, currentState).catch(() => null);
147
+ if (verifyResult?.verified) {
148
+ if (verifyResult.assertionSteps?.length) {
149
+ this.explorer.getPlaywrightRecorder().recordVerification(verifyResult.assertionSteps);
161
150
  }
162
151
  }
163
- catch (verifyErr) {
164
- tag('warning').log(`Pilot verification errored: ${verifyErr.message}`);
152
+ else {
153
+ let answer = null;
154
+ if (screenshotState?.screenshot) {
155
+ answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, `Does the screen confirm: "${result.requestVerification}"? Answer YES or NO only.`);
156
+ }
157
+ if (!(answer || '').trim().toUpperCase().startsWith('YES')) {
158
+ task.addNote(`Pilot: verification failed — ${result.requestVerification}`, TestResult.FAILED);
159
+ task.finish(TestResult.FAILED);
160
+ return false;
161
+ }
165
162
  }
166
163
  }
167
164
  tag('info').log(`Pilot: ${result.decision} — ${result.reason}`);
@@ -348,6 +345,8 @@ export class Pilot {
348
345
  - If no verification was done → prefer "continue" with guidance telling tester what to verify.
349
346
  - If verify assertion describes a state that was ALREADY TRUE before the test started, the verification proves nothing — reject with "continue".
350
347
 
348
+ requestVerification — pick assertions DOM can actually express. Some content is not assertable via DOM (iframe text, canvas, custom widgets, Monaco/CodeMirror editors). When the scenario goal lives in such a region, target a STABLE LANDMARK (container element, ARIA role, the parent that wraps the widget) rather than literal text inside it. Your "pass" verdict is honored even if the DOM assertion can't be made — pick the strongest landmark you can.
349
+
351
350
  GUIDANCE FIELD: When decision is "continue", you MUST provide "guidance" — a specific actionable instruction:
352
351
  - If evidence is insufficient: tell tester to verify with see()/verify(), specify WHAT to check
353
352
  - If approach was wrong: tell tester to try a different method, suggest which one
@@ -420,7 +419,7 @@ export class Pilot {
420
419
  Be concise and specific. Tester will follow your plan.
421
420
  `, 'pilot.planTest', { tools: true, planningOnly: true, maxToolRoundtrips: 3, task });
422
421
  }
423
- async reviewNewPage(task, currentState) {
422
+ async reviewNewPage(task, currentState, testerConversation) {
424
423
  if (!this.conversation)
425
424
  return '';
426
425
  tag('substep').log('Pilot reviewing new page...');
@@ -430,7 +429,13 @@ export class Pilot {
430
429
  if (!pageSummary)
431
430
  return '';
432
431
  const stateContext = this.buildStateContext(currentState);
432
+ const toolCalls = testerConversation
433
+ .getToolExecutions()
434
+ .filter((t) => t.wasSuccessful)
435
+ .slice(-this.stepsToReview);
436
+ const actionsContext = this.formatActions(toolCalls);
433
437
  this.conversation.cleanupTag('page_summary', '...trimmed...', 1);
438
+ this.conversation.cleanupTag('recent_actions', '...trimmed...', 2);
434
439
  return this.sendToPilot(dedent `
435
440
  Navigated to new page.
436
441
  START URL: ${task.startUrl}
@@ -443,6 +448,10 @@ export class Pilot {
443
448
  ${pageSummary}
444
449
  </page_summary>
445
450
 
451
+ <recent_actions>
452
+ ${actionsContext || 'None'}
453
+ </recent_actions>
454
+
446
455
  ${this.formatExpectations(task)}
447
456
 
448
457
  First: evaluate whether this navigation makes sense for the scenario goal. If the page is unrelated, instruct Tester to back() or reset(). Then plan next steps.
@@ -272,11 +272,9 @@ export const actionRule = dedent `
272
272
  I.fillField('Description', 'Hello world', '.editor'); // works for rich text / code editors too
273
273
  </example>
274
274
 
275
- I.fillField handles plain inputs, textareas, contenteditable regions, and rich text / code editors
276
- (Monaco, ProseMirror, CodeMirror, TipTap, Quill, Draft.js, Slate, etc.) transparently.
277
- ALWAYS use I.fillField for rich editors target the editor container or its nearest label/heading with a normal locator.
278
- Do NOT open the editor with raw JS (executeScript, page.evaluate), do NOT dispatch synthetic events,
279
- do NOT call the editor's own API (monaco.editor.setValue, view.dispatch, etc.) to write text.
275
+ I.fillField handles plain inputs, textareas, contenteditable regions, and rich text / code editors transparently.
276
+ ALWAYS use I.fillField for rich text / code editors — target the editor container or its nearest label/heading with a normal locator.
277
+ If I.fillField does not work, I.type into the focused element is the fallback.
280
278
 
281
279
  ### I.type
282
280
 
@@ -0,0 +1,117 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import dedent from 'dedent';
4
+ import { outputPath } from "../config.js";
5
+ import { Stats } from "../stats.js";
6
+ export class SessionAnalyst {
7
+ emoji = '🧐';
8
+ provider;
9
+ constructor(provider) {
10
+ this.provider = provider;
11
+ }
12
+ async analyze(tests) {
13
+ const eligible = tests.filter((t) => t.startTime != null);
14
+ if (eligible.length === 0)
15
+ return '';
16
+ const model = this.provider.getModelForAgent('analyst');
17
+ const customPrompt = this.provider.getSystemPromptForAgent('analyst', undefined);
18
+ const systemPrompt = dedent `
19
+ You write a brief end-of-session report after autonomous exploratory testing. Your reader is a developer who needs to know in seconds: what is broken, how to reproduce it, and which results were inconclusive.
20
+
21
+ Output MARKDOWN. No JSON, no preamble, no closing remarks. Start with the heading.
22
+
23
+ ## Clustering
24
+ Group by ROOT CAUSE, not by scenario. If three tests fail for the same dropdown, that is ONE defect listing all three test refs (#3, #5, #7). Do not produce one cluster per test.
25
+
26
+ ## Bucketing
27
+ Use the FINAL verdict (the test's \`result\` field) as the starting point. Mid-test errors that the automation recovered from do NOT make a passed test unreliable.
28
+
29
+ - **Defect** — real product bug. \`result: failed\` AND the failure reflects the app misbehaving (not the automation). The automation completed its interactions, the app contradicted the expected outcome. Severity required.
30
+ - **UX issue** — app works but the UI is ambiguous, controls are hidden, or labels are unclear. Worth flagging to design.
31
+ - **Execution issue** — the FINAL verdict is unreliable. Only two cases:
32
+ 1. \`result: failed\` AND the failure was automation, environment, or UI/UX (locator missing, timeout, AI loop, navigation stuck, modal trapped focus, no accessible label) — i.e. the test could not conclude whether the app works.
33
+ 2. \`result: passed\` AND clear evidence in the log shows the user-visible goal was NOT achieved (no confirmation visible, no state change verified, the assertion was vacuous).
34
+
35
+ A test that passed and shows no contrary evidence belongs in NO section. Do not list passed tests just because the log contains intermediate retries or recovered failures.
36
+
37
+ ## Severity emoji (defects only)
38
+ - 🔴 critical or high — core flow blocked, data loss, security
39
+ - 🟡 medium — partial breakage with workaround
40
+ - 🟢 low — cosmetic
41
+
42
+ ## Required format
43
+
44
+ # Session Analysis
45
+
46
+ <one sentence: total tests, defect count, headline finding>
47
+
48
+ ## Defects
49
+
50
+ ### 🔴 <plain-English title of the BUG, not the scenario name>
51
+ Affects: #3, #5, #7
52
+ Reproduce:
53
+ 1. <concrete UI step a person can replay>
54
+ 2. <next step>
55
+ Evidence: <one short observation from the test log>
56
+
57
+ ### 🟡 <next defect>
58
+ ...
59
+
60
+ ## UX issues
61
+
62
+ - **<title>** — #4
63
+ <one short evidence line>
64
+
65
+ ## Execution Issues
66
+
67
+ - **<short test name or scenario phrase>** — <plain-English one-liner: what made the result unreliable>
68
+ - **<…>** — <…>
69
+
70
+ ## Rules
71
+ - Defects first, sorted by severity descending. Omit any section that has zero entries.
72
+ - Defect title describes the BUG ("Run-type dropdown does not filter"), never the scenario name.
73
+ - Reproduce steps are concrete UI actions derived from the log: URL + clicks + inputs. Imperative, one short line each.
74
+ - Evidence is the smallest factual observation from notes/steps that supports the claim — what was OBSERVED in the page (HTML, message, missing element). Never quote the test's \`result\` field as evidence; that is a tautology.
75
+ - **Execution Issues** entries must explain what actually went wrong in concrete terms a human understands: "could not find a Submit button after navigation", "page reloaded before the assertion ran", "passed without ever seeing a confirmation message", "marked failed but the new item appears in the list", "modal trapped focus and tests could not click outside", "ARIA tree had no labelled controls". Avoid jargon like "locator failed" without context. Never write category prefixes ("execution:", "false-positive:") — the section header already says it. No emoji on these entries.
76
+ - Do NOT include a passed test in any section unless evidence proves its goal was not achieved. Intermediate retries or recovered errors in the log are not grounds for listing a passed test.
77
+ - No editorialising, no restating the scenario verbatim, no closing summary.
78
+
79
+ ${customPrompt || ''}
80
+ `;
81
+ const userPayload = dedent `
82
+ ${eligible.length} tests were executed in this session.
83
+
84
+ ${eligible.map((t, i) => this.serializeTest(t, i + 1)).join('\n\n')}
85
+ `;
86
+ const response = await this.provider.chat([
87
+ { role: 'system', content: systemPrompt },
88
+ { role: 'user', content: userPayload },
89
+ ], model, { agentName: 'analyst' });
90
+ return (response?.text || '').trim();
91
+ }
92
+ writeReport(markdown) {
93
+ const filePath = outputPath('reports', `${Stats.sessionLabel()}.md`);
94
+ const dir = path.dirname(filePath);
95
+ if (!existsSync(dir))
96
+ mkdirSync(dir, { recursive: true });
97
+ writeFileSync(filePath, markdown);
98
+ return filePath;
99
+ }
100
+ serializeTest(test, ref) {
101
+ const log = test
102
+ .getLog()
103
+ .slice(-30)
104
+ .map((entry) => ` - [${entry.type}] ${entry.content}`)
105
+ .join('\n');
106
+ return dedent `
107
+ <test ref="#${ref}">
108
+ url: ${test.startUrl || '/'}
109
+ scenario: ${test.scenario}
110
+ result: ${test.result || 'unknown'}
111
+ expected: ${test.expected.join(' | ') || '(none)'}
112
+ log:
113
+ ${log}
114
+ </test>
115
+ `;
116
+ }
117
+ }
@@ -216,7 +216,7 @@ export class Tester extends TaskAgent {
216
216
  nextStep += await this.reinjectContextIfNeeded(iteration, currentState);
217
217
  nextStep += await this.prepareInstructionsForNextStep(task);
218
218
  if (isNewPage && this.pilot) {
219
- const guidance = await this.pilot.reviewNewPage(task, currentState);
219
+ const guidance = await this.pilot.reviewNewPage(task, currentState, conversation);
220
220
  if (guidance)
221
221
  nextStep += `\n\n${guidance}`;
222
222
  }
@@ -388,6 +388,7 @@ export class Tester extends TaskAgent {
388
388
  this.previousUrl = currentUrl;
389
389
  this.previousStateHash = currentStateHash;
390
390
  let context = '';
391
+ const focusArea = detectFocusArea(currentState.ariaSnapshot);
391
392
  const focusedElement = extractFocusedElement(currentState.ariaSnapshot);
392
393
  if (focusedElement) {
393
394
  const isTextInput = ['textbox', 'combobox', 'searchbox'].includes(focusedElement.role);
@@ -403,6 +404,17 @@ export class Tester extends TaskAgent {
403
404
  <no_focus>
404
405
  No element is focused
405
406
  </no_focus>
407
+ `;
408
+ }
409
+ if (focusArea.detected) {
410
+ const areaName = focusArea.name ? ` "${focusArea.name}"` : '';
411
+ context += dedent `
412
+ <focus_scope>
413
+ A ${focusArea.type}${areaName} is currently open above the page.
414
+ Scope all interactions to elements inside this ${focusArea.type}.
415
+ Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it — prefer the locator inside the ${focusArea.type}.
416
+ Use <page_aria> to confirm the element you target is actually inside the ${focusArea.type}.
417
+ </focus_scope>
406
418
  `;
407
419
  }
408
420
  if (currentState.isInsideIframe) {
@@ -462,7 +474,6 @@ export class Tester extends TaskAgent {
462
474
  `;
463
475
  return context;
464
476
  }
465
- const focusArea = detectFocusArea(currentState.ariaSnapshot);
466
477
  if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
467
478
  const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash);
468
479
  if (overlaySection) {
@@ -19,17 +19,17 @@ export class BaseCommand {
19
19
  if (this.suggestions.length === 0)
20
20
  return;
21
21
  const prefix = isInteractive() ? '/' : `${getCliName()} `;
22
- tag('info').log('');
23
- tag('info').log(chalk.bold('Suggested:'));
22
+ const commandWidth = this.suggestions.reduce((max, s) => (s.command ? Math.max(max, prefix.length + s.command.length) : max), 0);
23
+ const lines = [chalk.bold('Suggested:')];
24
24
  for (const { command, hint } of this.suggestions) {
25
- tag('info').log('');
26
25
  if (!command) {
27
- tag('info').log(chalk.dim(hint));
26
+ lines.push(` ${chalk.dim(hint)}`);
28
27
  continue;
29
28
  }
30
- tag('info').log(chalk.dim(`${hint}:`));
31
- tag('info').log(` ${chalk.yellow(`${prefix}${command}`)}`);
29
+ const cmd = `${prefix}${command}`.padEnd(commandWidth);
30
+ lines.push(` ${chalk.yellow(cmd)} ${chalk.dim(hint)}`);
32
31
  }
32
+ tag('info').log(lines.join('\n'));
33
33
  }
34
34
  parseArgs(args) {
35
35
  const cmd = new Command();
@@ -2,6 +2,7 @@ import { BaseCommand } from './base-command.js';
2
2
  export class DrillCommand extends BaseCommand {
3
3
  name = 'drill';
4
4
  description = 'Drill all components on current page to learn interactions';
5
+ aliases = ['driller'];
5
6
  suggestions = [
6
7
  { command: 'research', hint: 'see UI map first' },
7
8
  { command: 'navigate <page>', hint: 'go to another page' },
@@ -13,7 +14,7 @@ export class DrillCommand extends BaseCommand {
13
14
  if (!state) {
14
15
  throw new Error('No active page to drill');
15
16
  }
16
- await this.explorBot.agentBosun().drill({
17
+ await this.explorBot.agentDriller().drill({
17
18
  knowledgePath,
18
19
  maxComponents,
19
20
  interactive: true,
@@ -24,7 +25,7 @@ export class DrillCommand extends BaseCommand {
24
25
  return match ? match[1] : undefined;
25
26
  }
26
27
  parseMaxArg(args) {
27
- const match = args.match(/--max\s+(\d+)/);
28
+ const match = args.match(/--max-components\s+(\d+)/);
28
29
  return match ? Number.parseInt(match[1], 10) : undefined;
29
30
  }
30
31
  }
@@ -8,6 +8,7 @@ export class ExitCommand extends BaseCommand {
8
8
  description = 'Exit the application';
9
9
  aliases = ['quit'];
10
10
  async execute(_args) {
11
+ await this.explorBot.printSessionAnalysis();
11
12
  await this.explorBot.getExplorer().stop();
12
13
  if (Stats.hasActivity()) {
13
14
  await new Promise((resolve) => {
@@ -68,6 +68,7 @@ export class ExploreCommand extends BaseCommand {
68
68
  await this.explorBot.visit(mainUrl);
69
69
  const savedPath = this.explorBot.savePlans(this.completedPlans);
70
70
  this.printResults();
71
+ await this.explorBot.printSessionAnalysis();
71
72
  this.printNextSteps(savedPath);
72
73
  }
73
74
  async runAllStyles(pageUrl, feature, parentPlan, completedPlans) {
@@ -4,7 +4,7 @@ import { Box, Text, useInput } from 'ink';
4
4
  import React, { useEffect, useState } from 'react';
5
5
  import { AddRuleCommand } from '../commands/add-rule-command.js';
6
6
  import InputReadline from './InputReadline.js';
7
- const KNOWN_AGENTS = ['researcher', 'tester', 'planner', 'pilot', 'captain', 'bosun', 'navigator'];
7
+ const KNOWN_AGENTS = ['researcher', 'tester', 'planner', 'pilot', 'captain', 'driller', 'navigator'];
8
8
  const AddRule = ({ initialAgent = '', initialName = '', onComplete, onCancel }) => {
9
9
  const [agent, setAgent] = useState(initialAgent);
10
10
  const [ruleName, setRuleName] = useState(initialName);
@@ -1,8 +1,8 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ActionResult } from "./action-result.js";
4
- import { Bosun } from "./ai/bosun.js";
5
4
  import { Captain } from "./ai/captain.js";
5
+ import { Driller } from "./ai/driller.js";
6
6
  import { ExperienceCompactor } from "./ai/experience-compactor.js";
7
7
  import { Fisherman } from "./ai/fisherman.js";
8
8
  import { Historian } from "./ai/historian.js";
@@ -13,6 +13,7 @@ import { AIProvider } from "./ai/provider.js";
13
13
  import { Quartermaster } from "./ai/quartermaster.js";
14
14
  import { Rerunner } from "./ai/rerunner.js";
15
15
  import { Researcher } from "./ai/researcher.js";
16
+ import { SessionAnalyst } from "./ai/session-analyst.js";
16
17
  import { Tester } from "./ai/tester.js";
17
18
  import { createAgentTools } from "./ai/tools.js";
18
19
  import { ApiClient } from "./api/api-client.js";
@@ -24,6 +25,7 @@ import Explorer from "./explorer.js";
24
25
  import { KnowledgeTracker } from "./knowledge-tracker.js";
25
26
  import { Plan } from "./test-plan.js";
26
27
  import { setVerboseMode, tag } from "./utils/logger.js";
28
+ import { relativeToCwd } from "./utils/next-steps.js";
27
29
  import { sanitizeFilename } from "./utils/strings.js";
28
30
  export class ExplorBot {
29
31
  configParser;
@@ -38,6 +40,8 @@ export class ExplorBot {
38
40
  lastPlanError = null;
39
41
  lastSavedPlanPath = null;
40
42
  agents = {};
43
+ sessionPlans = [];
44
+ lastReportedTestCount = 0;
41
45
  constructor(options = {}) {
42
46
  this.options = options;
43
47
  this.configParser = ConfigParser.getInstance();
@@ -239,14 +243,15 @@ export class ExplorBot {
239
243
  }
240
244
  return this.agents.rerunner;
241
245
  }
242
- agentBosun() {
243
- return (this.agents.bosun ||= this.createAgent(({ ai, explorer }) => {
244
- const researcher = this.agentResearcher();
246
+ agentDriller() {
247
+ return (this.agents.driller ||= this.createAgent(({ ai, explorer }) => {
245
248
  const navigator = this.agentNavigator();
246
- const tools = createAgentTools({ explorer, researcher, navigator });
247
- return new Bosun(explorer, ai, researcher, navigator, tools);
249
+ return new Driller(explorer, ai, navigator);
248
250
  }));
249
251
  }
252
+ agentSessionAnalyst() {
253
+ return (this.agents.sessionAnalyst ||= this.createAgent(({ ai }) => new SessionAnalyst(ai)));
254
+ }
250
255
  agentFisherman() {
251
256
  const fishermanConfig = this.config.ai?.agents?.fisherman;
252
257
  const hasApiConfig = !!this.config.api;
@@ -309,7 +314,7 @@ export class ExplorBot {
309
314
  }
310
315
  this.lastPlanError = null;
311
316
  try {
312
- this.currentPlan = await planner.plan(feature, opts.style, opts.extend, opts.completedPlans);
317
+ this.setCurrentPlan(await planner.plan(feature, opts.style, opts.extend, opts.completedPlans));
313
318
  }
314
319
  catch (err) {
315
320
  this.lastPlanError = err instanceof Error ? err : new Error(String(err));
@@ -376,10 +381,45 @@ export class ExplorBot {
376
381
  if (!existsSync(planPath)) {
377
382
  throw new Error(`Plan file not found: ${planPath}`);
378
383
  }
379
- this.currentPlan = Plan.fromMarkdown(planPath);
384
+ this.setCurrentPlan(Plan.fromMarkdown(planPath));
380
385
  return this.currentPlan;
381
386
  }
382
387
  setCurrentPlan(plan) {
383
388
  this.currentPlan = plan;
389
+ if (plan && !this.sessionPlans.includes(plan)) {
390
+ this.sessionPlans.push(plan);
391
+ }
392
+ }
393
+ getSessionTests() {
394
+ return this.sessionPlans.flatMap((p) => p.tests.filter((t) => t.startTime != null));
395
+ }
396
+ async printSessionAnalysis() {
397
+ const analystConfig = this.config.ai?.agents?.analyst;
398
+ if (analystConfig?.enabled === false)
399
+ return;
400
+ const tests = this.getSessionTests();
401
+ if (tests.length === 0)
402
+ return;
403
+ if (tests.length === this.lastReportedTestCount)
404
+ return;
405
+ try {
406
+ const markdown = await this.agentSessionAnalyst().analyze(tests);
407
+ if (!markdown) {
408
+ this.lastReportedTestCount = tests.length;
409
+ return;
410
+ }
411
+ tag('multiline').log(markdown);
412
+ const filePath = this.agentSessionAnalyst().writeReport(markdown);
413
+ tag('info').log(`Session report saved: ${relativeToCwd(filePath)}`);
414
+ const reporter = this.explorer?.getReporter();
415
+ if (reporter?.isEnabled()) {
416
+ await reporter.setRunDescription(markdown);
417
+ }
418
+ this.lastReportedTestCount = tests.length;
419
+ }
420
+ catch (error) {
421
+ const message = error instanceof Error ? error.message : String(error);
422
+ tag('warning').log(`Session analysis failed: ${message}`);
423
+ }
384
424
  }
385
425
  }
@@ -15,8 +15,9 @@ import { KnowledgeTracker } from './knowledge-tracker.js';
15
15
  import { PlaywrightRecorder } from "./playwright-recorder.js";
16
16
  import { Reporter } from "./reporter.js";
17
17
  import { StateManager } from './state-manager.js';
18
+ import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from "./utils/html.js";
18
19
  import { createDebug, log, tag } from './utils/logger.js';
19
- import { WebElement, extractElementData } from "./utils/web-element.js";
20
+ import { WebElement } from "./utils/web-element.js";
20
21
  const debugLog = createDebug('explorbot:explorer');
21
22
  const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
22
23
  const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
@@ -270,11 +271,11 @@ class Explorer {
270
271
  async getEidxInContainer(containerCss) {
271
272
  const page = this.playwrightHelper.page;
272
273
  try {
273
- const selector = containerCss ? `${containerCss} [data-explorbot-eidx]` : '[data-explorbot-eidx]';
274
+ const selector = containerCss ? `${containerCss} [${ELEMENT_EXTRACTION_CONFIG.attrs.eidx}]` : `[${ELEMENT_EXTRACTION_CONFIG.attrs.eidx}]`;
274
275
  const elements = await page.locator(selector).all();
275
276
  const result = [];
276
277
  for (const el of elements) {
277
- const attr = await el.getAttribute('data-explorbot-eidx');
278
+ const attr = await el.getAttribute(ELEMENT_EXTRACTION_CONFIG.attrs.eidx);
278
279
  if (attr)
279
280
  result.push(attr);
280
281
  }
@@ -293,7 +294,7 @@ class Explorer {
293
294
  const page = this.playwrightHelper.page;
294
295
  const base = container ? page.locator(container) : page;
295
296
  const el = locator.startsWith('//') ? base.locator(`xpath=${locator}`) : base.locator(locator);
296
- return await el.first().getAttribute('data-explorbot-eidx');
297
+ return await el.first().getAttribute(ELEMENT_EXTRACTION_CONFIG.attrs.eidx);
297
298
  }
298
299
  catch (error) {
299
300
  if (this.isFatalBrowserError(error)) {
@@ -644,21 +645,21 @@ export async function annotatePageElements(page) {
644
645
  const elements = [];
645
646
  for (const [role, entries] of byRole) {
646
647
  try {
647
- const rawList = await page.getByRole(role).evaluateAll((domElements, [data, extractFnStr]) => {
648
+ const rawList = await page.getByRole(role).evaluateAll((domElements, [data, extractFnStr, config]) => {
648
649
  const extract = new Function(`return ${extractFnStr}`)();
649
650
  const results = [];
650
651
  let ariaIdx = 0;
651
652
  for (const el of domElements) {
652
653
  if (ariaIdx >= data.length)
653
654
  break;
654
- el.setAttribute('data-explorbot-eidx', data[ariaIdx].ref);
655
- const elData = extract(el);
655
+ el.setAttribute(config.attrs.eidx, data[ariaIdx].ref);
656
+ const elData = extract(el, config);
656
657
  if (elData)
657
658
  results.push(elData);
658
659
  ariaIdx++;
659
660
  }
660
661
  return results;
661
- }, [entries, extractElementData.toString()]);
662
+ }, [entries, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG]);
662
663
  for (const raw of rawList) {
663
664
  elements.push(WebElement.fromRawData(raw, role));
664
665
  }
@@ -14,8 +14,19 @@ export class Reporter {
14
14
  if (this.reporterEnabled && (!process.env.TESTOMATIO || config?.html)) {
15
15
  this.configureHtmlPipe();
16
16
  }
17
- const pipe = process.env.TESTOMATIO && config?.html ? 'both' : process.env.TESTOMATIO ? 'testomatio' : 'html';
18
- debugLog('Reporter initialized', { enabled: this.reporterEnabled, pipe });
17
+ if (this.reporterEnabled && config?.markdown) {
18
+ this.configureMarkdownPipe();
19
+ }
20
+ if (this.reporterEnabled) {
21
+ this.configureRunGroup(config?.runGroup);
22
+ }
23
+ debugLog('Reporter initialized', {
24
+ enabled: this.reporterEnabled,
25
+ testomatio: Boolean(process.env.TESTOMATIO),
26
+ html: Boolean(process.env.TESTOMATIO_HTML_REPORT_SAVE),
27
+ markdown: Boolean(process.env.TESTOMATIO_MARKDOWN_REPORT_SAVE),
28
+ runGroup: process.env.TESTOMATIO_RUNGROUP_TITLE || null,
29
+ });
19
30
  }
20
31
  buildTitle() {
21
32
  if (process.env.TESTOMATIO_TITLE)
@@ -39,7 +50,31 @@ export class Reporter {
39
50
  configureHtmlPipe() {
40
51
  process.env.TESTOMATIO_HTML_REPORT_SAVE = '1';
41
52
  process.env.TESTOMATIO_HTML_REPORT_FOLDER = outputPath('reports');
42
- debugLog('HTML report pipe configured', { folder: process.env.TESTOMATIO_HTML_REPORT_FOLDER });
53
+ process.env.TESTOMATIO_HTML_FILENAME = `${Stats.sessionLabel()}.html`;
54
+ debugLog('HTML report pipe configured', {
55
+ folder: process.env.TESTOMATIO_HTML_REPORT_FOLDER,
56
+ filename: process.env.TESTOMATIO_HTML_FILENAME,
57
+ });
58
+ }
59
+ configureMarkdownPipe() {
60
+ process.env.TESTOMATIO_MARKDOWN_REPORT_SAVE = '1';
61
+ process.env.TESTOMATIO_MARKDOWN_REPORT_FOLDER = outputPath('reports');
62
+ process.env.TESTOMATIO_MARKDOWN_FILENAME = `${Stats.sessionLabel()}-tests.md`;
63
+ debugLog('Markdown report pipe configured', {
64
+ folder: process.env.TESTOMATIO_MARKDOWN_REPORT_FOLDER,
65
+ filename: process.env.TESTOMATIO_MARKDOWN_FILENAME,
66
+ });
67
+ }
68
+ configureRunGroup(runGroup) {
69
+ if (process.env.TESTOMATIO_RUNGROUP_TITLE)
70
+ return;
71
+ if (runGroup === null)
72
+ return;
73
+ if (runGroup) {
74
+ process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
75
+ return;
76
+ }
77
+ process.env.TESTOMATIO_RUNGROUP_TITLE = `Explorbot ${new Date().toISOString().slice(0, 10)}`;
43
78
  }
44
79
  async startRun() {
45
80
  if (this.isRunStarted) {
@@ -190,6 +225,32 @@ export class Reporter {
190
225
  isEnabled() {
191
226
  return this.isRunStarted;
192
227
  }
228
+ async setRunDescription(text) {
229
+ if (!this.isRunStarted)
230
+ return;
231
+ if (!process.env.TESTOMATIO)
232
+ return;
233
+ const runId = this.client.runId;
234
+ if (!runId)
235
+ return;
236
+ const baseUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
237
+ const url = `${baseUrl}/api/reporter/${runId}`;
238
+ try {
239
+ const response = await fetch(url, {
240
+ method: 'PUT',
241
+ headers: { 'Content-Type': 'application/json' },
242
+ body: JSON.stringify({ api_key: process.env.TESTOMATIO, description: text }),
243
+ });
244
+ if (!response.ok) {
245
+ debugLog('Run description update failed:', response.status, response.statusText);
246
+ return;
247
+ }
248
+ debugLog('Run description updated');
249
+ }
250
+ catch (error) {
251
+ debugLog('Failed to update run description:', error);
252
+ }
253
+ }
193
254
  extractLastNoteMessage(test) {
194
255
  const notes = Object.values(test.notes);
195
256
  if (notes.length === 0)
@@ -70,8 +70,8 @@ export class StateManager {
70
70
  }
71
71
  /**
72
72
  * Extract state path from full URL
73
- * Removes domain, port, protocol, and query params
74
- * Keeps path and hash: /path/to/page#section
73
+ * Removes domain, port, protocol
74
+ * Keeps path, query, and hash: /path/to/page?tab=users#section
75
75
  */
76
76
  /**
77
77
  * Update current state from ActionResult and record transition if state changed
@@ -418,7 +418,8 @@ export class StateManager {
418
418
  export function normalizeUrl(url) {
419
419
  try {
420
420
  const parsed = new URL(url, 'http://localhost');
421
- return parsed.pathname.replace(/^\/+|\/+$/g, '');
421
+ const path = parsed.pathname.replace(/^\/+|\/+$/g, '');
422
+ return `${path}${parsed.search}${parsed.hash}`;
422
423
  }
423
424
  catch {
424
425
  return url.replace(/^\/+|\/+$/g, '');