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,5 +1,6 @@
1
1
  import figureSet from 'figures';
2
2
  import { getStyles } from '../ai/planner/styles.js';
3
+ import { outputPath } from '../config.js';
3
4
  import { Stats } from '../stats.js';
4
5
  import type { Plan } from '../test-plan.js';
5
6
  import { getCliName } from '../utils/cli-name.ts';
@@ -7,6 +8,7 @@ import { ErrorPageError } from '../utils/error-page.ts';
7
8
  import { tag } from '../utils/logger.js';
8
9
  import { jsonToTable } from '../utils/markdown-parser.js';
9
10
  import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
11
+ import { safeFilename } from '../utils/strings.ts';
10
12
  import { BaseCommand, type Suggestion } from './base-command.js';
11
13
 
12
14
  export class ExploreCommand extends BaseCommand {
@@ -71,6 +73,7 @@ export class ExploreCommand extends BaseCommand {
71
73
  if (mainUrl) await this.explorBot.visit(mainUrl);
72
74
  const savedPath = this.explorBot.savePlans(this.completedPlans);
73
75
  this.printResults();
76
+ await this.explorBot.printSessionAnalysis();
74
77
  this.printNextSteps(savedPath);
75
78
  }
76
79
 
@@ -152,11 +155,27 @@ export class ExploreCommand extends BaseCommand {
152
155
  }
153
156
 
154
157
  const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
155
- if (savedFiles.length > 0) {
156
- const commands = savedFiles.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
158
+ const screencasts = savedFiles.filter((f) => f.endsWith('.webm'));
159
+ const testFiles = savedFiles.filter((f) => !f.endsWith('.webm'));
160
+
161
+ if (testFiles.length > 0) {
162
+ const commands = testFiles.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
157
163
  commands.push({ label: 'List tests', command: `${cli} runs` });
158
164
  sections.push({
159
- label: `Generated tests (${savedFiles.length})`,
165
+ label: `Generated tests (${testFiles.length})`,
166
+ commands,
167
+ });
168
+ }
169
+
170
+ if (screencasts.length > 0) {
171
+ const commands = screencasts.map((f) => ({ label: '', command: relativeToCwd(f) }));
172
+ const screencastDir = relativeToCwd(outputPath('screencasts'));
173
+ const planSlugs = [...new Set(this.completedPlans.map((p) => safeFilename(p.title)).filter(Boolean))];
174
+ for (const slug of planSlugs) {
175
+ commands.push({ label: 'Browse plan', command: `ls ${screencastDir}/${slug}-*` });
176
+ }
177
+ sections.push({
178
+ label: `Screencasts (${screencasts.length})`,
160
179
  commands,
161
180
  });
162
181
  }
@@ -5,7 +5,7 @@ import React, { useEffect, useState } from 'react';
5
5
  import { AddRuleCommand } from '../commands/add-rule-command.js';
6
6
  import InputReadline from './InputReadline.js';
7
7
 
8
- const KNOWN_AGENTS = ['researcher', 'tester', 'planner', 'pilot', 'captain', 'bosun', 'navigator'];
8
+ const KNOWN_AGENTS = ['researcher', 'tester', 'planner', 'pilot', 'captain', 'driller', 'navigator'];
9
9
 
10
10
  interface AddRuleProps {
11
11
  initialAgent?: string;
package/src/config.ts CHANGED
@@ -107,8 +107,14 @@ interface PlannerAgentConfig extends AgentConfig {
107
107
  stylesDir?: string;
108
108
  }
109
109
 
110
+ interface ScreencastConfig {
111
+ size?: { width: number; height: number };
112
+ quality?: number;
113
+ }
114
+
110
115
  interface HistorianAgentConfig extends AgentConfig {
111
116
  framework?: 'codeceptjs' | 'playwright';
117
+ screencast?: boolean | ScreencastConfig;
112
118
  }
113
119
 
114
120
  interface AgentsConfig {
@@ -117,6 +123,7 @@ interface AgentsConfig {
117
123
  researcher?: ResearcherAgentConfig;
118
124
  planner?: PlannerAgentConfig;
119
125
  pilot?: PilotAgentConfig;
126
+ driller?: AgentConfig;
120
127
  'experience-compactor'?: AgentConfig;
121
128
  captain?: AgentConfig;
122
129
  quartermaster?: AgentConfig;
@@ -125,6 +132,7 @@ interface AgentsConfig {
125
132
  chief?: AgentConfig;
126
133
  curler?: AgentConfig;
127
134
  rerunner?: RerunnerAgentConfig;
135
+ analyst?: AgentConfig;
128
136
  }
129
137
 
130
138
  interface AIConfig {
@@ -173,6 +181,8 @@ interface ActionConfig {
173
181
  interface ReporterConfig {
174
182
  enabled?: boolean;
175
183
  html?: boolean;
184
+ markdown?: boolean;
185
+ runGroup?: string | null;
176
186
  }
177
187
 
178
188
  type ApiHookFn = (ctx: { headers: Record<string, string>; baseEndpoint: string }) => Promise<Record<string, string> | undefined> | Record<string, string> | undefined;
package/src/explorbot.ts CHANGED
@@ -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.ts';
4
- import { Bosun } from './ai/bosun.ts';
5
4
  import { Captain } from './ai/captain.ts';
5
+ import { Driller } from './ai/driller.ts';
6
6
  import { ExperienceCompactor } from './ai/experience-compactor.ts';
7
7
  import { Fisherman } from './ai/fisherman.ts';
8
8
  import { Historian } from './ai/historian.ts';
@@ -13,6 +13,7 @@ import { AIProvider } from './ai/provider.ts';
13
13
  import { Quartermaster } from './ai/quartermaster.ts';
14
14
  import { Rerunner } from './ai/rerunner.ts';
15
15
  import { Researcher } from './ai/researcher.ts';
16
+ import { SessionAnalyst } from './ai/session-analyst.ts';
16
17
  import { Tester } from './ai/tester.ts';
17
18
  import { createAgentTools } from './ai/tools.ts';
18
19
  import { ApiClient } from './api/api-client.ts';
@@ -25,8 +26,9 @@ import Explorer from './explorer.ts';
25
26
  import { KnowledgeTracker } from './knowledge-tracker.ts';
26
27
  import { WebPageState } from './state-manager.ts';
27
28
  import type { Suite } from './suite.ts';
28
- import { Plan } from './test-plan.ts';
29
+ import { Plan, type Test } from './test-plan.ts';
29
30
  import { setVerboseMode, tag } from './utils/logger.ts';
31
+ import { relativeToCwd } from './utils/next-steps.ts';
30
32
  import { sanitizeFilename } from './utils/strings.ts';
31
33
 
32
34
  export interface ExplorBotOptions {
@@ -55,6 +57,8 @@ export class ExplorBot {
55
57
  lastPlanError: Error | null = null;
56
58
  lastSavedPlanPath: string | null = null;
57
59
  private agents: Record<string, any> = {};
60
+ private sessionPlans: Plan[] = [];
61
+ private lastReportedTestCount = 0;
58
62
 
59
63
  constructor(options: ExplorBotOptions = {}) {
60
64
  this.options = options;
@@ -262,7 +266,10 @@ export class ExplorBot {
262
266
  return (this.agents.historian ||= this.createAgent(({ ai, explorer, config }) => {
263
267
  const experienceTracker = explorer.getStateManager().getExperienceTracker();
264
268
  const reporter = explorer.getReporter();
265
- return new Historian(ai, experienceTracker, reporter, explorer.getStateManager(), config, explorer.getPlaywrightRecorder());
269
+ return new Historian(ai, experienceTracker, reporter, explorer.getStateManager(), config, {
270
+ recorder: explorer.getPlaywrightRecorder(),
271
+ helper: explorer.playwrightHelper,
272
+ });
266
273
  }));
267
274
  }
268
275
 
@@ -281,15 +288,17 @@ export class ExplorBot {
281
288
  return this.agents.rerunner;
282
289
  }
283
290
 
284
- agentBosun(): Bosun {
285
- return (this.agents.bosun ||= this.createAgent(({ ai, explorer }) => {
286
- const researcher = this.agentResearcher();
291
+ agentDriller(): Driller {
292
+ return (this.agents.driller ||= this.createAgent(({ ai, explorer }) => {
287
293
  const navigator = this.agentNavigator();
288
- const tools = createAgentTools({ explorer, researcher, navigator });
289
- return new Bosun(explorer, ai, researcher, navigator, tools);
294
+ return new Driller(explorer, ai, navigator);
290
295
  }));
291
296
  }
292
297
 
298
+ agentSessionAnalyst(): SessionAnalyst {
299
+ return (this.agents.sessionAnalyst ||= this.createAgent(({ ai }) => new SessionAnalyst(ai)));
300
+ }
301
+
293
302
  agentFisherman(): Fisherman | null {
294
303
  const fishermanConfig = this.config.ai?.agents?.fisherman;
295
304
  const hasApiConfig = !!this.config.api;
@@ -362,7 +371,7 @@ export class ExplorBot {
362
371
  }
363
372
  this.lastPlanError = null;
364
373
  try {
365
- this.currentPlan = await planner.plan(feature, opts.style, opts.extend, opts.completedPlans);
374
+ this.setCurrentPlan(await planner.plan(feature, opts.style, opts.extend, opts.completedPlans));
366
375
  } catch (err) {
367
376
  this.lastPlanError = err instanceof Error ? err : new Error(String(err));
368
377
  tag('warning').log(`Planning failed: ${this.lastPlanError.message}`);
@@ -433,11 +442,50 @@ export class ExplorBot {
433
442
  throw new Error(`Plan file not found: ${planPath}`);
434
443
  }
435
444
 
436
- this.currentPlan = Plan.fromMarkdown(planPath);
437
- return this.currentPlan;
445
+ this.setCurrentPlan(Plan.fromMarkdown(planPath));
446
+ return this.currentPlan!;
438
447
  }
439
448
 
440
449
  setCurrentPlan(plan?: Plan): void {
441
450
  this.currentPlan = plan;
451
+ if (plan && !this.sessionPlans.includes(plan)) {
452
+ this.sessionPlans.push(plan);
453
+ }
454
+ }
455
+
456
+ getSessionTests(): Test[] {
457
+ return this.sessionPlans.flatMap((p) => p.tests.filter((t) => t.startTime != null));
458
+ }
459
+
460
+ async printSessionAnalysis(): Promise<void> {
461
+ const analystConfig = this.config.ai?.agents?.analyst;
462
+ if (analystConfig?.enabled === false) return;
463
+
464
+ const tests = this.getSessionTests();
465
+ if (tests.length === 0) return;
466
+ if (tests.length === this.lastReportedTestCount) return;
467
+
468
+ try {
469
+ const markdown = await this.agentSessionAnalyst().analyze(tests);
470
+ if (!markdown) {
471
+ this.lastReportedTestCount = tests.length;
472
+ return;
473
+ }
474
+
475
+ tag('multiline').log(markdown);
476
+
477
+ const filePath = this.agentSessionAnalyst().writeReport(markdown);
478
+ tag('info').log(`Session report saved: ${relativeToCwd(filePath)}`);
479
+
480
+ const reporter = this.explorer?.getReporter();
481
+ if (reporter?.isEnabled()) {
482
+ await reporter.setRunDescription(markdown);
483
+ }
484
+
485
+ this.lastReportedTestCount = tests.length;
486
+ } catch (error) {
487
+ const message = error instanceof Error ? error.message : String(error);
488
+ tag('warning').log(`Session analysis failed: ${message}`);
489
+ }
442
490
  }
443
491
  }
package/src/explorer.ts CHANGED
@@ -19,8 +19,9 @@ import { PlaywrightRecorder } from './playwright-recorder.ts';
19
19
  import { Reporter } from './reporter.ts';
20
20
  import { StateManager } from './state-manager.js';
21
21
  import { Test } from './test-plan.ts';
22
+ import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from './utils/html.ts';
22
23
  import { createDebug, log, tag } from './utils/logger.js';
23
- import { WebElement, extractElementData } from './utils/web-element.ts';
24
+ import { WebElement } from './utils/web-element.ts';
24
25
 
25
26
  declare global {
26
27
  namespace NodeJS {
@@ -337,11 +338,11 @@ class Explorer {
337
338
  async getEidxInContainer(containerCss: string | null): Promise<string[]> {
338
339
  const page = this.playwrightHelper.page;
339
340
  try {
340
- const selector = containerCss ? `${containerCss} [data-explorbot-eidx]` : '[data-explorbot-eidx]';
341
+ const selector = containerCss ? `${containerCss} [${ELEMENT_EXTRACTION_CONFIG.attrs.eidx}]` : `[${ELEMENT_EXTRACTION_CONFIG.attrs.eidx}]`;
341
342
  const elements = await page.locator(selector).all();
342
343
  const result: string[] = [];
343
344
  for (const el of elements) {
344
- const attr = await el.getAttribute('data-explorbot-eidx');
345
+ const attr = await el.getAttribute(ELEMENT_EXTRACTION_CONFIG.attrs.eidx);
345
346
  if (attr) result.push(attr);
346
347
  }
347
348
  return result;
@@ -359,7 +360,7 @@ class Explorer {
359
360
  const page = this.playwrightHelper.page;
360
361
  const base = container ? page.locator(container) : page;
361
362
  const el = locator.startsWith('//') ? base.locator(`xpath=${locator}`) : base.locator(locator);
362
- return await el.first().getAttribute('data-explorbot-eidx');
363
+ return await el.first().getAttribute(ELEMENT_EXTRACTION_CONFIG.attrs.eidx);
363
364
  } catch (error) {
364
365
  if (this.isFatalBrowserError(error)) {
365
366
  tag('warning').log(`getEidxByLocator: ${error instanceof Error ? error.message : error}`);
@@ -714,6 +715,7 @@ function toCodeceptjsTest(test: Test): any {
714
715
  codeceptjsTest.fullTitle = () => `${parent.title} ${test.scenario}`;
715
716
  codeceptjsTest.state = 'pending';
716
717
  codeceptjsTest.notes = test.getPrintableNotes();
718
+ codeceptjsTest._explorbotTest = test;
717
719
  return codeceptjsTest;
718
720
  }
719
721
 
@@ -733,7 +735,7 @@ function parseAriaRefs(ariaSnapshot: string): Array<{ role: string; name: string
733
735
  }
734
736
 
735
737
  export async function annotatePageElements(page: any): Promise<{ ariaSnapshot: string; elements: WebElement[] }> {
736
- const ariaSnapshot: string = await page.locator('body').ariaSnapshot({ forAI: true });
738
+ const ariaSnapshot: string = await page.locator('body').ariaSnapshot({ mode: 'ai' });
737
739
  const refEntries = parseAriaRefs(ariaSnapshot);
738
740
 
739
741
  const byRole = new Map<string, Array<{ name: string; ref: string }>>();
@@ -750,20 +752,20 @@ export async function annotatePageElements(page: any): Promise<{ ariaSnapshot: s
750
752
  for (const [role, entries] of byRole) {
751
753
  try {
752
754
  const rawList = await page.getByRole(role).evaluateAll(
753
- (domElements: Element[], [data, extractFnStr]: [Array<{ name: string; ref: string }>, string]) => {
755
+ (domElements: Element[], [data, extractFnStr, config]: [Array<{ name: string; ref: string }>, string, typeof ELEMENT_EXTRACTION_CONFIG]) => {
754
756
  const extract = new Function(`return ${extractFnStr}`)() as (el: Element) => any;
755
757
  const results: any[] = [];
756
758
  let ariaIdx = 0;
757
759
  for (const el of domElements) {
758
760
  if (ariaIdx >= data.length) break;
759
- el.setAttribute('data-explorbot-eidx', data[ariaIdx].ref);
760
- const elData = extract(el);
761
+ el.setAttribute(config.attrs.eidx, data[ariaIdx].ref);
762
+ const elData = extract(el, config);
761
763
  if (elData) results.push(elData);
762
764
  ariaIdx++;
763
765
  }
764
766
  return results;
765
767
  },
766
- [entries, extractElementData.toString()]
768
+ [entries, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG]
767
769
  );
768
770
  for (const raw of rawList) {
769
771
  elements.push(WebElement.fromRawData(raw, role));
package/src/reporter.ts CHANGED
@@ -33,8 +33,21 @@ export class Reporter {
33
33
  this.configureHtmlPipe();
34
34
  }
35
35
 
36
- const pipe = process.env.TESTOMATIO && config?.html ? 'both' : process.env.TESTOMATIO ? 'testomatio' : 'html';
37
- debugLog('Reporter initialized', { enabled: this.reporterEnabled, pipe });
36
+ if (this.reporterEnabled && config?.markdown) {
37
+ this.configureMarkdownPipe();
38
+ }
39
+
40
+ if (this.reporterEnabled) {
41
+ this.configureRunGroup(config?.runGroup);
42
+ }
43
+
44
+ debugLog('Reporter initialized', {
45
+ enabled: this.reporterEnabled,
46
+ testomatio: Boolean(process.env.TESTOMATIO),
47
+ html: Boolean(process.env.TESTOMATIO_HTML_REPORT_SAVE),
48
+ markdown: Boolean(process.env.TESTOMATIO_MARKDOWN_REPORT_SAVE),
49
+ runGroup: process.env.TESTOMATIO_RUNGROUP_TITLE || null,
50
+ });
38
51
  }
39
52
 
40
53
  private buildTitle(): string {
@@ -56,7 +69,31 @@ export class Reporter {
56
69
  private configureHtmlPipe(): void {
57
70
  process.env.TESTOMATIO_HTML_REPORT_SAVE = '1';
58
71
  process.env.TESTOMATIO_HTML_REPORT_FOLDER = outputPath('reports');
59
- debugLog('HTML report pipe configured', { folder: process.env.TESTOMATIO_HTML_REPORT_FOLDER });
72
+ process.env.TESTOMATIO_HTML_FILENAME = `${Stats.sessionLabel()}.html`;
73
+ debugLog('HTML report pipe configured', {
74
+ folder: process.env.TESTOMATIO_HTML_REPORT_FOLDER,
75
+ filename: process.env.TESTOMATIO_HTML_FILENAME,
76
+ });
77
+ }
78
+
79
+ private configureMarkdownPipe(): void {
80
+ process.env.TESTOMATIO_MARKDOWN_REPORT_SAVE = '1';
81
+ process.env.TESTOMATIO_MARKDOWN_REPORT_FOLDER = outputPath('reports');
82
+ process.env.TESTOMATIO_MARKDOWN_FILENAME = `${Stats.sessionLabel()}-tests.md`;
83
+ debugLog('Markdown report pipe configured', {
84
+ folder: process.env.TESTOMATIO_MARKDOWN_REPORT_FOLDER,
85
+ filename: process.env.TESTOMATIO_MARKDOWN_FILENAME,
86
+ });
87
+ }
88
+
89
+ private configureRunGroup(runGroup: string | null | undefined): void {
90
+ if (process.env.TESTOMATIO_RUNGROUP_TITLE) return;
91
+ if (runGroup === null) return;
92
+ if (runGroup) {
93
+ process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
94
+ return;
95
+ }
96
+ process.env.TESTOMATIO_RUNGROUP_TITLE = `Explorbot ${new Date().toISOString().slice(0, 10)}`;
60
97
  }
61
98
 
62
99
  async startRun(): Promise<void> {
@@ -104,6 +141,7 @@ export class Reporter {
104
141
  const noteEntries = Object.entries(test.notes)
105
142
  .map(([timestampKey, note]) => ({
106
143
  startTime: note.startTime,
144
+ endTime: note.endTime,
107
145
  message: note.message,
108
146
  status: note.status,
109
147
  screenshot: note.screenshot,
@@ -135,7 +173,7 @@ export class Reporter {
135
173
  const step: Step = {
136
174
  category: 'user',
137
175
  title: noteEntry.message,
138
- duration: 0,
176
+ duration: Math.max(0, Math.round(noteEntry.endTime - noteEntry.startTime)),
139
177
  status: noteEntry.status || 'none',
140
178
  steps: noteSteps.length > 0 ? noteSteps : undefined,
141
179
  };
@@ -182,6 +220,7 @@ export class Reporter {
182
220
  }
183
221
 
184
222
  const steps = this.combineStepsAndNotes(test, screenshotFile);
223
+ const durationMs = test.getDurationMs();
185
224
 
186
225
  const testData = {
187
226
  rid: test.id,
@@ -197,6 +236,7 @@ export class Reporter {
197
236
  files: Object.values(test.artifacts) || [],
198
237
  message: test.summary || this.extractLastNoteMessage(test) || '',
199
238
  meta,
239
+ time: durationMs != null ? Math.round(durationMs) : 0,
200
240
  };
201
241
 
202
242
  debugLog(testData);
@@ -226,6 +266,30 @@ export class Reporter {
226
266
  return this.isRunStarted;
227
267
  }
228
268
 
269
+ async setRunDescription(text: string): Promise<void> {
270
+ if (!this.isRunStarted) return;
271
+ if (!process.env.TESTOMATIO) return;
272
+ const runId = this.client.runId;
273
+ if (!runId) return;
274
+
275
+ const baseUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
276
+ const url = `${baseUrl}/api/reporter/${runId}`;
277
+ try {
278
+ const response = await fetch(url, {
279
+ method: 'PUT',
280
+ headers: { 'Content-Type': 'application/json' },
281
+ body: JSON.stringify({ api_key: process.env.TESTOMATIO, description: text }),
282
+ });
283
+ if (!response.ok) {
284
+ debugLog('Run description update failed:', response.status, response.statusText);
285
+ return;
286
+ }
287
+ debugLog('Run description updated');
288
+ } catch (error) {
289
+ debugLog('Failed to update run description:', error);
290
+ }
291
+ }
292
+
229
293
  private extractLastNoteMessage(test: Test): string {
230
294
  const notes = Object.values(test.notes);
231
295
  if (notes.length === 0) return '';
@@ -142,8 +142,8 @@ export class StateManager {
142
142
 
143
143
  /**
144
144
  * Extract state path from full URL
145
- * Removes domain, port, protocol, and query params
146
- * Keeps path and hash: /path/to/page#section
145
+ * Removes domain, port, protocol
146
+ * Keeps path, query, and hash: /path/to/page?tab=users#section
147
147
  */
148
148
  /**
149
149
  * Update current state from ActionResult and record transition if state changed
@@ -549,7 +549,8 @@ export class StateManager {
549
549
  export function normalizeUrl(url: string): string {
550
550
  try {
551
551
  const parsed = new URL(url, 'http://localhost');
552
- return parsed.pathname.replace(/^\/+|\/+$/g, '');
552
+ const path = parsed.pathname.replace(/^\/+|\/+$/g, '');
553
+ return `${path}${parsed.search}${parsed.hash}`;
553
554
  } catch {
554
555
  return url.replace(/^\/+|\/+$/g, '');
555
556
  }
package/src/stats.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { uniqExplorationName } from './utils/unique-names.ts';
2
+
1
3
  interface TokenUsage {
2
4
  input: number;
3
5
  output: number;
@@ -8,6 +10,7 @@ export type ExplorbotMode = 'explore' | 'test' | 'freesail' | 'tui';
8
10
 
9
11
  export class Stats {
10
12
  static startTime = Date.now();
13
+ static sessionName = uniqExplorationName();
11
14
  static researches = 0;
12
15
  static tests = 0;
13
16
  static plans = 0;
@@ -54,4 +57,8 @@ export class Stats {
54
57
  const totalTokens = Object.values(Stats.models).reduce((sum, m) => sum + m.total, 0);
55
58
  return totalTokens > 0;
56
59
  }
60
+
61
+ static sessionLabel(): string {
62
+ return `${Stats.mode || 'session'}-${Stats.sessionName}`;
63
+ }
57
64
  }