explorbot 0.1.11 → 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 (72) hide show
  1. package/README.md +12 -2
  2. package/bin/explorbot-cli.ts +21 -21
  3. package/dist/bin/explorbot-cli.js +3 -3
  4. package/dist/package.json +4 -3
  5. package/dist/rules/researcher/container-rules.md +2 -0
  6. package/dist/src/action-result.js +2 -1
  7. package/dist/src/action.js +5 -10
  8. package/dist/src/ai/captain.js +0 -2
  9. package/dist/src/ai/driller.js +1108 -0
  10. package/dist/src/ai/historian/codeceptjs.js +2 -2
  11. package/dist/src/ai/historian/experience.js +1 -0
  12. package/dist/src/ai/historian/playwright.js +4 -4
  13. package/dist/src/ai/historian/screencast.js +121 -0
  14. package/dist/src/ai/historian.js +5 -3
  15. package/dist/src/ai/pilot.js +31 -22
  16. package/dist/src/ai/rules.js +3 -5
  17. package/dist/src/ai/session-analyst.js +117 -0
  18. package/dist/src/ai/tester.js +13 -2
  19. package/dist/src/commands/base-command.js +6 -6
  20. package/dist/src/commands/drill-command.js +3 -2
  21. package/dist/src/commands/exit-command.js +1 -0
  22. package/dist/src/commands/explore-command.js +20 -3
  23. package/dist/src/components/AddRule.js +1 -1
  24. package/dist/src/explorbot.js +52 -9
  25. package/dist/src/explorer.js +11 -9
  26. package/dist/src/reporter.js +68 -4
  27. package/dist/src/state-manager.js +4 -3
  28. package/dist/src/stats.js +5 -0
  29. package/dist/src/utils/aria.js +354 -529
  30. package/dist/src/utils/hooks-runner.js +2 -8
  31. package/dist/src/utils/html.js +371 -0
  32. package/dist/src/utils/strings.js +15 -0
  33. package/dist/src/utils/unique-names.js +12 -1
  34. package/dist/src/utils/url-matcher.js +6 -1
  35. package/dist/src/utils/web-element.js +27 -24
  36. package/dist/src/utils/xpath.js +1 -1
  37. package/package.json +4 -3
  38. package/rules/researcher/container-rules.md +2 -0
  39. package/src/action-result.ts +2 -1
  40. package/src/action.ts +5 -12
  41. package/src/ai/captain.ts +0 -2
  42. package/src/ai/driller.ts +1194 -0
  43. package/src/ai/historian/codeceptjs.ts +2 -2
  44. package/src/ai/historian/experience.ts +3 -2
  45. package/src/ai/historian/playwright.ts +5 -5
  46. package/src/ai/historian/screencast.ts +133 -0
  47. package/src/ai/historian.ts +7 -5
  48. package/src/ai/pilot.ts +31 -21
  49. package/src/ai/rules.ts +3 -5
  50. package/src/ai/session-analyst.ts +133 -0
  51. package/src/ai/tester.ts +15 -2
  52. package/src/commands/base-command.ts +6 -6
  53. package/src/commands/drill-command.ts +3 -2
  54. package/src/commands/exit-command.ts +1 -0
  55. package/src/commands/explore-command.ts +22 -3
  56. package/src/components/AddRule.tsx +1 -1
  57. package/src/config.ts +10 -0
  58. package/src/explorbot.ts +59 -11
  59. package/src/explorer.ts +11 -9
  60. package/src/reporter.ts +68 -4
  61. package/src/state-manager.ts +4 -3
  62. package/src/stats.ts +7 -0
  63. package/src/utils/aria.ts +367 -537
  64. package/src/utils/hooks-runner.ts +2 -6
  65. package/src/utils/html.ts +381 -0
  66. package/src/utils/strings.ts +17 -0
  67. package/src/utils/unique-names.ts +13 -0
  68. package/src/utils/url-matcher.ts +5 -1
  69. package/src/utils/web-element.ts +31 -28
  70. package/src/utils/xpath.ts +1 -1
  71. package/dist/src/ai/bosun.js +0 -456
  72. package/src/ai/bosun.ts +0 -571
@@ -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();
@@ -218,7 +222,10 @@ export class ExplorBot {
218
222
  return (this.agents.historian ||= this.createAgent(({ ai, explorer, config }) => {
219
223
  const experienceTracker = explorer.getStateManager().getExperienceTracker();
220
224
  const reporter = explorer.getReporter();
221
- return new Historian(ai, experienceTracker, reporter, explorer.getStateManager(), config, explorer.getPlaywrightRecorder());
225
+ return new Historian(ai, experienceTracker, reporter, explorer.getStateManager(), config, {
226
+ recorder: explorer.getPlaywrightRecorder(),
227
+ helper: explorer.playwrightHelper,
228
+ });
222
229
  }));
223
230
  }
224
231
  agentRerunner() {
@@ -236,14 +243,15 @@ export class ExplorBot {
236
243
  }
237
244
  return this.agents.rerunner;
238
245
  }
239
- agentBosun() {
240
- return (this.agents.bosun ||= this.createAgent(({ ai, explorer }) => {
241
- const researcher = this.agentResearcher();
246
+ agentDriller() {
247
+ return (this.agents.driller ||= this.createAgent(({ ai, explorer }) => {
242
248
  const navigator = this.agentNavigator();
243
- const tools = createAgentTools({ explorer, researcher, navigator });
244
- return new Bosun(explorer, ai, researcher, navigator, tools);
249
+ return new Driller(explorer, ai, navigator);
245
250
  }));
246
251
  }
252
+ agentSessionAnalyst() {
253
+ return (this.agents.sessionAnalyst ||= this.createAgent(({ ai }) => new SessionAnalyst(ai)));
254
+ }
247
255
  agentFisherman() {
248
256
  const fishermanConfig = this.config.ai?.agents?.fisherman;
249
257
  const hasApiConfig = !!this.config.api;
@@ -306,7 +314,7 @@ export class ExplorBot {
306
314
  }
307
315
  this.lastPlanError = null;
308
316
  try {
309
- 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));
310
318
  }
311
319
  catch (err) {
312
320
  this.lastPlanError = err instanceof Error ? err : new Error(String(err));
@@ -373,10 +381,45 @@ export class ExplorBot {
373
381
  if (!existsSync(planPath)) {
374
382
  throw new Error(`Plan file not found: ${planPath}`);
375
383
  }
376
- this.currentPlan = Plan.fromMarkdown(planPath);
384
+ this.setCurrentPlan(Plan.fromMarkdown(planPath));
377
385
  return this.currentPlan;
378
386
  }
379
387
  setCurrentPlan(plan) {
380
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
+ }
381
424
  }
382
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)) {
@@ -612,6 +613,7 @@ function toCodeceptjsTest(test) {
612
613
  codeceptjsTest.fullTitle = () => `${parent.title} ${test.scenario}`;
613
614
  codeceptjsTest.state = 'pending';
614
615
  codeceptjsTest.notes = test.getPrintableNotes();
616
+ codeceptjsTest._explorbotTest = test;
615
617
  return codeceptjsTest;
616
618
  }
617
619
  const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
@@ -629,7 +631,7 @@ function parseAriaRefs(ariaSnapshot) {
629
631
  return entries;
630
632
  }
631
633
  export async function annotatePageElements(page) {
632
- const ariaSnapshot = await page.locator('body').ariaSnapshot({ forAI: true });
634
+ const ariaSnapshot = await page.locator('body').ariaSnapshot({ mode: 'ai' });
633
635
  const refEntries = parseAriaRefs(ariaSnapshot);
634
636
  const byRole = new Map();
635
637
  for (const { role, name, ref } of refEntries) {
@@ -643,21 +645,21 @@ export async function annotatePageElements(page) {
643
645
  const elements = [];
644
646
  for (const [role, entries] of byRole) {
645
647
  try {
646
- const rawList = await page.getByRole(role).evaluateAll((domElements, [data, extractFnStr]) => {
648
+ const rawList = await page.getByRole(role).evaluateAll((domElements, [data, extractFnStr, config]) => {
647
649
  const extract = new Function(`return ${extractFnStr}`)();
648
650
  const results = [];
649
651
  let ariaIdx = 0;
650
652
  for (const el of domElements) {
651
653
  if (ariaIdx >= data.length)
652
654
  break;
653
- el.setAttribute('data-explorbot-eidx', data[ariaIdx].ref);
654
- const elData = extract(el);
655
+ el.setAttribute(config.attrs.eidx, data[ariaIdx].ref);
656
+ const elData = extract(el, config);
655
657
  if (elData)
656
658
  results.push(elData);
657
659
  ariaIdx++;
658
660
  }
659
661
  return results;
660
- }, [entries, extractElementData.toString()]);
662
+ }, [entries, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG]);
661
663
  for (const raw of rawList) {
662
664
  elements.push(WebElement.fromRawData(raw, role));
663
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) {
@@ -78,6 +113,7 @@ export class Reporter {
78
113
  const noteEntries = Object.entries(test.notes)
79
114
  .map(([timestampKey, note]) => ({
80
115
  startTime: note.startTime,
116
+ endTime: note.endTime,
81
117
  message: note.message,
82
118
  status: note.status,
83
119
  screenshot: note.screenshot,
@@ -105,7 +141,7 @@ export class Reporter {
105
141
  const step = {
106
142
  category: 'user',
107
143
  title: noteEntry.message,
108
- duration: 0,
144
+ duration: Math.max(0, Math.round(noteEntry.endTime - noteEntry.startTime)),
109
145
  status: noteEntry.status || 'none',
110
146
  steps: noteSteps.length > 0 ? noteSteps : undefined,
111
147
  };
@@ -148,6 +184,7 @@ export class Reporter {
148
184
  meta = Object.fromEntries(Object.entries(meta).filter(([, v]) => v));
149
185
  }
150
186
  const steps = this.combineStepsAndNotes(test, screenshotFile);
187
+ const durationMs = test.getDurationMs();
151
188
  const testData = {
152
189
  rid: test.id,
153
190
  title: test.scenario,
@@ -162,6 +199,7 @@ export class Reporter {
162
199
  files: Object.values(test.artifacts) || [],
163
200
  message: test.summary || this.extractLastNoteMessage(test) || '',
164
201
  meta,
202
+ time: durationMs != null ? Math.round(durationMs) : 0,
165
203
  };
166
204
  debugLog(testData);
167
205
  await this.client.addTestRun(status, testData);
@@ -187,6 +225,32 @@ export class Reporter {
187
225
  isEnabled() {
188
226
  return this.isRunStarted;
189
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
+ }
190
254
  extractLastNoteMessage(test) {
191
255
  const notes = Object.values(test.notes);
192
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, '');
package/dist/src/stats.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { uniqExplorationName } from "./utils/unique-names.js";
1
2
  export class Stats {
2
3
  static startTime = Date.now();
4
+ static sessionName = uniqExplorationName();
3
5
  static researches = 0;
4
6
  static tests = 0;
5
7
  static plans = 0;
@@ -42,4 +44,7 @@ export class Stats {
42
44
  const totalTokens = Object.values(Stats.models).reduce((sum, m) => sum + m.total, 0);
43
45
  return totalTokens > 0;
44
46
  }
47
+ static sessionLabel() {
48
+ return `${Stats.mode || 'session'}-${Stats.sessionName}`;
49
+ }
45
50
  }