explorbot 0.1.10 → 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 (84) hide show
  1. package/README.md +27 -1
  2. package/bin/explorbot-cli.ts +27 -18
  3. package/dist/bin/explorbot-cli.js +26 -18
  4. package/dist/package.json +2 -2
  5. package/dist/rules/navigator/output.md +9 -0
  6. package/dist/rules/navigator/verification-actions.md +2 -0
  7. package/dist/src/action-result.js +23 -1
  8. package/dist/src/action.js +46 -38
  9. package/dist/src/ai/bosun.js +11 -1
  10. package/dist/src/ai/conversation.js +39 -0
  11. package/dist/src/ai/historian/codeceptjs.js +109 -0
  12. package/dist/src/ai/historian/experience.js +320 -0
  13. package/dist/src/ai/historian/mixin.js +2 -0
  14. package/dist/src/ai/historian/playwright.js +145 -0
  15. package/dist/src/ai/historian/utils.js +18 -0
  16. package/dist/src/ai/historian.js +19 -405
  17. package/dist/src/ai/navigator.js +82 -29
  18. package/dist/src/ai/pilot.js +232 -13
  19. package/dist/src/ai/planner.js +29 -9
  20. package/dist/src/ai/provider.js +54 -17
  21. package/dist/src/ai/researcher.js +41 -32
  22. package/dist/src/ai/rules.js +26 -14
  23. package/dist/src/ai/tester.js +90 -26
  24. package/dist/src/ai/tools.js +13 -7
  25. package/dist/src/browser-server.js +16 -3
  26. package/dist/src/commands/add-rule-command.js +11 -8
  27. package/dist/src/commands/clean-command.js +2 -1
  28. package/dist/src/commands/explore-command.js +27 -15
  29. package/dist/src/commands/init-command.js +9 -8
  30. package/dist/src/commands/plan-command.js +32 -0
  31. package/dist/src/commands/plan-save-command.js +19 -7
  32. package/dist/src/commands/rerun-command.js +4 -0
  33. package/dist/src/components/App.js +15 -5
  34. package/dist/src/execution-controller.js +13 -2
  35. package/dist/src/experience-tracker.js +20 -64
  36. package/dist/src/explorbot.js +5 -8
  37. package/dist/src/explorer.js +9 -2
  38. package/dist/src/observability.js +50 -99
  39. package/dist/src/playwright-recorder.js +309 -0
  40. package/dist/src/test-plan.js +12 -0
  41. package/dist/src/utils/aria.js +37 -1
  42. package/dist/src/utils/error-page.js +20 -7
  43. package/dist/src/utils/next-steps.js +37 -0
  44. package/package.json +2 -2
  45. package/rules/navigator/output.md +9 -0
  46. package/rules/navigator/verification-actions.md +2 -0
  47. package/src/action-result.ts +26 -1
  48. package/src/action.ts +44 -37
  49. package/src/ai/bosun.ts +11 -1
  50. package/src/ai/conversation.ts +37 -0
  51. package/src/ai/historian/codeceptjs.ts +130 -0
  52. package/src/ai/historian/experience.ts +383 -0
  53. package/src/ai/historian/mixin.ts +4 -0
  54. package/src/ai/historian/playwright.ts +169 -0
  55. package/src/ai/historian/utils.ts +23 -0
  56. package/src/ai/historian.ts +35 -473
  57. package/src/ai/navigator.ts +82 -29
  58. package/src/ai/pilot.ts +237 -14
  59. package/src/ai/planner.ts +29 -9
  60. package/src/ai/provider.ts +51 -17
  61. package/src/ai/researcher.ts +45 -33
  62. package/src/ai/rules.ts +27 -14
  63. package/src/ai/tester.ts +94 -26
  64. package/src/ai/tools.ts +47 -25
  65. package/src/browser-server.ts +17 -3
  66. package/src/commands/add-rule-command.ts +11 -7
  67. package/src/commands/clean-command.ts +2 -1
  68. package/src/commands/explore-command.ts +29 -15
  69. package/src/commands/init-command.ts +9 -8
  70. package/src/commands/plan-command.ts +35 -0
  71. package/src/commands/plan-save-command.ts +18 -7
  72. package/src/commands/rerun-command.ts +5 -0
  73. package/src/components/App.tsx +16 -5
  74. package/src/config.ts +6 -1
  75. package/src/execution-controller.ts +14 -3
  76. package/src/experience-tracker.ts +21 -72
  77. package/src/explorbot.ts +5 -8
  78. package/src/explorer.ts +11 -2
  79. package/src/observability.ts +50 -109
  80. package/src/playwright-recorder.ts +305 -0
  81. package/src/test-plan.ts +12 -0
  82. package/src/utils/aria.ts +38 -1
  83. package/src/utils/error-page.ts +22 -7
  84. package/src/utils/next-steps.ts +51 -0
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';
@@ -497,7 +499,15 @@ export class Bosun extends TaskAgent implements Agent {
497
499
  const content = this.generateKnowledgeContent(state, successfulInteractions);
498
500
  const result = knowledgeTracker.addKnowledge(knowledgePath, content);
499
501
 
500
- 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);
501
511
  }
502
512
 
503
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');
@@ -0,0 +1,130 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ActionResult } from '../../action-result.ts';
4
+ import { ConfigParser } from '../../config.ts';
5
+ import { KnowledgeTracker } from '../../knowledge-tracker.ts';
6
+ import type { Plan } from '../../test-plan.ts';
7
+ import { tag } from '../../utils/logger.ts';
8
+ import { relativeToCwd } from '../../utils/next-steps.ts';
9
+ import type { Conversation } from '../conversation.ts';
10
+ import { ASSERTION_TOOLS, CODECEPT_TOOLS } from '../tools.ts';
11
+ import type { Constructor } from './mixin.ts';
12
+ import { escapeString, getExecutionLabel, isNonReusableCode, stripComments } from './utils.ts';
13
+
14
+ export interface CodeceptJSMethods {
15
+ toCode(conversation: Conversation, scenario: string): string;
16
+ saveCodeceptPlanToFile(plan: Plan): string;
17
+ }
18
+
19
+ export function WithCodeceptJS<T extends Constructor>(Base: T) {
20
+ return class extends Base {
21
+ declare savedFiles: Set<string>;
22
+
23
+ toCode(conversation: Conversation, scenario: string): string {
24
+ const toolExecutions = conversation.getToolExecutions();
25
+ const TRACKABLE_TOOLS = [...CODECEPT_TOOLS, ...ASSERTION_TOOLS];
26
+ const successfulSteps = toolExecutions.filter((exec) => exec.wasSuccessful && TRACKABLE_TOOLS.includes(exec.toolName as any) && exec.output?.code);
27
+
28
+ if (successfulSteps.length === 0) {
29
+ return '';
30
+ }
31
+
32
+ const lines: string[] = [];
33
+ lines.push(`Scenario('${escapeString(scenario)}', ({ I }) => {`);
34
+
35
+ for (const exec of successfulSteps) {
36
+ if (isNonReusableCode(exec.output.code)) continue;
37
+ const explanation = getExecutionLabel(exec);
38
+ if (explanation) {
39
+ lines.push('');
40
+ lines.push(` Section('${escapeString(explanation)}');`);
41
+ }
42
+ const code = stripComments(exec.output.code);
43
+ const codeLines = code.includes('\n') ? code.split('\n') : code.split('; ');
44
+ for (const codeLine of codeLines) {
45
+ const trimmed = codeLine.trim();
46
+ if (trimmed) {
47
+ lines.push(` ${trimmed}`);
48
+ }
49
+ }
50
+ }
51
+
52
+ lines.push('});');
53
+ return lines.join('\n');
54
+ }
55
+
56
+ saveCodeceptPlanToFile(plan: Plan): string {
57
+ const lines: string[] = [];
58
+
59
+ lines.push(`import step, { Section } from 'codeceptjs/steps';`);
60
+ lines.push('');
61
+ lines.push(`Feature('${escapeString(plan.title)}')`);
62
+ lines.push('');
63
+
64
+ const startUrl = plan.url || plan.tests[0]?.startUrl;
65
+ if (startUrl) {
66
+ lines.push('Before(({ I }) => {');
67
+ lines.push(` I.amOnPage('${escapeString(startUrl)}');`);
68
+ lines.push(...this.getKnowledgeLines(startUrl));
69
+ lines.push('});');
70
+ lines.push('');
71
+ }
72
+
73
+ for (const test of plan.tests) {
74
+ if (test.generatedCode) {
75
+ if (test.isSuccessful) {
76
+ lines.push(test.generatedCode);
77
+ } else {
78
+ lines.push(`// FAILED: ${test.scenario}`);
79
+ lines.push(test.generatedCode.replace(/Scenario\(/, 'Scenario.skip('));
80
+ }
81
+ lines.push('');
82
+ continue;
83
+ }
84
+
85
+ lines.push(`Scenario.todo('${escapeString(test.scenario)}', ({ I }) => {`);
86
+ if (test.plannedSteps.length > 0) {
87
+ for (const step of test.plannedSteps) {
88
+ lines.push(` // ${step}`);
89
+ }
90
+ } else {
91
+ lines.push(` // ${test.scenario}`);
92
+ }
93
+ lines.push('});');
94
+ lines.push('');
95
+ }
96
+
97
+ const testsDir = ConfigParser.getInstance().getTestsDir();
98
+ mkdirSync(testsDir, { recursive: true });
99
+
100
+ const filename = plan.title.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
101
+ const filePath = join(testsDir, `${filename}.js`);
102
+ writeFileSync(filePath, lines.join('\n'));
103
+ this.savedFiles.add(filePath);
104
+
105
+ tag('substep').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
106
+ return filePath;
107
+ }
108
+
109
+ private getKnowledgeLines(url: string, indent = ' '): string[] {
110
+ const knowledgeTracker = new KnowledgeTracker();
111
+ const state = new ActionResult({ url });
112
+ const { wait, waitForElement, code } = knowledgeTracker.getStateParameters(state, ['wait', 'waitForElement', 'code']);
113
+
114
+ const lines: string[] = [];
115
+ if (wait !== undefined) {
116
+ lines.push(`${indent}I.wait(${wait});`);
117
+ }
118
+ if (waitForElement) {
119
+ lines.push(`${indent}I.waitForElement(${JSON.stringify(waitForElement)});`);
120
+ }
121
+ if (code) {
122
+ for (const codeLine of code.split('\n')) {
123
+ const trimmed = codeLine.trim();
124
+ if (trimmed) lines.push(`${indent}${trimmed}`);
125
+ }
126
+ }
127
+ return lines;
128
+ }
129
+ };
130
+ }