explorbot 0.1.9 → 0.1.11

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 (157) hide show
  1. package/README.md +27 -1
  2. package/bin/explorbot-cli.ts +86 -15
  3. package/boat/api-tester/src/ai/curler-tools.ts +3 -3
  4. package/boat/api-tester/src/ai/curler.ts +1 -1
  5. package/boat/api-tester/src/apibot.ts +2 -2
  6. package/boat/api-tester/src/config.ts +1 -1
  7. package/dist/bin/explorbot-cli.js +85 -14
  8. package/dist/boat/api-tester/src/ai/curler-tools.js +2 -2
  9. package/dist/boat/api-tester/src/apibot.js +2 -2
  10. package/dist/package.json +2 -2
  11. package/dist/rules/navigator/output.md +9 -0
  12. package/dist/rules/navigator/verification-actions.md +2 -0
  13. package/dist/src/action-result.js +23 -1
  14. package/dist/src/action.js +46 -38
  15. package/dist/src/ai/bosun.js +16 -2
  16. package/dist/src/ai/conversation.js +39 -0
  17. package/dist/src/ai/experience-compactor.js +235 -50
  18. package/dist/src/ai/historian/codeceptjs.js +109 -0
  19. package/dist/src/ai/historian/experience.js +320 -0
  20. package/dist/src/ai/historian/mixin.js +2 -0
  21. package/dist/src/ai/historian/playwright.js +145 -0
  22. package/dist/src/ai/historian/utils.js +18 -0
  23. package/dist/src/ai/historian.js +19 -398
  24. package/dist/src/ai/navigator.js +133 -80
  25. package/dist/src/ai/pilot.js +254 -13
  26. package/dist/src/ai/planner/subpages.js +1 -30
  27. package/dist/src/ai/planner.js +33 -13
  28. package/dist/src/ai/provider.js +55 -18
  29. package/dist/src/ai/rerunner.js +3 -3
  30. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  31. package/dist/src/ai/researcher/fingerprint-worker.js +1 -1
  32. package/dist/src/ai/researcher/locators.js +1 -1
  33. package/dist/src/ai/researcher/sections.js +8 -1
  34. package/dist/src/ai/researcher.js +43 -41
  35. package/dist/src/ai/rules.js +26 -14
  36. package/dist/src/ai/tester.js +90 -26
  37. package/dist/src/ai/tools.js +18 -10
  38. package/dist/src/api/request-store.js +20 -0
  39. package/dist/src/api/xhr-capture.js +19 -3
  40. package/dist/src/browser-server.js +16 -3
  41. package/dist/src/command-handler.js +1 -1
  42. package/dist/src/commands/add-rule-command.js +12 -9
  43. package/dist/src/commands/base-command.js +20 -0
  44. package/dist/src/commands/clean-command.js +3 -2
  45. package/dist/src/commands/compact-command.js +138 -0
  46. package/dist/src/commands/context-command.js +7 -1
  47. package/dist/src/commands/drill-command.js +4 -1
  48. package/dist/src/commands/experience-command.js +104 -0
  49. package/dist/src/commands/explore-command.js +54 -19
  50. package/dist/src/commands/freesail-command.js +2 -0
  51. package/dist/src/commands/index.js +7 -3
  52. package/dist/src/commands/init-command.js +11 -10
  53. package/dist/src/commands/learn-command.js +1 -1
  54. package/dist/src/commands/navigate-command.js +4 -1
  55. package/dist/src/commands/plan-clear-command.js +4 -1
  56. package/dist/src/commands/plan-command.js +43 -4
  57. package/dist/src/commands/plan-edit-command.js +1 -1
  58. package/dist/src/commands/plan-load-command.js +4 -1
  59. package/dist/src/commands/plan-reload-command.js +4 -1
  60. package/dist/src/commands/plan-save-command.js +20 -8
  61. package/dist/src/commands/rerun-command.js +4 -0
  62. package/dist/src/commands/research-command.js +5 -2
  63. package/dist/src/commands/start-command.js +5 -1
  64. package/dist/src/commands/test-command.js +7 -1
  65. package/dist/src/components/App.js +15 -5
  66. package/dist/src/execution-controller.js +13 -2
  67. package/dist/src/experience-tracker.js +174 -83
  68. package/dist/src/explorbot.js +31 -22
  69. package/dist/src/explorer.js +12 -5
  70. package/dist/src/observability.js +50 -99
  71. package/dist/src/playwright-recorder.js +309 -0
  72. package/dist/src/reporter.js +17 -2
  73. package/dist/src/stats.js +2 -0
  74. package/dist/src/suite.js +1 -1
  75. package/dist/src/test-plan.js +12 -0
  76. package/dist/src/utils/aria.js +37 -1
  77. package/dist/src/utils/error-page.js +30 -7
  78. package/dist/src/utils/logger.js +1 -1
  79. package/dist/src/utils/next-steps.js +37 -0
  80. package/dist/src/utils/rules-loader.js +1 -1
  81. package/dist/src/utils/test-files.js +1 -1
  82. package/dist/src/utils/url-matcher.js +50 -0
  83. package/package.json +2 -2
  84. package/rules/navigator/output.md +9 -0
  85. package/rules/navigator/verification-actions.md +2 -0
  86. package/src/action-result.ts +26 -1
  87. package/src/action.ts +44 -37
  88. package/src/ai/bosun.ts +16 -2
  89. package/src/ai/conversation.ts +37 -0
  90. package/src/ai/experience-compactor.ts +270 -63
  91. package/src/ai/historian/codeceptjs.ts +130 -0
  92. package/src/ai/historian/experience.ts +383 -0
  93. package/src/ai/historian/mixin.ts +4 -0
  94. package/src/ai/historian/playwright.ts +169 -0
  95. package/src/ai/historian/utils.ts +23 -0
  96. package/src/ai/historian.ts +35 -468
  97. package/src/ai/navigator.ts +140 -85
  98. package/src/ai/pilot.ts +259 -14
  99. package/src/ai/planner/subpages.ts +1 -24
  100. package/src/ai/planner.ts +34 -14
  101. package/src/ai/provider.ts +52 -18
  102. package/src/ai/rerunner.ts +3 -3
  103. package/src/ai/researcher/deep-analysis.ts +1 -1
  104. package/src/ai/researcher/fingerprint-worker.ts +1 -1
  105. package/src/ai/researcher/locators.ts +2 -2
  106. package/src/ai/researcher/sections.ts +7 -1
  107. package/src/ai/researcher.ts +47 -42
  108. package/src/ai/rules.ts +27 -14
  109. package/src/ai/task-agent.ts +1 -1
  110. package/src/ai/tester.ts +94 -26
  111. package/src/ai/tools.ts +53 -29
  112. package/src/api/request-store.ts +22 -0
  113. package/src/api/xhr-capture.ts +21 -3
  114. package/src/browser-server.ts +17 -3
  115. package/src/command-handler.ts +1 -1
  116. package/src/commands/add-rule-command.ts +13 -9
  117. package/src/commands/base-command.ts +26 -1
  118. package/src/commands/clean-command.ts +4 -3
  119. package/src/commands/compact-command.ts +156 -0
  120. package/src/commands/context-command.ts +8 -2
  121. package/src/commands/drill-command.ts +5 -2
  122. package/src/commands/experience-command.ts +125 -0
  123. package/src/commands/explore-command.ts +58 -21
  124. package/src/commands/freesail-command.ts +2 -0
  125. package/src/commands/index.ts +7 -3
  126. package/src/commands/init-command.ts +11 -10
  127. package/src/commands/learn-command.ts +2 -2
  128. package/src/commands/navigate-command.ts +5 -2
  129. package/src/commands/plan-clear-command.ts +5 -2
  130. package/src/commands/plan-command.ts +47 -5
  131. package/src/commands/plan-edit-command.ts +2 -2
  132. package/src/commands/plan-load-command.ts +5 -2
  133. package/src/commands/plan-reload-command.ts +5 -2
  134. package/src/commands/plan-save-command.ts +20 -9
  135. package/src/commands/rerun-command.ts +5 -0
  136. package/src/commands/research-command.ts +6 -3
  137. package/src/commands/start-command.ts +6 -2
  138. package/src/commands/test-command.ts +8 -2
  139. package/src/components/App.tsx +16 -5
  140. package/src/config.ts +6 -1
  141. package/src/execution-controller.ts +14 -3
  142. package/src/experience-tracker.ts +198 -100
  143. package/src/explorbot.ts +33 -23
  144. package/src/explorer.ts +14 -5
  145. package/src/observability.ts +50 -109
  146. package/src/playwright-recorder.ts +305 -0
  147. package/src/reporter.ts +17 -3
  148. package/src/stats.ts +4 -0
  149. package/src/suite.ts +1 -1
  150. package/src/test-plan.ts +12 -0
  151. package/src/utils/aria.ts +38 -1
  152. package/src/utils/error-page.ts +32 -7
  153. package/src/utils/logger.ts +1 -1
  154. package/src/utils/next-steps.ts +51 -0
  155. package/src/utils/rules-loader.ts +1 -1
  156. package/src/utils/test-files.ts +1 -1
  157. package/src/utils/url-matcher.ts +43 -0
@@ -113,6 +113,8 @@ For input field values, ALWAYS use I.seeInField() — never check value via CSS
113
113
  Prefer text locators (label, name, placeholder) for form fields: I.seeInField('Search', 'value') over I.seeInField('input[name="search"]', 'value').
114
114
  Only use locators that exist in the provided HTML or ARIA snapshot.
115
115
  Verify exact conditions, not approximate matches.
116
+ NEVER use `:has-text(...)` inside a seeElement/dontSeeElement locator. Checking text inside an element is the job of I.see(text, context) — the `:has-text()` form duplicates that capability with a fragile selector.
117
+ NEVER emit two assertions that check the same fact with different shapes. `I.see(text, locator)` and `I.seeElement("<locator>:has-text('text')")` verify the same thing — pick one (prefer I.see). One claim, one assertion.
116
118
  </verification_rules>
117
119
 
118
120
  [DO NEVER USE OTHER CODECEPTJS COMMANDS THAN PROPOSED HERE]
@@ -611,7 +611,7 @@ export class ActionResult implements ActionResultData {
611
611
  }
612
612
  }
613
613
  if (processedParts.length > 0) {
614
- pageDiff.htmlParts = processedParts;
614
+ pageDiff.htmlParts = collapseHtmlParts(processedParts);
615
615
  }
616
616
  }
617
617
 
@@ -629,6 +629,31 @@ export class ActionResult implements ActionResultData {
629
629
  }
630
630
  }
631
631
 
632
+ const HTML_PARTS_TOTAL_BUDGET = 8000;
633
+ const HTML_PARTS_COUNT_LIMIT = 8;
634
+ const HTML_PART_SUBTREE_BUDGET = 2000;
635
+
636
+ function collapseHtmlParts(parts: HtmlDiffPart[]): HtmlDiffPart[] {
637
+ const total = parts.reduce((sum, p) => sum + p.subtree.length, 0);
638
+ const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
639
+
640
+ if (fullPageReRender) {
641
+ return parts.map((part) => ({
642
+ ...part,
643
+ subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
644
+ }));
645
+ }
646
+
647
+ return parts.map((part) => {
648
+ if (part.subtree.length <= HTML_PART_SUBTREE_BUDGET) return part;
649
+ const head = part.subtree.slice(0, HTML_PART_SUBTREE_BUDGET);
650
+ return {
651
+ ...part,
652
+ subtree: `${head}...<!-- truncated ${part.subtree.length - HTML_PART_SUBTREE_BUDGET} chars -->`,
653
+ };
654
+ });
655
+ }
656
+
632
657
  export class Diff {
633
658
  private _htmlDiffResult: HtmlDiffResult | null = null;
634
659
  private _ariaDiffResult: string | null = null;
package/src/action.ts CHANGED
@@ -16,6 +16,7 @@ import { ConfigParser, outputPath } from './config.js';
16
16
  import type { ExplorbotConfig } from './config.js';
17
17
  import type { UserResolveFunction } from './explorbot.ts';
18
18
  import { Observability } from './observability.ts';
19
+ import type { PlaywrightRecorder } from './playwright-recorder.ts';
19
20
  import type { StateManager } from './state-manager.js';
20
21
  import { extractCodeBlocks } from './utils/code-extractor.js';
21
22
  import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
@@ -36,12 +37,16 @@ class Action {
36
37
  private expectation: string | null = null;
37
38
  public lastError: Error | null = null;
38
39
  public playwrightHelper: any;
40
+ public playwrightGroupId: string | null = null;
41
+ public assertionSteps: Array<{ name: string; args: any[] }> = [];
42
+ private recorder?: PlaywrightRecorder;
39
43
 
40
- constructor(actor: CodeceptJS.I, stateManager: StateManager) {
44
+ constructor(actor: CodeceptJS.I, stateManager: StateManager, recorder?: PlaywrightRecorder) {
41
45
  this.actor = actor;
42
46
  this.stateManager = stateManager;
43
47
  this.config = ConfigParser.getInstance().getConfig();
44
48
  this.playwrightHelper = container.helpers('Playwright');
49
+ this.recorder = recorder;
45
50
  }
46
51
 
47
52
  async caputrePageWithScreenshot(): Promise<ActionResult> {
@@ -71,7 +76,14 @@ class Action {
71
76
  const timestamp = Date.now();
72
77
  const page = this.playwrightHelper.page;
73
78
  const frame = this.playwrightHelper.frame;
74
- const [html, title, browserLogs] = await Promise.all([(this.actor as any).grabSource(), (this.actor as any).grabTitle(), this.captureBrowserLogs()]);
79
+ await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => {});
80
+ const grabAll = () => Promise.all([(this.actor as any).grabSource(), (this.actor as any).grabTitle(), this.captureBrowserLogs()]);
81
+ const [html, title, browserLogs] = await grabAll().catch(async (err: Error) => {
82
+ const msg = err instanceof Error ? err.message : String(err);
83
+ if (!/navigating and changing the content/i.test(msg)) throw err;
84
+ await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => {});
85
+ return grabAll();
86
+ });
75
87
  const url = page?.url() || (await (this.actor as any).grabCurrentUrl?.());
76
88
 
77
89
  let screenshotFile: string | undefined = undefined;
@@ -218,7 +230,10 @@ class Action {
218
230
  let codeString = code.replace(/^\(I\) => /, '').trim();
219
231
 
220
232
  const executedSteps: string[] = [];
221
- registerStepLogger(executedSteps);
233
+ const assertionSteps: Array<{ name: string; args: any[] }> = [];
234
+ const stepListener = attachStepLogger(executedSteps, assertionSteps);
235
+ const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
236
+ this.playwrightGroupId = groupId;
222
237
  const activeSpan = Observability.getSpan();
223
238
  const tracer = trace.getTracer('ai');
224
239
  const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
@@ -253,6 +268,7 @@ class Action {
253
268
  this.stateManager.updateState(pageState, codeString);
254
269
 
255
270
  this.actionResult = pageState;
271
+ this.assertionSteps = assertionSteps;
256
272
  } catch (err) {
257
273
  debugLog('Action error', errorToString(err));
258
274
  error = err as Error;
@@ -260,9 +276,11 @@ class Action {
260
276
  await recorder.reset();
261
277
  await recorder.start();
262
278
  }
279
+ this.assertionSteps = [];
263
280
  throw err;
264
281
  } finally {
265
- unregisterStepLogger();
282
+ if (groupId) await this.recorder!.endAction();
283
+ detachStepLogger(stepListener);
266
284
  if (stepSpan) {
267
285
  stepSpan.end();
268
286
  }
@@ -407,41 +425,30 @@ function sleep(ms: number) {
407
425
  return new Promise((resolve) => setTimeout(resolve, ms));
408
426
  }
409
427
 
410
- let stepLoggerRegistered = false;
411
- let stepLoggerTarget: string[] | null = null;
428
+ const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeElement', 'seeInField', 'dontSeeInField', 'seeInCurrentUrl', 'dontSeeInCurrentUrl']);
412
429
 
413
- const stepLogger = (step: any, error?: any) => {
414
- if (!step?.toCode) {
415
- return;
416
- }
417
- if (step.name?.startsWith('grab')) return;
418
- const stepCode = step.toCode();
419
- if (stepLoggerTarget) {
420
- stepLoggerTarget.push(stepCode);
421
- }
422
- if (error) {
423
- tag('step').log(step, error);
424
- return;
425
- }
426
- tag('step').log(step);
427
- };
430
+ type StepListener = (step: any, error?: any) => void;
428
431
 
429
- const registerStepLogger = (target: string[]) => {
430
- stepLoggerTarget = target;
431
- if (stepLoggerRegistered) {
432
- return;
433
- }
434
- stepLoggerRegistered = true;
435
- codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, stepLogger);
436
- codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, stepLogger);
432
+ const attachStepLogger = (target: string[], assertionsTarget?: Array<{ name: string; args: any[] }>): StepListener => {
433
+ const listener: StepListener = (step, error) => {
434
+ if (!step?.toCode) return;
435
+ if (step.name?.startsWith('grab')) return;
436
+ target.push(step.toCode());
437
+ if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
438
+ assertionsTarget.push({ name: step.name, args: step.args || [] });
439
+ }
440
+ if (error) {
441
+ tag('step').log(step, error);
442
+ return;
443
+ }
444
+ tag('step').log(step);
445
+ };
446
+ codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
447
+ codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
448
+ return listener;
437
449
  };
438
450
 
439
- const unregisterStepLogger = () => {
440
- stepLoggerTarget = null;
441
- if (!stepLoggerRegistered) {
442
- return;
443
- }
444
- stepLoggerRegistered = false;
445
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, stepLogger);
446
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, stepLogger);
451
+ const detachStepLogger = (listener: StepListener) => {
452
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
453
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
447
454
  };
package/src/ai/bosun.ts CHANGED
@@ -10,8 +10,10 @@ import { Observability } from '../observability.ts';
10
10
  import { Plan, Task, Test, TestResult } from '../test-plan.ts';
11
11
  import { diffAriaSnapshots } from '../utils/aria.ts';
12
12
  import { HooksRunner } from '../utils/hooks-runner.ts';
13
+ import { getCliName } from '../utils/cli-name.ts';
13
14
  import { createDebug, tag } from '../utils/logger.ts';
14
15
  import { loop, pause } from '../utils/loop.ts';
16
+ import { type NextStepSection, printNextSteps } from '../utils/next-steps.ts';
15
17
  import type { Agent } from './agent.ts';
16
18
  import type { Conversation } from './conversation.ts';
17
19
  import type { Navigator } from './navigator.ts';
@@ -473,7 +475,11 @@ export class Bosun extends TaskAgent implements Agent {
473
475
  const successfulInteractions = results.filter((r) => r.result === 'success' && r.code);
474
476
 
475
477
  for (const interaction of successfulInteractions) {
476
- await experienceTracker.saveSuccessfulResolution(actionResult, `Drill ${interaction.action}: ${interaction.component}`, interaction.code!, interaction.description);
478
+ experienceTracker.writeAction(actionResult, {
479
+ title: `Drill ${interaction.action}: ${interaction.component}`,
480
+ code: interaction.code!,
481
+ explanation: interaction.description,
482
+ });
477
483
  }
478
484
 
479
485
  if (successfulInteractions.length > 0) {
@@ -493,7 +499,15 @@ export class Bosun extends TaskAgent implements Agent {
493
499
  const content = this.generateKnowledgeContent(state, successfulInteractions);
494
500
  const result = knowledgeTracker.addKnowledge(knowledgePath, content);
495
501
 
496
- tag('success').log(`Knowledge saved to: ${result.filePath}`);
502
+ const cli = getCliName();
503
+ const sections: NextStepSection[] = [
504
+ {
505
+ label: 'Knowledge',
506
+ path: result.filePath,
507
+ commands: [{ label: 'View matches', command: `${cli} knows ${knowledgePath}` }],
508
+ },
509
+ ];
510
+ printNextSteps(sections);
497
511
  }
498
512
 
499
513
  private generateKnowledgeContent(state: any, interactions: InteractionResult[]): string {
@@ -11,6 +11,9 @@ export function toolExecutionLabel(input: Record<string, any> | undefined): stri
11
11
  return input?.explanation || input?.assertion || input?.reason || input?.request || '';
12
12
  }
13
13
 
14
+ const AUTO_COMPACT_ARIA_CHANGES_CUTOFF = 500;
15
+ const AUTO_COMPACT_TARGETED_HTML_CUTOFF = 500;
16
+
14
17
  export class Conversation {
15
18
  id: string;
16
19
  messages: ModelMessage[];
@@ -132,6 +135,40 @@ export class Conversation {
132
135
  this.autoTrimRules.set(tagName, maxLength);
133
136
  }
134
137
 
138
+ compactToolResults(keepLastN: number): void {
139
+ const toolMessageIndexes: number[] = [];
140
+ for (let i = 0; i < this.messages.length; i++) {
141
+ if (this.messages[i].role === 'tool') toolMessageIndexes.push(i);
142
+ }
143
+ const compactUpTo = toolMessageIndexes.length - Math.max(0, keepLastN);
144
+ for (let k = 0; k < compactUpTo; k++) {
145
+ const message = this.messages[toolMessageIndexes[k]];
146
+ if (!Array.isArray(message.content)) continue;
147
+ for (const part of message.content) {
148
+ if (part.type !== 'tool-result') continue;
149
+ const rawOutput = part.output as Record<string, any> | undefined;
150
+ if (!rawOutput || rawOutput.type !== 'json' || !rawOutput.value || typeof rawOutput.value !== 'object') continue;
151
+ const value = rawOutput.value as Record<string, any>;
152
+ if (value.pageDiff && typeof value.pageDiff === 'object') {
153
+ const pageDiff = value.pageDiff as Record<string, any>;
154
+ if (Array.isArray(pageDiff.htmlParts)) {
155
+ pageDiff.htmlParts = undefined;
156
+ pageDiff.compacted = true;
157
+ }
158
+ if (typeof pageDiff.ariaChanges === 'string' && pageDiff.ariaChanges.length > AUTO_COMPACT_ARIA_CHANGES_CUTOFF) {
159
+ pageDiff.ariaChanges = `${pageDiff.ariaChanges.slice(0, AUTO_COMPACT_ARIA_CHANGES_CUTOFF)}...`;
160
+ }
161
+ if (typeof pageDiff.iframes === 'string') {
162
+ pageDiff.iframes = undefined;
163
+ }
164
+ }
165
+ if (typeof value.targetedHtml === 'string' && value.targetedHtml.length > AUTO_COMPACT_TARGETED_HTML_CUTOFF) {
166
+ value.targetedHtml = `${value.targetedHtml.slice(0, AUTO_COMPACT_TARGETED_HTML_CUTOFF)}...`;
167
+ }
168
+ }
169
+ }
170
+ }
171
+
135
172
  hasTag(tagName: string, lastN?: number): boolean {
136
173
  const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
137
174
  const regex = new RegExp(`<${escapedTag}>`, 'g');