explorbot 0.1.0 → 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 (69) hide show
  1. package/bin/explorbot-cli.ts +93 -36
  2. package/dist/bin/explorbot-cli.js +71 -16
  3. package/dist/rules/rerunner/healing-approach.md +19 -0
  4. package/dist/src/action.js +8 -10
  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.js +29 -10
  9. package/dist/src/ai/rerunner.js +472 -0
  10. package/dist/src/ai/researcher.js +3 -4
  11. package/dist/src/ai/rules.js +2 -2
  12. package/dist/src/ai/tools.js +2 -2
  13. package/dist/src/commands/add-rule-command.js +1 -2
  14. package/dist/src/commands/base-command.js +12 -0
  15. package/dist/src/commands/context-command.js +12 -5
  16. package/dist/src/commands/drill-command.js +0 -1
  17. package/dist/src/commands/explore-command.js +20 -5
  18. package/dist/src/commands/freesail-command.js +8 -22
  19. package/dist/src/commands/index.js +4 -0
  20. package/dist/src/commands/init-command.js +3 -3
  21. package/dist/src/commands/path-command.js +2 -1
  22. package/dist/src/commands/plan-command.js +37 -15
  23. package/dist/src/commands/rerun-command.js +42 -0
  24. package/dist/src/commands/research-command.js +10 -4
  25. package/dist/src/commands/runs-command.js +22 -0
  26. package/dist/src/commands/start-command.js +0 -1
  27. package/dist/src/commands/test-command.js +3 -3
  28. package/dist/src/components/App.js +8 -0
  29. package/dist/src/config.js +3 -0
  30. package/dist/src/explorbot.js +19 -0
  31. package/dist/src/explorer.js +2 -1
  32. package/dist/src/suite.js +115 -0
  33. package/dist/src/utils/html.js +2 -5
  34. package/dist/src/utils/rules-loader.js +33 -17
  35. package/dist/src/utils/test-files.js +103 -0
  36. package/package.json +2 -1
  37. package/rules/rerunner/healing-approach.md +19 -0
  38. package/src/action.ts +7 -9
  39. package/src/ai/historian.ts +37 -3
  40. package/src/ai/navigator.ts +35 -28
  41. package/src/ai/pilot.ts +33 -9
  42. package/src/ai/planner.ts +28 -9
  43. package/src/ai/rerunner.ts +532 -0
  44. package/src/ai/researcher.ts +3 -4
  45. package/src/ai/rules.ts +2 -2
  46. package/src/ai/tools.ts +2 -2
  47. package/src/commands/add-rule-command.ts +1 -2
  48. package/src/commands/base-command.ts +13 -0
  49. package/src/commands/context-command.ts +12 -5
  50. package/src/commands/drill-command.ts +0 -1
  51. package/src/commands/explore-command.ts +21 -5
  52. package/src/commands/freesail-command.ts +6 -23
  53. package/src/commands/index.ts +4 -0
  54. package/src/commands/init-command.ts +3 -3
  55. package/src/commands/path-command.ts +2 -1
  56. package/src/commands/plan-command.ts +45 -16
  57. package/src/commands/rerun-command.ts +46 -0
  58. package/src/commands/research-command.ts +10 -4
  59. package/src/commands/runs-command.ts +27 -0
  60. package/src/commands/start-command.ts +0 -1
  61. package/src/commands/test-command.ts +3 -3
  62. package/src/components/App.tsx +8 -0
  63. package/src/config.ts +23 -0
  64. package/src/explorbot.ts +21 -0
  65. package/src/explorer.ts +3 -2
  66. package/src/suite.ts +135 -0
  67. package/src/utils/html.ts +1 -5
  68. package/src/utils/rules-loader.ts +35 -17
  69. package/src/utils/test-files.ts +122 -0
@@ -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.'),
@@ -335,7 +335,7 @@ export function createCodeceptJSTools(explorer, task) {
335
335
  message: `Form completed successfully with ${lines.length} commands.`,
336
336
  commandsExecuted: lines.length,
337
337
  code: codeBlock,
338
- 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").',
339
339
  });
340
340
  }
341
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,27 +8,34 @@ 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');
18
- const { ariaSnapshot } = await explorer.annotateElements();
23
+ const { opts } = this.parseArgs(args);
24
+ const isVisual = !!(opts.visual || opts.screenshot);
25
+ await explorer.annotateElements();
19
26
  if (isVisual) {
20
27
  const cachedResearch = Researcher.getCachedResearch(state);
21
28
  const containers = cachedResearch ? extractValidContainers(cachedResearch) : [];
22
29
  await explorer.visuallyAnnotateElements({ containers });
23
30
  }
24
- const actionResult = await explorer.createAction().capturePageState({ includeScreenshot: isVisual, ariaSnapshot });
31
+ const actionResult = await explorer.createAction().capturePageState({ includeScreenshot: isVisual });
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;
@@ -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,
@@ -22,11 +22,11 @@ const config = {
22
22
 
23
23
  ai: {
24
24
  // fast model with tool calling capabilities
25
- model: openrouter('<your base model here>'),
25
+ model: openrouter('openai/gpt-oss-20b:nitro'),
26
26
  // vision model for screenshot analysis
27
- visionModel: openrouter('<your vision model here>'),
27
+ visionModel: openrouter('meta-llama/llama-4-scout-17b-16e-instruct'),
28
28
  // agentic model for decision making
29
- agenticModel: openrouter('<your agentic model here>'),
29
+ agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
30
30
  },
31
31
  };
32
32
 
@@ -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 {
@@ -11,30 +14,49 @@ export class PlanCommand extends BaseCommand {
11
14
  { flags: '--focus <feature>', description: 'Focus area for test planning' },
12
15
  ];
13
16
  async execute(args) {
14
- const clear = args.includes('--clear');
15
- const fresh = args.includes('--fresh') || clear;
16
- const styleMatch = args.match(/--style\s+(\S+)/);
17
- const style = styleMatch?.[1];
18
- const focusMatch = args.match(/--focus\s+("[^"]+"|'[^']+'|\S+)/);
19
- const focusFromFlag = focusMatch?.[1]?.replace(/^["']|["']$/g, '');
20
- const focusFromText = args
21
- .replace('--clear', '')
22
- .replace('--fresh', '')
23
- .replace(/--style\s+\S+/, '')
24
- .replace(/--focus\s+("[^"]+"|'[^']+'|\S+)/, '')
25
- .trim();
26
- const focus = focusFromFlag || focusFromText;
27
- if (clear) {
17
+ const { opts, args: remaining } = this.parseArgs(args);
18
+ const focus = opts.focus || remaining.join(' ') || undefined;
19
+ if (opts.clear) {
28
20
  this.explorBot.clearPlan();
29
21
  tag('success').log('Plan cleared');
30
22
  }
31
23
  if (focus) {
32
24
  tag('info').log(`Planning focus: ${focus}`);
33
25
  }
34
- await this.explorBot.plan(focus || undefined, { fresh, style });
26
+ await this.explorBot.plan(focus, { fresh: !!(opts.fresh || opts.clear), style: opts.style });
35
27
  const plan = this.explorBot.getCurrentPlan();
36
28
  if (!plan?.tests.length) {
37
29
  throw new Error('No test scenarios in the current plan.');
38
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');
39
61
  }
40
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";
@@ -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
  }
@@ -244,7 +244,8 @@ class Explorer {
244
244
  return action;
245
245
  }
246
246
  async annotateElements() {
247
- return annotatePageElements(this.playwrightHelper.page);
247
+ const { elements } = await annotatePageElements(this.playwrightHelper.page);
248
+ return elements;
248
249
  }
249
250
  async visuallyAnnotateElements(opts) {
250
251
  return visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []);
@@ -0,0 +1,115 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Reflection } from '@codeceptjs/reflection';
4
+ import { ConfigParser } from "./config.js";
5
+ import { normalizeUrl } from "./state-manager.js";
6
+ import { parsePlanFromMarkdown } from "./utils/test-plan-markdown.js";
7
+ import { createDebug } from "./utils/logger.js";
8
+ const debugLog = createDebug('explorbot:suite');
9
+ export class Suite {
10
+ url;
11
+ _automatedTests = null;
12
+ _plannedScenarios = null;
13
+ constructor(url) {
14
+ this.url = url;
15
+ }
16
+ getAutomatedTests() {
17
+ if (this._automatedTests !== null)
18
+ return this._automatedTests;
19
+ this._automatedTests = this.loadAutomatedTests();
20
+ return this._automatedTests;
21
+ }
22
+ getPlannedScenarios() {
23
+ if (this._plannedScenarios !== null)
24
+ return this._plannedScenarios;
25
+ this._plannedScenarios = this.loadPlannedScenarios();
26
+ return this._plannedScenarios;
27
+ }
28
+ getActiveScenarioTitles() {
29
+ return new Set(this.getAutomatedTests()
30
+ .filter((t) => !t.pending)
31
+ .map((t) => t.title.toLowerCase()));
32
+ }
33
+ get automatedTestCount() {
34
+ return this.getAutomatedTests().filter((t) => !t.pending).length;
35
+ }
36
+ getAutomatedTestNames() {
37
+ return this.getAutomatedTests()
38
+ .filter((t) => !t.pending)
39
+ .map((t) => t.title);
40
+ }
41
+ getAutomatedTestFiles() {
42
+ return [...new Set(this.getAutomatedTests().map((t) => t.file))];
43
+ }
44
+ loadAutomatedTests() {
45
+ const testsDir = ConfigParser.getInstance().getTestsDir();
46
+ if (!existsSync(testsDir))
47
+ return [];
48
+ const jsFiles = readdirSync(testsDir)
49
+ .filter((f) => f.endsWith('.js'))
50
+ .map((f) => path.resolve(testsDir, f));
51
+ const results = [];
52
+ for (const filePath of jsFiles) {
53
+ const parsed = this.parseTestFile(filePath);
54
+ if (!parsed)
55
+ continue;
56
+ if (normalizeUrl(parsed.url) !== normalizeUrl(this.url))
57
+ continue;
58
+ results.push(...parsed.tests);
59
+ }
60
+ return results;
61
+ }
62
+ parseTestFile(filePath) {
63
+ try {
64
+ const scanned = Reflection.scanFile(filePath);
65
+ if (!scanned.suites?.length)
66
+ return null;
67
+ const content = readFileSync(filePath, 'utf-8');
68
+ const suiteRef = Reflection.forSuite(scanned.suites[0]);
69
+ const beforeHooks = suiteRef.findHook('Before');
70
+ if (!beforeHooks?.length)
71
+ return null;
72
+ const hookBody = content.slice(beforeHooks[0].range.start, beforeHooks[0].range.end);
73
+ const match = hookBody.match(/I\.amOnPage\(['"]([^'"]+)['"]\)/);
74
+ if (!match)
75
+ return null;
76
+ const lines = content.split('\n');
77
+ const tests = (scanned.tests || []).map((t) => {
78
+ const line = lines[t.line - 1] || '';
79
+ const pending = line.includes('Scenario.skip') || line.includes('Scenario.todo');
80
+ return { title: t.title, pending, file: filePath };
81
+ });
82
+ return { url: match[1], tests };
83
+ }
84
+ catch (err) {
85
+ debugLog('Failed to parse test file %s: %s', filePath, err.message);
86
+ return null;
87
+ }
88
+ }
89
+ loadPlannedScenarios() {
90
+ try {
91
+ const plansDir = ConfigParser.getInstance().getPlansDir();
92
+ if (!existsSync(plansDir))
93
+ return [];
94
+ const mdFiles = readdirSync(plansDir)
95
+ .filter((f) => f.endsWith('.md'))
96
+ .map((f) => path.resolve(plansDir, f));
97
+ const scenarios = [];
98
+ for (const filePath of mdFiles) {
99
+ const plan = parsePlanFromMarkdown(filePath);
100
+ if (!plan.url)
101
+ continue;
102
+ if (normalizeUrl(plan.url) !== normalizeUrl(this.url))
103
+ continue;
104
+ for (const test of plan.tests) {
105
+ scenarios.push(test.scenario);
106
+ }
107
+ }
108
+ return scenarios;
109
+ }
110
+ catch (err) {
111
+ debugLog('Failed to load planned scenarios: %s', err.message);
112
+ return [];
113
+ }
114
+ }
115
+ }
@@ -424,18 +424,15 @@ export function htmlMinimalUISnapshot(html, htmlConfig) {
424
424
  node.attrs = node.attrs.filter((attr) => {
425
425
  const { name, value } = attr;
426
426
  if (name === 'class') {
427
- // Remove classes containing digits
428
427
  attr.value = value
429
428
  .split(' ')
430
- // remove classes containing digits/
431
429
  .filter((className) => !/\d/.test(className))
432
- // remove popular trash classes
433
430
  .filter((className) => !className.match(trashHtmlClasses))
434
- // remove classes with : and __ in them
435
431
  .filter((className) => !className.match(/(:|__)/))
436
- // remove tailwind utility classes
437
432
  .filter((className) => !TAILWIND_CLASS_PATTERNS.some((pattern) => pattern.test(className)))
438
433
  .join(' ');
434
+ if (attr.value === '')
435
+ return false;
439
436
  }
440
437
  return allowedAttrs.includes(name) || name.startsWith('data-explorbot-');
441
438
  });