explorbot 0.3.2 → 0.3.5

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 (51) hide show
  1. package/bin/explorbot-cli.ts +14 -3
  2. package/dist/bin/explorbot-cli.js +12 -3
  3. package/dist/models.json +2 -0
  4. package/dist/package.json +1 -1
  5. package/dist/src/action-result.d.ts +1 -0
  6. package/dist/src/action-result.js +17 -12
  7. package/dist/src/action.d.ts +10 -0
  8. package/dist/src/action.js +16 -10
  9. package/dist/src/ai/conversation.d.ts +2 -1
  10. package/dist/src/ai/conversation.js +9 -4
  11. package/dist/src/ai/navigator.d.ts +3 -0
  12. package/dist/src/ai/navigator.js +20 -4
  13. package/dist/src/ai/pilot.js +9 -0
  14. package/dist/src/ai/planner.js +1 -1
  15. package/dist/src/ai/provider.js +28 -2
  16. package/dist/src/ai/researcher/deep-analysis.js +5 -1
  17. package/dist/src/ai/researcher/sections.js +0 -1
  18. package/dist/src/ai/researcher.js +0 -1
  19. package/dist/src/ai/rules.js +0 -1
  20. package/dist/src/ai/tester.d.ts +1 -0
  21. package/dist/src/ai/tester.js +8 -9
  22. package/dist/src/ai/tools.d.ts +2 -0
  23. package/dist/src/ai/tools.js +21 -4
  24. package/dist/src/commands/init-command.js +74 -24
  25. package/dist/src/components/InitWizard.d.ts +2 -1
  26. package/dist/src/components/InitWizard.js +8 -4
  27. package/dist/src/explorer.js +1 -0
  28. package/dist/src/knowledge-tracker.d.ts +3 -1
  29. package/dist/src/knowledge-tracker.js +4 -4
  30. package/dist/src/utils/aria.js +1 -1
  31. package/docs/basics/providers.md +2 -4
  32. package/models.json +2 -0
  33. package/package.json +1 -1
  34. package/src/action-result.ts +20 -15
  35. package/src/action.ts +21 -12
  36. package/src/ai/conversation.ts +11 -5
  37. package/src/ai/navigator.ts +22 -4
  38. package/src/ai/pilot.ts +9 -0
  39. package/src/ai/planner.ts +1 -1
  40. package/src/ai/provider.ts +28 -2
  41. package/src/ai/researcher/deep-analysis.ts +6 -1
  42. package/src/ai/researcher/sections.ts +0 -1
  43. package/src/ai/researcher.ts +0 -1
  44. package/src/ai/rules.ts +0 -1
  45. package/src/ai/tester.ts +8 -7
  46. package/src/ai/tools.ts +20 -4
  47. package/src/commands/init-command.ts +81 -22
  48. package/src/components/InitWizard.tsx +8 -4
  49. package/src/explorer.ts +1 -0
  50. package/src/knowledge-tracker.ts +4 -4
  51. package/src/utils/aria.ts +1 -1
@@ -3,12 +3,13 @@ import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { ActionResult } from '../action-result.js';
5
5
  import type Action from '../action.ts';
6
+ import type { ExecutedStep } from '../action.ts';
6
7
  import type { ExplorbotConfig } from '../config.ts';
7
8
  import type { ExperienceTracker } from '../experience-tracker.js';
8
9
  import Explorer from '../explorer.ts';
9
10
  import type { KnowledgeTracker } from '../knowledge-tracker.js';
10
- import { type StateManager, normalizeUrl } from '../state-manager.js';
11
11
  import { renderAssertion } from '../playwright-recorder.ts';
12
+ import { type StateManager, normalizeUrl } from '../state-manager.js';
12
13
  import { isFatalBrowserError } from '../utils/browser-errors.ts';
13
14
  import { getCliName } from '../utils/cli-name.ts';
14
15
  import { extractCodeBlocks } from '../utils/code-extractor.js';
@@ -37,6 +38,7 @@ class Navigator implements Agent {
37
38
 
38
39
  private MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
39
40
  lastFailureReason: string | null = null;
41
+ executedSteps: ExecutedStep[] = [];
40
42
 
41
43
  private systemPrompt = dedent`
42
44
  <role>
@@ -217,12 +219,18 @@ class Navigator implements Agent {
217
219
  if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
218
220
 
219
221
  this.lastFailureReason = null;
222
+ this.executedSteps = [];
220
223
  tag('info').log('AI Navigator resolving state at', actionResult.url);
221
224
  debugLog('Resolution message:', message);
222
225
 
223
226
  const action = opts?.action ?? this.explorer.action();
224
227
  const expectedUrl = opts?.expectedUrl;
225
228
 
229
+ if (expectedUrl && this.targetUrlReached(action, expectedUrl, actionResult)) {
230
+ tag('success').log(`Already at ${expectedUrl} — navigation resolved`);
231
+ return true;
232
+ }
233
+
226
234
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
227
235
 
228
236
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
@@ -313,14 +321,19 @@ class Navigator implements Agent {
313
321
  resolved = check.urlMatches && freshHash !== actionResult.getStateHash();
314
322
 
315
323
  if (!resolved && attempt.ok) {
316
- lastFailure = `URL did not change (still ${check.freshState.url})`;
324
+ if (check.urlMatches) {
325
+ lastFailure = `Reached ${check.freshState.url} but the page state did not change`;
326
+ tag('warning').log(`Page state did not change at ${check.freshState.url}`);
327
+ } else {
328
+ lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`;
329
+ tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
330
+ }
317
331
  batchFailures.push({
318
332
  code: codeBlock,
319
333
  error: lastFailure,
320
334
  ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
321
335
  urlAfter: check.freshState.url,
322
336
  });
323
- tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
324
337
  }
325
338
  if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
326
339
  progressBlocks.push(codeBlock);
@@ -456,6 +469,7 @@ class Navigator implements Agent {
456
469
 
457
470
  debugLog(`Attempting resolution: ${codeBlock}`);
458
471
  const ok = await action.attempt(codeBlock, message);
472
+ this.executedSteps.push(...action.executedSteps);
459
473
 
460
474
  const page = action.playwrightHelper?.page;
461
475
  if (page) {
@@ -484,11 +498,15 @@ class Navigator implements Agent {
484
498
  }
485
499
 
486
500
  const freshState = await this.explorer.capture();
487
- const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(freshState, expectedUrl));
501
+ const urlMatches = this.targetUrlReached(action, expectedUrl, freshState);
488
502
 
489
503
  return { freshState, urlMatches };
490
504
  }
491
505
 
506
+ private targetUrlReached(action: Action, expectedUrl: string, state: ActionResult): boolean {
507
+ return this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(state, expectedUrl));
508
+ }
509
+
492
510
  private async ariaDiff(freshState: ActionResult, previous: ActionResult): Promise<string | null> {
493
511
  if (freshState.getStateHash() === previous.getStateHash()) return null;
494
512
  try {
package/src/ai/pilot.ts CHANGED
@@ -28,6 +28,7 @@ import { withdrawVisionTools } from './tools.ts';
28
28
  const CHECK_TOOLS = ['verify', 'see', 'research'];
29
29
  const EVIDENCE_TOOLS = ['verify', 'see'];
30
30
  const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
31
+ const PILOT_REASONING_LIMIT = 500;
31
32
  const PILOT_MESSAGE_LIMIT = 2;
32
33
  const PILOT_MESSAGE_MAX_LENGTH = 160;
33
34
 
@@ -1044,6 +1045,12 @@ export class Pilot implements Agent {
1044
1045
  if (resultMessage) line += `\n result: ${resultMessage}`;
1045
1046
  if (errorDetail && errorDetail !== resultMessage) line += `\n error: ${errorDetail}`;
1046
1047
 
1048
+ if (!t.wasSuccessful && t.reasoning) {
1049
+ let rationale = t.reasoning;
1050
+ if (rationale.length > PILOT_REASONING_LIMIT) rationale = `...${rationale.slice(-PILOT_REASONING_LIMIT)}`;
1051
+ line += `\n tester reasoned: ${rationale.replace(/\n+/g, ' ')}`;
1052
+ }
1053
+
1047
1054
  const attempts = t.output?.attempts;
1048
1055
  if (attempts && attempts.length > 1 && t.wasSuccessful) {
1049
1056
  const failedBefore = attempts.filter((a: any) => !a.success);
@@ -1107,6 +1114,8 @@ export class Pilot implements Agent {
1107
1114
  state), instruct Tester to verify() and finish(). If goal was already true at the start, propose
1108
1115
  different input data so the test is meaningful. If Tester repeats the same successful action, STOP.
1109
1116
 
1117
+ If needed you should pick the exact item the scenario should act on (from the page, or precondition() one) and pass it to tester
1118
+
1110
1119
  Action classification: GOAL-ADVANCING actions mutate the scenario's subject data (create/edit/delete/submit/verify).
1111
1120
  VIEW-ONLY actions toggle filters/tabs/sort/collapse without changing data. One VIEW-ONLY to reveal a
1112
1121
  target is fine; ≥2 consecutive VIEW-ONLY actions with no GOAL-ADVANCING action in between is thrashing
package/src/ai/planner.ts CHANGED
@@ -33,7 +33,7 @@ const TasksSchema = z.object({
33
33
  scenarios: z
34
34
  .array(
35
35
  z.object({
36
- scenario: z.string().describe('A single sentence describing what to test'),
36
+ scenario: z.string().describe('A single sentence describing the behavior to test.'),
37
37
  priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
38
38
  startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL. Use only stable feature/list/detail pages, not transient create/edit/modal URLs unless the scenario specifically starts inside that form.'),
39
39
  steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Open the form", "Enter required data", "Submit the form"). Keep steps atomic and actionable.'),
@@ -2,7 +2,8 @@ import { OpenTelemetry } from '@ai-sdk/otel';
2
2
  import { LangfuseSpanProcessor } from '@langfuse/otel';
3
3
  import { NodeSDK } from '@opentelemetry/sdk-node';
4
4
  import { AsyncLocalStorage } from 'node:async_hooks';
5
- import { generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
5
+ import dedent from 'dedent';
6
+ import { APICallError, generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
6
7
  import type { ModelMessage } from 'ai';
7
8
  import { z } from 'zod';
8
9
  import { clearActivity, setActivity } from '../activity.ts';
@@ -423,10 +424,19 @@ export class Provider {
423
424
  const stopConditions: any[] = [isStepCount(maxRoundtrips)];
424
425
  if (extraStop) stopConditions.push(extraStop);
425
426
  const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
427
+ let attemptMessages = messages;
428
+ let invalidRequestFeedbackAdded = false;
426
429
  try {
427
430
  const response = await this.withModelRequestSlot(() =>
428
431
  withRetry(async () => {
429
- const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
432
+ const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal }), config.timeout || 30000).catch((error) => {
433
+ if (!invalidRequestFeedbackAdded) {
434
+ const amended = withInvalidRequestFeedback(attemptMessages, error);
435
+ invalidRequestFeedbackAdded = amended !== attemptMessages;
436
+ attemptMessages = amended;
437
+ }
438
+ throw error;
439
+ })) as any;
430
440
  this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
431
441
  const hasToolCall = (result.toolCalls?.length || 0) > 0;
432
442
  if (!result.text && !hasToolCall && result.finishReason === 'length') {
@@ -693,6 +703,22 @@ function repairToolCall(options: ToolCallRepairOptions): any | null {
693
703
  return repairHarmonyChannel(options);
694
704
  }
695
705
 
706
+ function withInvalidRequestFeedback(messages: ModelMessage[], error: unknown): ModelMessage[] {
707
+ if (!(error instanceof APICallError) || error.statusCode !== 400) return messages;
708
+ tag('warning').log('Provider rejected the request as invalid — relaying its reason before the retry');
709
+ return [
710
+ ...messages,
711
+ {
712
+ role: 'user',
713
+ content: dedent`
714
+ The previous request was rejected by the provider as invalid:
715
+ "${error.message}"
716
+ Fix what it describes and re-issue the request.
717
+ `,
718
+ },
719
+ ];
720
+ }
721
+
696
722
  function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any | null {
697
723
  const markerIndex = toolCall.toolName.indexOf('<|channel|>');
698
724
  if (markerIndex <= 0) return null;
@@ -9,6 +9,7 @@ import { diffAriaSnapshots } from '../../utils/aria.ts';
9
9
  import { extractCodeBlocks } from '../../utils/code-extractor.ts';
10
10
  import { tag } from '../../utils/logger.js';
11
11
  import { mdq } from '../../utils/markdown-query.ts';
12
+ import { truncate } from '../../utils/strings.ts';
12
13
  import type { Provider } from '../provider.js';
13
14
  import { getCachedResearch, getPreviousResearch, saveResearch } from './cache.ts';
14
15
  import { type Constructor, debugLog } from './mixin.ts';
@@ -16,6 +17,7 @@ import { type ResearchElement, parseResearchSections } from './parser.ts';
16
17
  import type { ResearchResult } from './research-result.ts';
17
18
 
18
19
  const DEFAULT_MAX_EXPANDABLE_CLICKS = 10;
20
+ const MAX_HTML_DIFF_CHARS = 20_000;
19
21
 
20
22
  export function WithDeepAnalysis<T extends Constructor>(Base: T) {
21
23
  return class extends Base {
@@ -486,6 +488,9 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
486
488
  `;
487
489
  }
488
490
 
491
+ const cleanedParts = await diff.cleanedHtmlParts();
492
+ const htmlChanges = truncate(cleanedParts.map((p) => `[Container: ${p.container}]\n${p.subtree}`).join('\n\n'), MAX_HTML_DIFF_CHARS);
493
+
489
494
  const prompt = dedent`
490
495
  ${intro}
491
496
  Analyze the changes and produce a UI map section.
@@ -494,7 +499,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
494
499
  ${diff.ariaChanged || 'none'}
495
500
 
496
501
  HTML changes:
497
- ${diff.htmlParts.map((p) => `[Container: ${p.container}]\n${p.subtree}`).join('\n\n') || 'none'}
502
+ ${htmlChanges || 'none'}
498
503
  ${alreadyHint}
499
504
 
500
505
  Respond with a SINGLE section in this format:
@@ -111,7 +111,6 @@ export function WithSections<T extends Constructor>(Base: T) {
111
111
  - Do not copy global toolbar, navigation, list, or detail elements into this section unless they are descendants of this section container.
112
112
  - Every element with eidx inside this section's container MUST appear in the table.
113
113
  - Every row needs CSS; ARIA may be "-" for icon-only buttons.
114
- - ARIA locator JSON uses keys "role" and "text" (NOT "name").
115
114
  - Elements marked data-explorbot-hit="covered" or "offscreen" are not directly actionable; describe the covering or focused UI first.
116
115
  - In split-pane pages, entity detail panels are active detail context; include close/back/pin controls in the detail panel section when present.
117
116
  </rules>
@@ -403,7 +403,6 @@ export class Researcher extends ResearcherBase implements Agent {
403
403
  - If an element has data-explorbot-hit="covered" or "offscreen", do not present it as directly actionable. Prefer the overlay, drawer, dialog, or focused section covering it, and mention what must be dismissed or revealed first.
404
404
  - Every element with an eidx attribute MUST appear in exactly one matching UI map section — describe icon-only buttons by their visual role.
405
405
  - Every UI map row needs a CSS selector; ARIA may be "-" for icon-only buttons, CSS must never be "-".
406
- - ARIA locator JSON uses keys "role" and "text" (NOT "name").
407
406
  - Mark elements with likely hover interactions (title, aria-describedby, menu items with submenus) as "(hover)".
408
407
  </rules>
409
408
 
package/src/ai/rules.ts CHANGED
@@ -71,7 +71,6 @@ const locatorStrategyRule = dedent`
71
71
 
72
72
  <bad_aria_locator_example>
73
73
  { "role": "button", "text": "" } // INVALID - empty text is useless, use "-" instead
74
- { "role": "button", "name": "Save" } // WRONG key - use "text", not "name"
75
74
  </bad_aria_locator_example>
76
75
 
77
76
  NEVER include \`eidx\` attribute in any locator (ARIA, CSS, XPath). It is an internal annotation.
package/src/ai/tester.ts CHANGED
@@ -65,6 +65,12 @@ export class Tester extends TaskAgent implements Agent {
65
65
  private stalledIterations = 0;
66
66
  private readonly MAX_STALLED_ITERATIONS = 3;
67
67
 
68
+ private skipResearch = (err: Error): string => {
69
+ if (err.name === 'AbortError') throw err;
70
+ tag('warning').log(`Research skipped: ${err.message}`);
71
+ return '';
72
+ };
73
+
68
74
  constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any) {
69
75
  super(deps);
70
76
  this.requestStore = deps.requestStore;
@@ -581,12 +587,7 @@ export class Tester extends TaskAgent implements Agent {
581
587
  const alreadySeenUiMap = this.seenUiMapUrls.has(currentUrl);
582
588
  let research = '';
583
589
  if (!alreadySeenUiMap) {
584
- try {
585
- research = await this.researcher.research(currentState);
586
- } catch (err) {
587
- if (!(err instanceof ErrorPageError)) throw err;
588
- tag('warning').log(`Research skipped: ${err.message}`);
589
- }
590
+ research = await this.researcher.research(currentState).catch(this.skipResearch);
590
591
  }
591
592
  this.pageStateHash = currentStateHash;
592
593
  this.pageActionResult = currentState;
@@ -627,7 +628,7 @@ export class Tester extends TaskAgent implements Agent {
627
628
  }
628
629
 
629
630
  if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
630
- const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash);
631
+ const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
631
632
  if (overlaySection) {
632
633
  context += dedent`
633
634
 
package/src/ai/tools.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
+ import type { ExecutedStep } from '../action.ts';
4
5
  import { ActionResult, type PageDiff, type ToolResultMetadata } from '../action-result.ts';
5
6
  import { type ExperienceTracker, renderExperienceRecipes } from '../experience-tracker.ts';
6
7
  import { Stats } from '../stats.ts';
@@ -411,7 +412,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
411
412
  const message = errorText(action.lastError);
412
413
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
413
414
 
414
- let formSuggestion = 'Look into error message and identify which commands passed and which failed. Continue execution using step-by-step approach using click() and form() tools.';
415
+ let formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
415
416
  if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
416
417
  const disambiguated = await disambiguateElements(action.lastError, explanation, ai);
417
418
  if (disambiguated) {
@@ -421,10 +422,11 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
421
422
 
422
423
  return failedToolResult(
423
424
  'form',
424
- `Form execution FAILED! ${message}`,
425
+ `Form execution FAILED! ${message}\n${formatExecutedSteps(action.executedSteps, codeLines.length)}`,
425
426
  {
426
427
  ...toolResult,
427
428
  code: codeBlock,
429
+ attempts: action.executedSteps,
428
430
  suggestion: formSuggestion,
429
431
  },
430
432
  action.lastError
@@ -446,6 +448,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
446
448
  ...toolResult,
447
449
  message: `Form completed successfully with ${lines.length} commands.`,
448
450
  commandsExecuted: lines.length,
451
+ attempts: action.executedSteps,
449
452
  code: codeBlock,
450
453
  suggestion: 'Verify the form was filled in correctly using see() tool. If needed to submit: try click() tool or form() with I.pressKey("Enter").',
451
454
  },
@@ -806,21 +809,26 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
806
809
  const actionResult = ActionResult.fromState(currentState);
807
810
  const experience = renderExperienceRecipes(explorer.activeTest?.getAppliedExperience(actionResult) ?? []);
808
811
  const success = await navigator.resolveState(instruction, actionResult, { experience });
812
+ const attempts = navigator.executedSteps;
813
+ let stepReport = '';
814
+ if (attempts.length) stepReport = `\n${formatExecutedSteps(attempts)}`;
809
815
 
810
816
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, instruction);
811
817
 
812
818
  if (success) {
813
819
  return successToolResult('interact', {
814
820
  ...toolResult,
815
- message: `Successfully executed: ${instruction}`,
821
+ message: `Successfully executed: ${instruction}${stepReport}`,
822
+ attempts,
816
823
  });
817
824
  }
818
825
 
819
826
  let reason = '';
820
827
  if (navigator.lastFailureReason) reason = `: ${navigator.lastFailureReason}`;
821
828
 
822
- return failedToolResult('interact', `Failed to execute: ${instruction}${reason}`, {
829
+ return failedToolResult('interact', `Failed to execute: ${instruction}${reason}${stepReport}`, {
823
830
  ...toolResult,
831
+ attempts,
824
832
  suggestion: 'The action could not be completed. Try a different instruction or use more specific element descriptions.',
825
833
  });
826
834
  } catch (error) {
@@ -1232,6 +1240,14 @@ export function hasFailedRequest(pageDiff: PageDiff): boolean {
1232
1240
  return (pageDiff.requests ?? []).some((request) => request.status >= 400);
1233
1241
  }
1234
1242
 
1243
+ export function formatExecutedSteps(steps: ExecutedStep[], requestedCount = steps.length): string {
1244
+ if (!steps.length) return `No command ran of ${requestedCount} requested.`;
1245
+ const lines = steps.map((step) => ` ${step.success ? 'OK' : 'FAILED'} ${step.command}`);
1246
+ const notRun = requestedCount - steps.length;
1247
+ if (notRun > 0) lines.push(` NOT RUN ${notRun} more`);
1248
+ return lines.join('\n');
1249
+ }
1250
+
1235
1251
  function hasObservablePageChange(data?: Record<string, any>): boolean {
1236
1252
  if (!data?.pageDiff) return false;
1237
1253
  if (data.pageDiff.urlChanged === true) return true;
@@ -1,16 +1,18 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, extname, join, resolve } from 'node:path';
3
3
  import chalk from 'chalk';
4
- import dedent from 'dedent';
5
4
  import { ConfigParser, PROVIDERS } from '../config.ts';
6
5
  import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from '../global-config.ts';
7
6
  import { getCliName } from '../utils/cli-name.ts';
8
7
  import { log, tag } from '../utils/logger.js';
9
8
  import { relativeToCwd } from '../utils/next-steps.ts';
10
9
 
11
- function defaultConfigTemplate(): string {
10
+ function defaultConfigTemplate(provider: string, esm: boolean): string {
11
+ let moduleExport = 'module.exports = config;';
12
+ if (esm) moduleExport = 'export default config;';
13
+
12
14
  return `// 'provider/model-id' uses a bundled provider.
13
- // It is also possible to import provider as a module from Vercel AI SDK.
15
+ // It is also possible to import provider as a module from Vercel AI SDK.
14
16
  // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
15
17
 
16
18
  const config = {
@@ -20,7 +22,7 @@ const config = {
20
22
  },
21
23
 
22
24
  ai: {
23
- ${modelLines('openrouter')}
25
+ ${modelLines(provider)}
24
26
  },
25
27
 
26
28
  reporter: {
@@ -33,17 +35,18 @@ ${modelLines('openrouter')}
33
35
  },
34
36
  };
35
37
 
36
- export default config;
38
+ ${moduleExport}
37
39
  `;
38
40
  }
39
41
 
40
- const DEFAULT_ENV_TEMPLATE = dedent`
41
- # AI provider API keys
42
- OPENROUTER_API_KEY=
42
+ function envTemplate(provider: string): string {
43
+ const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
44
+ if (name === provider) return `${envKey}=`;
45
+ return `# ${envKey}=`;
46
+ });
43
47
 
44
- # OPENAI_API_KEY=
45
- # ANTHROPIC_API_KEY=
46
- # GROQ_API_KEY=
48
+ return `# AI provider API keys
49
+ ${keyLines.join('\n')}
47
50
 
48
51
  # Langfuse Tracing
49
52
  LANGFUSE_SECRET_KEY=
@@ -51,11 +54,12 @@ LANGFUSE_PUBLIC_KEY=
51
54
  LANGFUSE_BASE_URL=
52
55
 
53
56
  # Testomat.io API key to publish run results
54
- TESTOMATIO=
55
- `;
57
+ TESTOMATIO=`;
58
+ }
56
59
 
57
60
  export async function runInit(options: InitCommandOptions): Promise<void> {
58
- if (options.global || options.provider) {
61
+ const localRequested = !!(options.configPath || options.path);
62
+ if (options.global || (options.provider && !localRequested)) {
59
63
  await runGlobalInit(options);
60
64
  return;
61
65
  }
@@ -66,7 +70,12 @@ export async function runInit(options: InitCommandOptions): Promise<void> {
66
70
  }
67
71
 
68
72
  const choice = await renderInitWizard('choose');
69
- if (choice === 'local') runInitCommand(options);
73
+ if (choice !== 'local') return;
74
+
75
+ const provider = await renderLocalProviderWizard();
76
+ if (!provider) return;
77
+
78
+ runInitCommand({ ...options, provider });
70
79
  }
71
80
 
72
81
  export function writeGlobalConfig(provider: string, apiKey?: string): void {
@@ -97,7 +106,7 @@ export function writeGlobalConfig(provider: string, apiKey?: string): void {
97
106
  }
98
107
 
99
108
  export function runInitCommand(options: InitCommandOptions): void {
100
- const configPath = options.configPath ?? './explorbot.config.js';
109
+ const provider = options.provider || 'openrouter';
101
110
  const force = options.force ?? false;
102
111
  const customPath = options.path;
103
112
  const originalCwd = process.cwd();
@@ -112,12 +121,15 @@ export function runInitCommand(options: InitCommandOptions): void {
112
121
  log(`Working in directory: ${relativeToCwd(dir)}`);
113
122
  }
114
123
 
124
+ const configName = 'explorbot.config.js';
125
+ const configPath = options.configPath ?? `./${configName}`;
126
+
115
127
  try {
116
128
  let outPath = resolve(configPath);
117
129
  if (existsSync(outPath) && statSync(outPath).isDirectory()) {
118
- outPath = join(outPath, 'explorbot.config.js');
130
+ outPath = join(outPath, configName);
119
131
  } else if (!extname(outPath)) {
120
- outPath = join(outPath, 'explorbot.config.js');
132
+ outPath = join(outPath, configName);
121
133
  }
122
134
 
123
135
  const dir = dirname(outPath);
@@ -132,24 +144,30 @@ export function runInitCommand(options: InitCommandOptions): void {
132
144
  process.exit(1);
133
145
  }
134
146
 
135
- writeFileSync(outPath, defaultConfigTemplate(), 'utf8');
147
+ const esm = extname(outPath) !== '.js' || isModuleProject(dirname(outPath));
148
+ writeFileSync(outPath, defaultConfigTemplate(provider, esm), 'utf8');
136
149
  log(`Created config file: ${relativeToCwd(outPath)}`);
137
150
 
138
151
  const envPath = resolve(process.cwd(), '.env');
139
152
  if (!existsSync(envPath)) {
140
- writeFileSync(envPath, `${DEFAULT_ENV_TEMPLATE}\n`, 'utf8');
153
+ writeFileSync(envPath, `${envTemplate(provider)}\n`, 'utf8');
141
154
  log(`Created env file: ${relativeToCwd(envPath)}`);
142
155
  } else {
143
156
  log(`Env file already exists: ${relativeToCwd(envPath)}`);
144
157
  }
145
158
 
159
+ const missing = missingRoles(provider);
160
+ if (missing.length) {
161
+ tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
162
+ }
163
+
146
164
  log('');
147
165
  log('Next steps:');
148
166
  log('1. Configure AI provider in .env');
149
167
  log('2. Set AI models config file');
150
168
  log('3. Set web application URL in the config file');
151
169
  log('4. Add initial knowledge (how to authorize to the application, etc.)');
152
- tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to aurhorize use these credentials: admin@example.com / secret123'`));
170
+ tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to authorize use these credentials: admin@example.com / secret123'`));
153
171
  tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
154
172
 
155
173
  log('5. Launch application on a relative URL');
@@ -213,6 +231,28 @@ async function renderInitWizard(mode: 'choose' | 'global'): Promise<'local' | 'g
213
231
  });
214
232
  }
215
233
 
234
+ async function renderLocalProviderWizard(): Promise<string | null> {
235
+ const [{ render }, React, InitWizard] = await Promise.all([import('ink'), import('react'), import('../components/InitWizard.js').then((m) => m.default)]);
236
+
237
+ return new Promise((resolve) => {
238
+ const finish = (provider: string | null) => {
239
+ unmount();
240
+ resolve(provider);
241
+ };
242
+ const { unmount } = render(
243
+ React.createElement(InitWizard, {
244
+ mode: 'local',
245
+ globalConfigExists: !!findGlobalConfig(),
246
+ onLocal: () => finish(null),
247
+ onComplete: () => finish(null),
248
+ onCancel: () => finish(null),
249
+ onLocalProvider: (provider: string) => finish(provider),
250
+ }),
251
+ { exitOnCtrlC: false, patchConsole: false }
252
+ );
253
+ });
254
+ }
255
+
216
256
  function modelLines(provider: string): string {
217
257
  const recommended = ConfigParser.recommendedModels()[provider] || {};
218
258
  const roles: Array<[ModelRoleName, string]> = [
@@ -245,10 +285,29 @@ ${modelLines(provider)}
245
285
  },
246
286
  };
247
287
 
248
- export default config;
288
+ module.exports = config;
249
289
  `;
250
290
  }
251
291
 
292
+ function isModuleProject(configDir: string): boolean {
293
+ let currentDir = resolve(configDir);
294
+
295
+ while (true) {
296
+ const packagePath = join(currentDir, 'package.json');
297
+ if (existsSync(packagePath)) {
298
+ try {
299
+ return JSON.parse(readFileSync(packagePath, 'utf8')).type === 'module';
300
+ } catch {
301
+ return false;
302
+ }
303
+ }
304
+
305
+ const parentDir = dirname(currentDir);
306
+ if (parentDir === currentDir) return false;
307
+ currentDir = parentDir;
308
+ }
309
+ }
310
+
252
311
  function missingRoles(provider: string): string[] {
253
312
  const recommended = ConfigParser.recommendedModels()[provider] || {};
254
313
  return ['model', 'visionModel', 'agenticModel'].filter((role) => !recommended[role]);
@@ -10,14 +10,15 @@ import InputReadline from './InputReadline.js';
10
10
  const PROVIDER_NAMES = Object.keys(PROVIDERS);
11
11
 
12
12
  interface InitWizardProps {
13
- mode: 'choose' | 'global';
13
+ mode: 'choose' | 'global' | 'local';
14
14
  globalConfigExists: boolean;
15
15
  onLocal: () => void;
16
16
  onComplete: () => void;
17
17
  onCancel: () => void;
18
+ onLocalProvider?: (provider: string) => void;
18
19
  }
19
20
 
20
- const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLocal, onComplete, onCancel }) => {
21
+ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalProvider }) => {
21
22
  const [step, setStep] = useState<'target' | 'provider' | 'key' | 'validate'>(mode === 'choose' ? 'target' : 'provider');
22
23
  const [targetIndex, setTargetIndex] = useState(0);
23
24
  const [providerIndex, setProviderIndex] = useState(0);
@@ -78,7 +79,10 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
78
79
  if (step === 'provider') {
79
80
  if (key.upArrow) setProviderIndex((index) => Math.max(0, index - 1));
80
81
  if (key.downArrow) setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
81
- if (key.return) setStep('key');
82
+ if (key.return) {
83
+ if (mode === 'local') onLocalProvider?.(provider);
84
+ else setStep('key');
85
+ }
82
86
  return;
83
87
  }
84
88
 
@@ -151,7 +155,7 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
151
155
 
152
156
  <Box marginTop={1}>
153
157
  <Text dimColor>
154
- Config goes to {globalDir()} | {step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm'} | Ctrl+C: exit
158
+ Config goes to {mode === 'local' ? 'the current directory' : globalDir()} | {step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm'} | Ctrl+C: exit
155
159
  </Text>
156
160
  </Box>
157
161
  </Box>
package/src/explorer.ts CHANGED
@@ -255,6 +255,7 @@ class Explorer {
255
255
  const projectRoot = configParser.getProjectRoot();
256
256
  (global as any).output_dir = configParser.getStatesDir();
257
257
  (global as any).codecept_dir = projectRoot;
258
+ (global as any).codeceptjs = codeceptjs;
258
259
 
259
260
  configParser.validateConfig(this.config);
260
261
 
@@ -95,7 +95,7 @@ export class KnowledgeTracker {
95
95
  return this.applicationSpec?.renderFor(state) || '';
96
96
  }
97
97
 
98
- addKnowledge(urlPattern: string, description: string): { filename: string; filePath: string; isNewFile: boolean } {
98
+ addKnowledge(urlPattern: string, description: string, opts?: { replace?: boolean }): { filename: string; filePath: string; isNewFile: boolean } {
99
99
  const configParser = ConfigParser.getInstance();
100
100
  const configPath = configParser.getConfigPath();
101
101
 
@@ -130,10 +130,10 @@ export class KnowledgeTracker {
130
130
 
131
131
  // Append new knowledge with separator
132
132
  let newContent;
133
- if (existingDescription) {
134
- newContent = `${existingDescription}\n\n---\n\n${description}`;
135
- } else {
133
+ if (opts?.replace || !existingDescription) {
136
134
  newContent = description;
135
+ } else {
136
+ newContent = `${existingDescription}\n\n---\n\n${description}`;
137
137
  }
138
138
 
139
139
  const fileContent = matter.stringify(newContent, frontmatter);
package/src/utils/aria.ts CHANGED
@@ -586,7 +586,7 @@ export function parseAriaLocator(ariaStr: string): { role: string; text: string
586
586
  const trimmed = ariaStr.trim();
587
587
  if (trimmed === '-' || trimmed === '' || trimmed === '"-"') return null;
588
588
 
589
- const match = trimmed.match(/\{\s*["']?role["']?\s*:\s*['"]([^'"]+)['"]\s*,\s*["']?text["']?\s*:\s*['"]([^'"]*)['"]\s*\}/);
589
+ const match = trimmed.match(/\{\s*["']?role["']?\s*:\s*['"]([^'"]+)['"]\s*,\s*["']?(?:text|name)["']?\s*:\s*['"]([^'"]*)['"]\s*\}/);
590
590
  if (!match) return null;
591
591
 
592
592
  return { role: match[1], text: match[2] };