explorbot 0.0.5 → 0.1.1

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 (85) hide show
  1. package/bin/explorbot-cli.ts +97 -39
  2. package/dist/bin/explorbot-cli.js +75 -19
  3. package/dist/rules/rerunner/healing-approach.md +19 -0
  4. package/dist/src/action.js +8 -7
  5. package/dist/src/ai/historian.js +34 -3
  6. package/dist/src/ai/navigator.js +35 -28
  7. package/dist/src/ai/pilot.js +33 -9
  8. package/dist/src/ai/planner/subpages.js +42 -6
  9. package/dist/src/ai/planner.js +44 -13
  10. package/dist/src/ai/rerunner.js +472 -0
  11. package/dist/src/ai/researcher/cache.js +13 -8
  12. package/dist/src/ai/researcher/coordinates.js +4 -2
  13. package/dist/src/ai/researcher/deep-analysis.js +16 -19
  14. package/dist/src/ai/researcher/locators.js +1 -1
  15. package/dist/src/ai/researcher/parser.js +4 -3
  16. package/dist/src/ai/researcher/research-result.js +2 -0
  17. package/dist/src/ai/researcher.js +3 -3
  18. package/dist/src/ai/rules.js +2 -2
  19. package/dist/src/ai/tools.js +6 -2
  20. package/dist/src/commands/add-rule-command.js +1 -2
  21. package/dist/src/commands/base-command.js +12 -0
  22. package/dist/src/commands/context-command.js +10 -3
  23. package/dist/src/commands/drill-command.js +0 -1
  24. package/dist/src/commands/explore-command.js +21 -6
  25. package/dist/src/commands/freesail-command.js +8 -22
  26. package/dist/src/commands/index.js +4 -0
  27. package/dist/src/commands/init-command.js +7 -5
  28. package/dist/src/commands/path-command.js +2 -1
  29. package/dist/src/commands/plan-command.js +38 -11
  30. package/dist/src/commands/rerun-command.js +42 -0
  31. package/dist/src/commands/research-command.js +10 -4
  32. package/dist/src/commands/runs-command.js +22 -0
  33. package/dist/src/commands/start-command.js +0 -1
  34. package/dist/src/commands/test-command.js +3 -3
  35. package/dist/src/components/App.js +8 -0
  36. package/dist/src/config.js +3 -0
  37. package/dist/src/explorbot.js +20 -1
  38. package/dist/src/explorer.js +59 -16
  39. package/dist/src/suite.js +115 -0
  40. package/dist/src/utils/html.js +2 -5
  41. package/dist/src/utils/rules-loader.js +33 -17
  42. package/dist/src/utils/test-files.js +103 -0
  43. package/dist/src/utils/web-element.js +6 -4
  44. package/package.json +3 -2
  45. package/rules/rerunner/healing-approach.md +19 -0
  46. package/src/action.ts +8 -6
  47. package/src/ai/historian.ts +37 -3
  48. package/src/ai/navigator.ts +35 -28
  49. package/src/ai/pilot.ts +33 -9
  50. package/src/ai/planner/subpages.ts +37 -7
  51. package/src/ai/planner.ts +44 -12
  52. package/src/ai/rerunner.ts +532 -0
  53. package/src/ai/researcher/cache.ts +14 -8
  54. package/src/ai/researcher/coordinates.ts +8 -7
  55. package/src/ai/researcher/deep-analysis.ts +18 -21
  56. package/src/ai/researcher/locators.ts +3 -3
  57. package/src/ai/researcher/parser.ts +4 -4
  58. package/src/ai/researcher/research-result.ts +1 -0
  59. package/src/ai/researcher.ts +3 -3
  60. package/src/ai/rules.ts +2 -2
  61. package/src/ai/tools.ts +7 -2
  62. package/src/commands/add-rule-command.ts +1 -2
  63. package/src/commands/base-command.ts +13 -0
  64. package/src/commands/context-command.ts +10 -3
  65. package/src/commands/drill-command.ts +0 -1
  66. package/src/commands/explore-command.ts +22 -6
  67. package/src/commands/freesail-command.ts +6 -23
  68. package/src/commands/index.ts +4 -0
  69. package/src/commands/init-command.ts +8 -5
  70. package/src/commands/path-command.ts +2 -1
  71. package/src/commands/plan-command.ts +46 -12
  72. package/src/commands/rerun-command.ts +46 -0
  73. package/src/commands/research-command.ts +10 -4
  74. package/src/commands/runs-command.ts +27 -0
  75. package/src/commands/start-command.ts +0 -1
  76. package/src/commands/test-command.ts +3 -3
  77. package/src/components/App.tsx +8 -0
  78. package/src/config.ts +24 -0
  79. package/src/explorbot.ts +22 -1
  80. package/src/explorer.ts +68 -20
  81. package/src/suite.ts +135 -0
  82. package/src/utils/html.ts +1 -5
  83. package/src/utils/rules-loader.ts +35 -17
  84. package/src/utils/test-files.ts +122 -0
  85. package/src/utils/web-element.ts +12 -10
@@ -274,7 +274,7 @@ export function createCodeceptJSTools(explorer, task) {
274
274
  I.selectOption({"role":"combobox","text":"Category"}, 'Technology')
275
275
 
276
276
  Do not submit form - use verify() first to check fields were filled correctly, then click() to submit.
277
- Do not use: wait functions, amOnPage, reloadPage, saveScreenshot
277
+ Do not use: wait functions, amOnPage, reloadPage, saveScreenshot
278
278
  `,
279
279
  inputSchema: z.object({
280
280
  codeBlock: z.string().describe('Valid CodeceptJS code starting with I. Can contain multiple commands separated by newlines.'),
@@ -301,7 +301,11 @@ export function createCodeceptJSTools(explorer, task) {
301
301
  const previousState = ActionResult.fromState(stateManager.getCurrentState());
302
302
  const formLocator = codeLines[0] || 'form';
303
303
  const action = explorer.createAction();
304
+ const wasInIframe = await explorer.isInsideIframe();
304
305
  await action.attempt(codeBlock, explanation);
306
+ if (action.lastError && !wasInIframe && (await explorer.isInsideIframe())) {
307
+ await explorer.switchToMainFrame();
308
+ }
305
309
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, formLocator);
306
310
  if (action.lastError) {
307
311
  const message = action.lastError ? String(action.lastError) : 'Unknown error';
@@ -331,7 +335,7 @@ export function createCodeceptJSTools(explorer, task) {
331
335
  message: `Form completed successfully with ${lines.length} commands.`,
332
336
  commandsExecuted: lines.length,
333
337
  code: codeBlock,
334
- suggestion: 'Verify the form was filled in correctly using see() tool. Submit if needed by using click() tool.',
338
+ suggestion: 'Verify the form was filled in correctly using see() tool. If needed to submit: try click() tool or form() with I.pressKey("Enter").',
335
339
  });
336
340
  }
337
341
  catch (error) {
@@ -5,8 +5,7 @@ import React from 'react';
5
5
  import { tag } from '../utils/logger.js';
6
6
  import { BaseCommand } from './base-command.js';
7
7
  export class AddRuleCommand extends BaseCommand {
8
- name = 'rules:add';
9
- aliases = ['add-rule'];
8
+ name = 'add-rule';
10
9
  description = 'Create a rule file for an agent';
11
10
  suggestions = ['/add-rule researcher check-tooltips'];
12
11
  async execute(args) {
@@ -1,3 +1,4 @@
1
+ import { Command } from 'commander';
1
2
  export class BaseCommand {
2
3
  aliases = [];
3
4
  options = [];
@@ -10,4 +11,15 @@ export class BaseCommand {
10
11
  matches(commandName) {
11
12
  return this.name === commandName || this.aliases.includes(commandName);
12
13
  }
14
+ parseArgs(args) {
15
+ const cmd = new Command();
16
+ cmd.exitOverride();
17
+ for (const opt of this.options) {
18
+ cmd.option(opt.flags, opt.description);
19
+ }
20
+ cmd.argument('[args...]');
21
+ const argv = (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).map((s) => s.replace(/^["']|["']$/g, ''));
22
+ cmd.parse(argv, { from: 'user' });
23
+ return { opts: cmd.opts(), args: cmd.args };
24
+ }
13
25
  }
@@ -8,13 +8,20 @@ export class ContextCommand extends BaseCommand {
8
8
  name = 'context';
9
9
  description = 'Show page context summary (URL, headings, experience, knowledge, ARIA, HTML, research)';
10
10
  suggestions = ['context:aria', 'context:html', 'context:knowledge', 'context:experience', 'context:data'];
11
+ options = [
12
+ { flags: '--visual', description: 'Include annotated screenshot' },
13
+ { flags: '--screenshot', description: 'Include annotated screenshot' },
14
+ { flags: '--full', description: 'Show full context with HTML' },
15
+ { flags: '--attached', description: 'Show attached context mode' },
16
+ ];
11
17
  async execute(args) {
12
18
  const explorer = this.explorBot.getExplorer();
13
19
  const state = explorer.getStateManager().getCurrentState();
14
20
  if (!state) {
15
21
  throw new Error('No active page to show context for');
16
22
  }
17
- const isVisual = args.includes('--visual') || args.includes('--screenshot');
23
+ const { opts } = this.parseArgs(args);
24
+ const isVisual = !!(opts.visual || opts.screenshot);
18
25
  await explorer.annotateElements();
19
26
  if (isVisual) {
20
27
  const cachedResearch = Researcher.getCachedResearch(state);
@@ -25,10 +32,10 @@ export class ContextCommand extends BaseCommand {
25
32
  const experienceTracker = explorer.getStateManager().getExperienceTracker();
26
33
  const knowledgeTracker = this.explorBot.getKnowledgeTracker();
27
34
  let mode = 'compact';
28
- if (args.includes('--full')) {
35
+ if (opts.full) {
29
36
  mode = 'full';
30
37
  }
31
- else if (args.includes('--attached')) {
38
+ else if (opts.attached) {
32
39
  mode = 'attached';
33
40
  }
34
41
  const contextData = {
@@ -2,7 +2,6 @@ import { BaseCommand } from './base-command.js';
2
2
  export class DrillCommand extends BaseCommand {
3
3
  name = 'drill';
4
4
  description = 'Drill all components on current page to learn interactions';
5
- aliases = ['bosun'];
6
5
  suggestions = ['/research - to see UI map first', '/navigate <page> - to go to another page'];
7
6
  async execute(args) {
8
7
  const knowledgePath = this.parseKnowledgeArg(args);
@@ -1,6 +1,8 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
1
2
  import figureSet from 'figures';
2
3
  import path from 'node:path';
3
4
  import { getStyles } from '../ai/planner/styles.js';
5
+ import { ConfigParser } from "../config.js";
4
6
  import { getCliName } from "../utils/cli-name.js";
5
7
  import { jsonToTable } from '../utils/markdown-parser.js';
6
8
  import { tag } from '../utils/logger.js';
@@ -14,12 +16,11 @@ export class ExploreCommand extends BaseCommand {
14
16
  testsRun = 0;
15
17
  completedPlans = [];
16
18
  async execute(args) {
17
- const maxTestsMatch = args.match(/--max-tests\s+(\d+)/);
18
- if (maxTestsMatch) {
19
- this.maxTests = Number.parseInt(maxTestsMatch[1], 10);
20
- args = args.replace(/--max-tests\s+\d+/, '').trim();
19
+ const { opts, args: remaining } = this.parseArgs(args);
20
+ if (opts.maxTests) {
21
+ this.maxTests = Number.parseInt(opts.maxTests, 10);
21
22
  }
22
- const feature = args.trim() || undefined;
23
+ const feature = remaining.join(' ') || undefined;
23
24
  const mainUrl = this.explorBot.getExplorer().getStateManager().getCurrentState()?.url;
24
25
  await this.runAllStyles(mainUrl, feature);
25
26
  const mainPlan = this.explorBot.getCurrentPlan();
@@ -56,6 +57,7 @@ export class ExploreCommand extends BaseCommand {
56
57
  await this.explorBot.visit(mainUrl);
57
58
  const savedPath = this.explorBot.savePlans(this.completedPlans);
58
59
  this.printResults(savedPath);
60
+ this.printRerunSuggestions();
59
61
  }
60
62
  async runAllStyles(pageUrl, feature, parentPlan, completedPlans) {
61
63
  let fresh = true;
@@ -72,7 +74,7 @@ export class ExploreCommand extends BaseCommand {
72
74
  }
73
75
  }
74
76
  printResults(savedPath) {
75
- const allTests = this.completedPlans.flatMap((plan) => plan.tests.filter((t) => t.status !== 'pending').map((test) => ({ test, planTitle: plan.title })));
77
+ const allTests = this.completedPlans.flatMap((plan) => plan.tests.filter((t) => t.startTime != null).map((test) => ({ test, planTitle: plan.title })));
76
78
  if (allTests.length === 0)
77
79
  return;
78
80
  const hasSubPages = this.completedPlans.length > 1;
@@ -107,6 +109,19 @@ export class ExploreCommand extends BaseCommand {
107
109
  tag('info').log(`Re-run tests: ${getCliName()} test ${relativePath} <index>`);
108
110
  }
109
111
  }
112
+ printRerunSuggestions() {
113
+ const testsDir = ConfigParser.getInstance().getTestsDir();
114
+ if (!existsSync(testsDir))
115
+ return;
116
+ const testFiles = readdirSync(testsDir).filter((f) => f.endsWith('.js'));
117
+ if (testFiles.length === 0)
118
+ return;
119
+ for (const file of testFiles) {
120
+ tag('info').log(`Generated: ${file}`);
121
+ }
122
+ tag('info').log(`List tests: ${getCliName()} runs`);
123
+ tag('info').log(`Re-run with healing: ${getCliName()} rerun <filename> [index]`);
124
+ }
110
125
  isLimitReached() {
111
126
  return this.maxTests != null && this.testsRun >= this.maxTests;
112
127
  }
@@ -16,7 +16,14 @@ export class FreesailCommand extends BaseCommand {
16
16
  { flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
17
17
  ];
18
18
  async execute(args) {
19
- const { strategy, scope, maxTests } = parseArgs(args);
19
+ const { opts } = this.parseArgs(args);
20
+ let strategy;
21
+ if (opts.deep)
22
+ strategy = 'deep';
23
+ if (opts.shallow)
24
+ strategy = 'shallow';
25
+ const scope = opts.scope;
26
+ const maxTests = opts.maxTests ? Number.parseInt(opts.maxTests, 10) : undefined;
20
27
  await this.explorBot.visitInitialState();
21
28
  let testsRun = 0;
22
29
  await loop(async (ctx) => {
@@ -60,24 +67,3 @@ export class FreesailCommand extends BaseCommand {
60
67
  }, { maxAttempts: Number.POSITIVE_INFINITY });
61
68
  }
62
69
  }
63
- function parseArgs(args) {
64
- const parts = args.trim().split(/\s+/);
65
- let strategy;
66
- let scope;
67
- let maxTests;
68
- for (let i = 0; i < parts.length; i++) {
69
- if (parts[i] === '--deep')
70
- strategy = 'deep';
71
- if (parts[i] === '--shallow')
72
- strategy = 'shallow';
73
- if (parts[i] === '--scope' && parts[i + 1]) {
74
- scope = parts[i + 1];
75
- i++;
76
- }
77
- if (parts[i] === '--max-tests' && parts[i + 1]) {
78
- maxTests = Number.parseInt(parts[i + 1], 10);
79
- i++;
80
- }
81
- }
82
- return { strategy, scope, maxTests };
83
- }
@@ -22,7 +22,9 @@ import { PlanEditCommand } from './plan-edit-command.js';
22
22
  import { PlanLoadCommand } from './plan-load-command.js';
23
23
  import { PlanReloadCommand } from './plan-reload-command.js';
24
24
  import { PlanSaveCommand } from './plan-save-command.js';
25
+ import { RerunCommand } from './rerun-command.js';
25
26
  import { ResearchCommand } from './research-command.js';
27
+ import { RunsCommand } from './runs-command.js';
26
28
  import { StartCommand } from './start-command.js';
27
29
  import { StatusCommand } from "./status-command.js";
28
30
  import { TestCommand } from './test-command.js';
@@ -53,6 +55,8 @@ const commandClasses = [
53
55
  ContextExperienceCommand,
54
56
  ContextDataCommand,
55
57
  TestCommand,
58
+ RunsCommand,
59
+ RerunCommand,
56
60
  StatusCommand,
57
61
  DebugCommand,
58
62
  ExitCommand,
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, extname, join, resolve } from 'node:path';
3
3
  import { log, tag } from '../utils/logger.js';
4
4
  import dedent from 'dedent';
5
+ import chalk from 'chalk';
5
6
  import { getCliName } from "../utils/cli-name.js";
6
7
  const DEFAULT_CONFIG_TEMPLATE = `import { createOpenRouter } from '@openrouter/ai-sdk-provider';
7
8
  // import { '<your provider here>' } from '<your provider package here>';
@@ -21,11 +22,11 @@ const config = {
21
22
 
22
23
  ai: {
23
24
  // fast model with tool calling capabilities
24
- model: openrouter('<your base model here>'),
25
+ model: openrouter('openai/gpt-oss-20b:nitro'),
25
26
  // vision model for screenshot analysis
26
- visionModel: openrouter('<your vision model here>'),
27
+ visionModel: openrouter('meta-llama/llama-4-scout-17b-16e-instruct'),
27
28
  // agentic model for decision making
28
- agenticModel: openrouter('<your agentic model here>'),
29
+ agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
29
30
  },
30
31
  };
31
32
 
@@ -95,9 +96,10 @@ export function runInitCommand(options) {
95
96
  log('2. Set AI models config file');
96
97
  log('3. Set web application URL in the config file');
97
98
  log('4. Add initial knowledge (how to authorize to the application, etc.)');
98
- tag('substep').log(`${getCliName()} learn * 'to aurhorize use these credentials: admin@example.com / secret123'`);
99
+ tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to aurhorize use these credentials: admin@example.com / secret123'`));
100
+ tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
99
101
  log('5. Launch application on a relative URL');
100
- tag('substep').log(`${getCliName()} start /dashboard`);
102
+ tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
101
103
  if (!existsSync('./output')) {
102
104
  mkdirSync('./output', { recursive: true });
103
105
  log('Created directory: ./output');
@@ -5,7 +5,8 @@ export class PathCommand extends BaseCommand {
5
5
  description = 'Display ASCII graph of navigation paths during the session';
6
6
  options = [{ flags: '--links', description: 'Show outgoing links from each page' }];
7
7
  async execute(args) {
8
- const showLinks = args.includes('--links');
8
+ const { opts } = this.parseArgs(args);
9
+ const showLinks = !!opts.links;
9
10
  const stateManager = this.explorBot.getExplorer().getStateManager();
10
11
  const history = stateManager.getStateHistory();
11
12
  if (history.length === 0) {
@@ -1,3 +1,6 @@
1
+ import path from 'node:path';
2
+ import chalk from 'chalk';
3
+ import figureSet from 'figures';
1
4
  import { tag } from '../utils/logger.js';
2
5
  import { BaseCommand } from './base-command.js';
3
6
  export class PlanCommand extends BaseCommand {
@@ -8,28 +11,52 @@ export class PlanCommand extends BaseCommand {
8
11
  { flags: '--fresh', description: 'Regenerate plan from scratch' },
9
12
  { flags: '--clear', description: 'Clear plan before regenerating' },
10
13
  { flags: '--style <name>', description: 'Planning style (normal, curious, psycho, performer)' },
14
+ { flags: '--focus <feature>', description: 'Focus area for test planning' },
11
15
  ];
12
16
  async execute(args) {
13
- const clear = args.includes('--clear');
14
- const fresh = args.includes('--fresh') || clear;
15
- const styleMatch = args.match(/--style\s+(\S+)/);
16
- const style = styleMatch?.[1];
17
- const focus = args
18
- .replace('--clear', '')
19
- .replace('--fresh', '')
20
- .replace(/--style\s+\S+/, '')
21
- .trim();
22
- if (clear) {
17
+ const { opts, args: remaining } = this.parseArgs(args);
18
+ const focus = opts.focus || remaining.join(' ') || undefined;
19
+ if (opts.clear) {
23
20
  this.explorBot.clearPlan();
24
21
  tag('success').log('Plan cleared');
25
22
  }
26
23
  if (focus) {
27
24
  tag('info').log(`Planning focus: ${focus}`);
28
25
  }
29
- await this.explorBot.plan(focus || undefined, { fresh, style });
26
+ await this.explorBot.plan(focus, { fresh: !!(opts.fresh || opts.clear), style: opts.style });
30
27
  const plan = this.explorBot.getCurrentPlan();
31
28
  if (!plan?.tests.length) {
32
29
  throw new Error('No test scenarios in the current plan.');
33
30
  }
31
+ this.printPlanSummary();
32
+ this.updateSuggestions();
33
+ }
34
+ printPlanSummary() {
35
+ const suite = this.explorBot.getSuite();
36
+ const plan = this.explorBot.getCurrentPlan();
37
+ if (suite && suite.automatedTestCount > 0) {
38
+ const names = suite.getAutomatedTestNames();
39
+ console.log(`\n${chalk.bold.cyan(`Already implemented (${names.length} tests)`)}`);
40
+ for (let i = 0; i < names.length; i++) {
41
+ console.log(` ${chalk.dim(`${i + 1}.`)} ${chalk.green(figureSet.pointer)} ${names[i]}`);
42
+ }
43
+ }
44
+ if (plan?.tests.length) {
45
+ console.log(`\n${chalk.bold.cyan(`New test scenarios (${plan.tests.length})`)}`);
46
+ for (let i = 0; i < plan.tests.length; i++) {
47
+ const t = plan.tests[i];
48
+ console.log(` ${chalk.dim(`${i + 1}.`)} ${chalk.green(figureSet.pointer)} ${t.scenario} ${chalk.dim(`[${t.priority}]`)}`);
49
+ }
50
+ }
51
+ }
52
+ updateSuggestions() {
53
+ this.suggestions = ['/test - to launch first test', '/test * - to launch all tests'];
54
+ const suite = this.explorBot.getSuite();
55
+ if (suite && suite.automatedTestCount > 0) {
56
+ for (const f of suite.getAutomatedTestFiles()) {
57
+ this.suggestions.push(`/rerun ${path.relative(process.cwd(), f)} - re-run automated tests`);
58
+ }
59
+ }
60
+ this.suggestions.push('Edit the plan in file and call /plan:reload to update it');
34
61
  }
35
62
  }
@@ -0,0 +1,42 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { ConfigParser } from "../config.js";
4
+ import { tag } from "../utils/logger.js";
5
+ import { BaseCommand } from './base-command.js';
6
+ export class RerunCommand extends BaseCommand {
7
+ name = 'rerun';
8
+ description = 'Re-run generated tests with AI auto-healing';
9
+ tuiEnabled = true;
10
+ async execute(args) {
11
+ const { args: remaining } = this.parseArgs(args);
12
+ const filename = remaining[0];
13
+ const indexArg = remaining[1];
14
+ if (!filename) {
15
+ tag('error').log('Usage: /rerun <filename> [index]');
16
+ return;
17
+ }
18
+ let filePath = resolve(filename);
19
+ if (!existsSync(filePath)) {
20
+ filePath = resolve(ConfigParser.getInstance().getTestsDir(), filename);
21
+ }
22
+ const testIndices = indexArg ? parseTestIndices(indexArg) : undefined;
23
+ await this.explorBot.agentRerunner().rerun(filePath, { testIndices });
24
+ }
25
+ }
26
+ function parseTestIndices(input) {
27
+ if (input === '*' || input === 'all')
28
+ return [];
29
+ const indices = new Set();
30
+ for (const part of input.split(',')) {
31
+ const trimmed = part.trim();
32
+ const range = trimmed.match(/^(\d+)-(\d+)$/);
33
+ if (range) {
34
+ for (let i = Number.parseInt(range[1]); i <= Number.parseInt(range[2]); i++)
35
+ indices.add(i - 1);
36
+ }
37
+ else {
38
+ indices.add(Number.parseInt(trimmed) - 1);
39
+ }
40
+ }
41
+ return [...indices].sort((a, b) => a - b);
42
+ }
@@ -6,11 +6,17 @@ export class ResearchCommand extends BaseCommand {
6
6
  name = 'research';
7
7
  description = 'Research current page or navigate to URI and research. Use --deep to explore interactive elements by clicking them. Use --data to include page data.';
8
8
  suggestions = ['/navigate <page> - to go to another page', '/plan <feature> - to plan testing'];
9
+ options = [
10
+ { flags: '--data', description: 'Include page data' },
11
+ { flags: '--deep', description: 'Explore interactive elements by clicking them' },
12
+ { flags: '--no-fix', description: 'Skip fixing research issues' },
13
+ ];
9
14
  async execute(args) {
10
- const includeData = args.includes('--data');
11
- const enableDeep = args.includes('--deep');
12
- const noFix = args.includes('--no-fix');
13
- const target = args.replace('--data', '').replace('--deep', '').replace('--no-fix', '').trim();
15
+ const { opts, args: remaining } = this.parseArgs(args);
16
+ const includeData = !!opts.data;
17
+ const enableDeep = !!opts.deep;
18
+ const noFix = !!opts.noFix;
19
+ const target = remaining.join(' ');
14
20
  if (target) {
15
21
  await this.explorBot.agentNavigator().visit(target);
16
22
  }
@@ -0,0 +1,22 @@
1
+ import * as codeceptjs from 'codeceptjs';
2
+ import { ConfigParser } from "../config.js";
3
+ import { dryRunTestFile, loadTestSuites, printTestList } from "../utils/test-files.js";
4
+ import { BaseCommand } from './base-command.js';
5
+ export class RunsCommand extends BaseCommand {
6
+ name = 'runs';
7
+ description = 'List generated test files and their scenarios';
8
+ tuiEnabled = true;
9
+ async execute(args) {
10
+ if (!this.explorBot.isExploring) {
11
+ codeceptjs.container.create({ helpers: {} }, {});
12
+ }
13
+ const { args: remaining } = this.parseArgs(args);
14
+ const filePath = remaining[0];
15
+ if (filePath) {
16
+ await dryRunTestFile(filePath);
17
+ return;
18
+ }
19
+ const suites = loadTestSuites(ConfigParser.getInstance().getTestsDir());
20
+ printTestList(suites);
21
+ }
22
+ }
@@ -2,7 +2,6 @@ import { BaseCommand } from './base-command.js';
2
2
  import { ExploreCommand } from './explore-command.js';
3
3
  export class StartCommand extends BaseCommand {
4
4
  name = 'start';
5
- aliases = ['sail'];
6
5
  description = 'Start web exploration';
7
6
  suggestions = ['/navigate <page> - to go to another page', '/research - to analyze', '/plan <feature> - to plan testing'];
8
7
  async execute(args) {
@@ -24,10 +24,10 @@ export class TestCommand extends BaseCommand {
24
24
  toExecute.push(...requirePlan().getPendingTests());
25
25
  }
26
26
  else if (args.match(/^[\d,\-\s]+$/)) {
27
- const pending = requirePlan().getPendingTests();
28
- const indices = parseTestIndices(args, pending.length);
27
+ const visible = requirePlan().tests.filter((t) => t.enabled);
28
+ const indices = parseTestIndices(args, visible.length);
29
29
  for (const idx of indices) {
30
- toExecute.push(pending[idx]);
30
+ toExecute.push(visible[idx]);
31
31
  }
32
32
  }
33
33
  else {
@@ -206,6 +206,14 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
206
206
  setShowPlanEditor(true);
207
207
  return;
208
208
  }
209
+ if (key.upArrow) {
210
+ setTaskScrollOffset((prev) => Math.max(0, prev - 1));
211
+ return;
212
+ }
213
+ if (key.downArrow) {
214
+ setTaskScrollOffset((prev) => prev + 1);
215
+ return;
216
+ }
209
217
  }
210
218
  if (!showInput && !showPlanEditor) {
211
219
  if (key.upArrow) {
@@ -100,6 +100,9 @@ export class ConfigParser {
100
100
  getPlansDir() {
101
101
  return outputPath('plans');
102
102
  }
103
+ getTestsDir() {
104
+ return outputPath('tests');
105
+ }
103
106
  // For testing purposes only
104
107
  static resetForTesting() {
105
108
  if (ConfigParser.instance) {
@@ -14,6 +14,7 @@ import { Planner } from "./ai/planner.js";
14
14
  import { AIProvider } from "./ai/provider.js";
15
15
  import { Quartermaster } from "./ai/quartermaster.js";
16
16
  import { Researcher } from "./ai/researcher.js";
17
+ import { Rerunner } from "./ai/rerunner.js";
17
18
  import { Tester } from "./ai/tester.js";
18
19
  import { createAgentTools } from "./ai/tools.js";
19
20
  import { ConfigParser } from "./config.js";
@@ -84,7 +85,7 @@ export class ExplorBot {
84
85
  await this.explorer.openFreshTab();
85
86
  }
86
87
  getCurrentState() {
87
- return this.explorer.getStateManager().getCurrentState();
88
+ return this.explorer?.getStateManager().getCurrentState() ?? null;
88
89
  }
89
90
  getExplorer() {
90
91
  return this.explorer;
@@ -202,6 +203,21 @@ export class ExplorBot {
202
203
  return new Historian(ai, experienceTracker, reporter, explorer.getStateManager());
203
204
  }));
204
205
  }
206
+ agentRerunner() {
207
+ if (!this.agents.rerunner) {
208
+ this.agents.rerunner = this.createAgent(({ ai, explorer }) => {
209
+ const researcher = this.agentResearcher();
210
+ const navigator = this.agentNavigator();
211
+ const tools = createAgentTools({ explorer, researcher, navigator });
212
+ return new Rerunner(explorer, ai, tools);
213
+ });
214
+ const qm = this.agentQuartermaster();
215
+ if (qm)
216
+ this.agents.rerunner.setQuartermaster(qm);
217
+ this.agents.rerunner.setHistorian(this.agentHistorian());
218
+ }
219
+ return this.agents.rerunner;
220
+ }
205
221
  agentBosun() {
206
222
  return (this.agents.bosun ||= this.createAgent(({ ai, explorer }) => {
207
223
  const researcher = this.agentResearcher();
@@ -243,6 +259,9 @@ export class ExplorBot {
243
259
  getCurrentPlan() {
244
260
  return this.currentPlan;
245
261
  }
262
+ getSuite() {
263
+ return this.agentPlanner().getSuite();
264
+ }
246
265
  getPlanFeature() {
247
266
  return this.planFeature;
248
267
  }
@@ -15,6 +15,7 @@ import { StateManager } from './state-manager.js';
15
15
  import { RequestStore } from "./api/request-store.js";
16
16
  import { XhrCapture } from "./api/xhr-capture.js";
17
17
  import { createDebug, log, tag } from './utils/logger.js';
18
+ import { WebElement, extractElementData } from "./utils/web-element.js";
18
19
  const debugLog = createDebug('explorbot:explorer');
19
20
  const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
20
21
  class Explorer {
@@ -243,19 +244,8 @@ class Explorer {
243
244
  return action;
244
245
  }
245
246
  async annotateElements() {
246
- const page = this.playwrightHelper.page;
247
- const roles = ['button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'switch', 'combobox', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem'];
248
- let idx = 1;
249
- for (const role of roles) {
250
- const elements = await page.getByRole(role).all();
251
- for (const el of elements) {
252
- await el.evaluate((node, i) => {
253
- node.setAttribute('data-explorbot-eidx', String(i));
254
- }, idx);
255
- idx++;
256
- }
257
- }
258
- return idx - 1;
247
+ const { elements } = await annotatePageElements(this.playwrightHelper.page);
248
+ return elements;
259
249
  }
260
250
  async visuallyAnnotateElements(opts) {
261
251
  return visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []);
@@ -269,7 +259,7 @@ class Explorer {
269
259
  for (const el of elements) {
270
260
  const attr = await el.getAttribute('data-explorbot-eidx');
271
261
  if (attr)
272
- result.push(Number.parseInt(attr, 10));
262
+ result.push(attr);
273
263
  }
274
264
  return result;
275
265
  }
@@ -286,8 +276,7 @@ class Explorer {
286
276
  const page = this.playwrightHelper.page;
287
277
  const base = container ? page.locator(container) : page;
288
278
  const el = locator.startsWith('//') ? base.locator(`xpath=${locator}`) : base.locator(locator);
289
- const eidx = await el.first().getAttribute('data-explorbot-eidx');
290
- return eidx ? Number.parseInt(eidx, 10) : null;
279
+ return await el.first().getAttribute('data-explorbot-eidx');
291
280
  }
292
281
  catch (error) {
293
282
  if (this.isFatalBrowserError(error)) {
@@ -607,4 +596,58 @@ function toCodeceptjsTest(test) {
607
596
  codeceptjsTest.notes = test.getPrintableNotes();
608
597
  return codeceptjsTest;
609
598
  }
599
+ const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
600
+ const ANNOTATABLE_ROLES = new Set(['button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'switch', 'combobox', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem']);
601
+ function parseAriaRefs(ariaSnapshot) {
602
+ const entries = [];
603
+ for (const line of ariaSnapshot.split('\n')) {
604
+ const match = line.match(REF_LINE_PATTERN);
605
+ if (!match)
606
+ continue;
607
+ if (!ANNOTATABLE_ROLES.has(match[2]))
608
+ continue;
609
+ entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
610
+ }
611
+ return entries;
612
+ }
613
+ export async function annotatePageElements(page) {
614
+ const ariaSnapshot = await page.locator('body').ariaSnapshot({ forAI: true });
615
+ const refEntries = parseAriaRefs(ariaSnapshot);
616
+ const byRole = new Map();
617
+ for (const { role, name, ref } of refEntries) {
618
+ let list = byRole.get(role);
619
+ if (!list) {
620
+ list = [];
621
+ byRole.set(role, list);
622
+ }
623
+ list.push({ name, ref });
624
+ }
625
+ const elements = [];
626
+ for (const [role, entries] of byRole) {
627
+ try {
628
+ const rawList = await page.getByRole(role).evaluateAll((domElements, [data, extractFnStr]) => {
629
+ const extract = new Function(`return ${extractFnStr}`)();
630
+ const results = [];
631
+ let ariaIdx = 0;
632
+ for (const el of domElements) {
633
+ if (ariaIdx >= data.length)
634
+ break;
635
+ el.setAttribute('data-explorbot-eidx', data[ariaIdx].ref);
636
+ const elData = extract(el);
637
+ if (elData)
638
+ results.push(elData);
639
+ ariaIdx++;
640
+ }
641
+ return results;
642
+ }, [entries, extractElementData.toString()]);
643
+ for (const raw of rawList) {
644
+ elements.push(WebElement.fromRawData(raw, role));
645
+ }
646
+ }
647
+ catch {
648
+ debugLog(`Failed to annotate role=${role}`);
649
+ }
650
+ }
651
+ return { ariaSnapshot, elements };
652
+ }
610
653
  export default Explorer;