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
@@ -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.ts';
4
6
  import { getCliName } from '../utils/cli-name.ts';
5
7
  import type { Plan } from '../test-plan.js';
6
8
  import { jsonToTable } from '../utils/markdown-parser.js';
@@ -18,13 +20,12 @@ export class ExploreCommand extends BaseCommand {
18
20
  private completedPlans: Plan[] = [];
19
21
 
20
22
  async execute(args: string): Promise<void> {
21
- const maxTestsMatch = args.match(/--max-tests\s+(\d+)/);
22
- if (maxTestsMatch) {
23
- this.maxTests = Number.parseInt(maxTestsMatch[1], 10);
24
- args = args.replace(/--max-tests\s+\d+/, '').trim();
23
+ const { opts, args: remaining } = this.parseArgs(args);
24
+ if (opts.maxTests) {
25
+ this.maxTests = Number.parseInt(opts.maxTests as string, 10);
25
26
  }
26
27
 
27
- const feature = args.trim() || undefined;
28
+ const feature = remaining.join(' ') || undefined;
28
29
  const mainUrl = this.explorBot.getExplorer().getStateManager().getCurrentState()?.url;
29
30
 
30
31
  await this.runAllStyles(mainUrl, feature);
@@ -61,6 +62,7 @@ export class ExploreCommand extends BaseCommand {
61
62
  if (mainUrl) await this.explorBot.visit(mainUrl);
62
63
  const savedPath = this.explorBot.savePlans(this.completedPlans);
63
64
  this.printResults(savedPath);
65
+ this.printRerunSuggestions();
64
66
  }
65
67
 
66
68
  private async runAllStyles(pageUrl?: string, feature?: string, parentPlan?: Plan, completedPlans?: Plan[]): Promise<void> {
@@ -113,6 +115,20 @@ export class ExploreCommand extends BaseCommand {
113
115
  }
114
116
  }
115
117
 
118
+ private printRerunSuggestions(): void {
119
+ const testsDir = ConfigParser.getInstance().getTestsDir();
120
+ if (!existsSync(testsDir)) return;
121
+
122
+ const testFiles = readdirSync(testsDir).filter((f) => f.endsWith('.js'));
123
+ if (testFiles.length === 0) return;
124
+
125
+ for (const file of testFiles) {
126
+ tag('info').log(`Generated: ${file}`);
127
+ }
128
+ tag('info').log(`List tests: ${getCliName()} runs`);
129
+ tag('info').log(`Re-run with healing: ${getCliName()} rerun <filename> [index]`);
130
+ }
131
+
116
132
  private isLimitReached(): boolean {
117
133
  return this.maxTests != null && this.testsRun >= this.maxTests;
118
134
  }
@@ -18,7 +18,12 @@ export class FreesailCommand extends BaseCommand {
18
18
  ];
19
19
 
20
20
  async execute(args: string): Promise<void> {
21
- const { strategy, scope, maxTests } = parseArgs(args);
21
+ const { opts } = this.parseArgs(args);
22
+ let strategy: 'deep' | 'shallow' | undefined;
23
+ if (opts.deep) strategy = 'deep';
24
+ if (opts.shallow) strategy = 'shallow';
25
+ const scope = opts.scope as string | undefined;
26
+ const maxTests = opts.maxTests ? Number.parseInt(opts.maxTests as string, 10) : undefined;
22
27
 
23
28
  await this.explorBot.visitInitialState();
24
29
 
@@ -71,25 +76,3 @@ export class FreesailCommand extends BaseCommand {
71
76
  );
72
77
  }
73
78
  }
74
-
75
- function parseArgs(args: string): { strategy: 'deep' | 'shallow' | undefined; scope: string | undefined; maxTests: number | undefined } {
76
- const parts = args.trim().split(/\s+/);
77
- let strategy: 'deep' | 'shallow' | undefined;
78
- let scope: string | undefined;
79
- let maxTests: number | undefined;
80
-
81
- for (let i = 0; i < parts.length; i++) {
82
- if (parts[i] === '--deep') strategy = 'deep';
83
- if (parts[i] === '--shallow') strategy = 'shallow';
84
- if (parts[i] === '--scope' && parts[i + 1]) {
85
- scope = parts[i + 1];
86
- i++;
87
- }
88
- if (parts[i] === '--max-tests' && parts[i + 1]) {
89
- maxTests = Number.parseInt(parts[i + 1], 10);
90
- i++;
91
- }
92
- }
93
-
94
- return { strategy, scope, maxTests };
95
- }
@@ -24,7 +24,9 @@ import { PlanEditCommand } from './plan-edit-command.js';
24
24
  import { PlanLoadCommand } from './plan-load-command.js';
25
25
  import { PlanReloadCommand } from './plan-reload-command.js';
26
26
  import { PlanSaveCommand } from './plan-save-command.js';
27
+ import { RerunCommand } from './rerun-command.js';
27
28
  import { ResearchCommand } from './research-command.js';
29
+ import { RunsCommand } from './runs-command.js';
28
30
  import { StartCommand } from './start-command.js';
29
31
  import { StatusCommand } from './status-command.tsx';
30
32
  import { TestCommand } from './test-command.js';
@@ -59,6 +61,8 @@ const commandClasses: CommandClass[] = [
59
61
  ContextExperienceCommand,
60
62
  ContextDataCommand,
61
63
  TestCommand,
64
+ RunsCommand,
65
+ RerunCommand,
62
66
  StatusCommand,
63
67
  DebugCommand,
64
68
  ExitCommand,
@@ -23,11 +23,11 @@ const config = {
23
23
 
24
24
  ai: {
25
25
  // fast model with tool calling capabilities
26
- model: openrouter('<your base model here>'),
26
+ model: openrouter('openai/gpt-oss-20b:nitro'),
27
27
  // vision model for screenshot analysis
28
- visionModel: openrouter('<your vision model here>'),
28
+ visionModel: openrouter('meta-llama/llama-4-scout-17b-16e-instruct'),
29
29
  // agentic model for decision making
30
- agenticModel: openrouter('<your agentic model here>'),
30
+ agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
31
31
  },
32
32
  };
33
33
 
@@ -14,7 +14,8 @@ export class PathCommand extends BaseCommand {
14
14
  options = [{ flags: '--links', description: 'Show outgoing links from each page' }];
15
15
 
16
16
  async execute(args: string): Promise<void> {
17
- const showLinks = args.includes('--links');
17
+ const { opts } = this.parseArgs(args);
18
+ const showLinks = !!opts.links;
18
19
  const stateManager = this.explorBot.getExplorer().getStateManager();
19
20
  const history = stateManager.getStateHistory();
20
21
 
@@ -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
 
@@ -13,21 +16,10 @@ export class PlanCommand extends BaseCommand {
13
16
  ];
14
17
 
15
18
  async execute(args: string): Promise<void> {
16
- const clear = args.includes('--clear');
17
- const fresh = args.includes('--fresh') || clear;
18
- const styleMatch = args.match(/--style\s+(\S+)/);
19
- const style = styleMatch?.[1];
20
- const focusMatch = args.match(/--focus\s+("[^"]+"|'[^']+'|\S+)/);
21
- const focusFromFlag = focusMatch?.[1]?.replace(/^["']|["']$/g, '');
22
- const focusFromText = args
23
- .replace('--clear', '')
24
- .replace('--fresh', '')
25
- .replace(/--style\s+\S+/, '')
26
- .replace(/--focus\s+("[^"]+"|'[^']+'|\S+)/, '')
27
- .trim();
28
- const focus = focusFromFlag || focusFromText;
29
-
30
- if (clear) {
19
+ const { opts, args: remaining } = this.parseArgs(args);
20
+ const focus = (opts.focus as string) || remaining.join(' ') || undefined;
21
+
22
+ if (opts.clear) {
31
23
  this.explorBot.clearPlan();
32
24
  tag('success').log('Plan cleared');
33
25
  }
@@ -36,11 +28,48 @@ export class PlanCommand extends BaseCommand {
36
28
  tag('info').log(`Planning focus: ${focus}`);
37
29
  }
38
30
 
39
- await this.explorBot.plan(focus || undefined, { fresh, style });
31
+ await this.explorBot.plan(focus, { fresh: !!(opts.fresh || opts.clear), style: opts.style as string });
40
32
 
41
33
  const plan = this.explorBot.getCurrentPlan();
42
34
  if (!plan?.tests.length) {
43
35
  throw new Error('No test scenarios in the current plan.');
44
36
  }
37
+
38
+ this.printPlanSummary();
39
+ this.updateSuggestions();
40
+ }
41
+
42
+ private printPlanSummary(): void {
43
+ const suite = this.explorBot.getSuite();
44
+ const plan = this.explorBot.getCurrentPlan();
45
+
46
+ if (suite && suite.automatedTestCount > 0) {
47
+ const names = suite.getAutomatedTestNames();
48
+ console.log(`\n${chalk.bold.cyan(`Already implemented (${names.length} tests)`)}`);
49
+ for (let i = 0; i < names.length; i++) {
50
+ console.log(` ${chalk.dim(`${i + 1}.`)} ${chalk.green(figureSet.pointer)} ${names[i]}`);
51
+ }
52
+ }
53
+
54
+ if (plan?.tests.length) {
55
+ console.log(`\n${chalk.bold.cyan(`New test scenarios (${plan.tests.length})`)}`);
56
+ for (let i = 0; i < plan.tests.length; i++) {
57
+ const t = plan.tests[i];
58
+ console.log(` ${chalk.dim(`${i + 1}.`)} ${chalk.green(figureSet.pointer)} ${t.scenario} ${chalk.dim(`[${t.priority}]`)}`);
59
+ }
60
+ }
61
+ }
62
+
63
+ private updateSuggestions(): void {
64
+ this.suggestions = ['/test - to launch first test', '/test * - to launch all tests'];
65
+
66
+ const suite = this.explorBot.getSuite();
67
+ if (suite && suite.automatedTestCount > 0) {
68
+ for (const f of suite.getAutomatedTestFiles()) {
69
+ this.suggestions.push(`/rerun ${path.relative(process.cwd(), f)} - re-run automated tests`);
70
+ }
71
+ }
72
+
73
+ this.suggestions.push('Edit the plan in file and call /plan:reload to update it');
45
74
  }
46
75
  }
@@ -0,0 +1,46 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { ConfigParser } from '../config.ts';
4
+ import { tag } from '../utils/logger.ts';
5
+ import { BaseCommand } from './base-command.js';
6
+
7
+ export class RerunCommand extends BaseCommand {
8
+ name = 'rerun';
9
+ description = 'Re-run generated tests with AI auto-healing';
10
+ tuiEnabled = true;
11
+
12
+ async execute(args: string): Promise<void> {
13
+ const { args: remaining } = this.parseArgs(args);
14
+ const filename = remaining[0];
15
+ const indexArg = remaining[1];
16
+
17
+ if (!filename) {
18
+ tag('error').log('Usage: /rerun <filename> [index]');
19
+ return;
20
+ }
21
+
22
+ let filePath = resolve(filename);
23
+ if (!existsSync(filePath)) {
24
+ filePath = resolve(ConfigParser.getInstance().getTestsDir(), filename);
25
+ }
26
+
27
+ const testIndices = indexArg ? parseTestIndices(indexArg) : undefined;
28
+ await this.explorBot.agentRerunner().rerun(filePath, { testIndices });
29
+ }
30
+ }
31
+
32
+ function parseTestIndices(input: string): number[] {
33
+ if (input === '*' || input === 'all') return [];
34
+
35
+ const indices = new Set<number>();
36
+ for (const part of input.split(',')) {
37
+ const trimmed = part.trim();
38
+ const range = trimmed.match(/^(\d+)-(\d+)$/);
39
+ if (range) {
40
+ for (let i = Number.parseInt(range[1]); i <= Number.parseInt(range[2]); i++) indices.add(i - 1);
41
+ } else {
42
+ indices.add(Number.parseInt(trimmed) - 1);
43
+ }
44
+ }
45
+ return [...indices].sort((a, b) => a - b);
46
+ }
@@ -7,12 +7,18 @@ export class ResearchCommand extends BaseCommand {
7
7
  name = 'research';
8
8
  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.';
9
9
  suggestions = ['/navigate <page> - to go to another page', '/plan <feature> - to plan testing'];
10
+ options = [
11
+ { flags: '--data', description: 'Include page data' },
12
+ { flags: '--deep', description: 'Explore interactive elements by clicking them' },
13
+ { flags: '--no-fix', description: 'Skip fixing research issues' },
14
+ ];
10
15
 
11
16
  async execute(args: string): Promise<void> {
12
- const includeData = args.includes('--data');
13
- const enableDeep = args.includes('--deep');
14
- const noFix = args.includes('--no-fix');
15
- const target = args.replace('--data', '').replace('--deep', '').replace('--no-fix', '').trim();
17
+ const { opts, args: remaining } = this.parseArgs(args);
18
+ const includeData = !!opts.data;
19
+ const enableDeep = !!opts.deep;
20
+ const noFix = !!opts.noFix;
21
+ const target = remaining.join(' ');
16
22
 
17
23
  if (target) {
18
24
  await this.explorBot.agentNavigator().visit(target);
@@ -0,0 +1,27 @@
1
+ import * as codeceptjs from 'codeceptjs';
2
+ import { ConfigParser } from '../config.ts';
3
+ import { dryRunTestFile, loadTestSuites, printTestList } from '../utils/test-files.ts';
4
+ import { BaseCommand } from './base-command.js';
5
+
6
+ export class RunsCommand extends BaseCommand {
7
+ name = 'runs';
8
+ description = 'List generated test files and their scenarios';
9
+ tuiEnabled = true;
10
+
11
+ async execute(args: string): Promise<void> {
12
+ if (!this.explorBot.isExploring) {
13
+ codeceptjs.container.create({ helpers: {} }, {});
14
+ }
15
+
16
+ const { args: remaining } = this.parseArgs(args);
17
+ const filePath = remaining[0];
18
+
19
+ if (filePath) {
20
+ await dryRunTestFile(filePath);
21
+ return;
22
+ }
23
+
24
+ const suites = loadTestSuites(ConfigParser.getInstance().getTestsDir());
25
+ printTestList(suites);
26
+ }
27
+ }
@@ -3,7 +3,6 @@ import { ExploreCommand } from './explore-command.js';
3
3
 
4
4
  export class StartCommand extends BaseCommand {
5
5
  name = 'start';
6
- aliases = ['sail'];
7
6
  description = 'Start web exploration';
8
7
  suggestions = ['/navigate <page> - to go to another page', '/research - to analyze', '/plan <feature> - to plan testing'];
9
8
 
@@ -25,10 +25,10 @@ export class TestCommand extends BaseCommand {
25
25
  } else if (args === '*' || args === 'all') {
26
26
  toExecute.push(...requirePlan().getPendingTests());
27
27
  } else if (args.match(/^[\d,\-\s]+$/)) {
28
- const pending = requirePlan().getPendingTests();
29
- const indices = parseTestIndices(args, pending.length);
28
+ const visible = requirePlan().tests.filter((t) => t.enabled);
29
+ const indices = parseTestIndices(args, visible.length);
30
30
  for (const idx of indices) {
31
- toExecute.push(pending[idx]);
31
+ toExecute.push(visible[idx]);
32
32
  }
33
33
  } else {
34
34
  const matching = plan?.getPendingTests().filter((test) => test.scenario.toLowerCase().includes(args.toLowerCase())) || [];
@@ -242,6 +242,14 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
242
242
  setShowPlanEditor(true);
243
243
  return;
244
244
  }
245
+ if (key.upArrow) {
246
+ setTaskScrollOffset((prev) => Math.max(0, prev - 1));
247
+ return;
248
+ }
249
+ if (key.downArrow) {
250
+ setTaskScrollOffset((prev) => prev + 1);
251
+ return;
252
+ }
245
253
  }
246
254
 
247
255
  if (!showInput && !showPlanEditor) {
package/src/config.ts CHANGED
@@ -85,6 +85,22 @@ interface NavigatorAgentConfig extends AgentConfig {
85
85
  maxAttempts?: number;
86
86
  }
87
87
 
88
+ type HealFn = (ctx: { I: any }) => Promise<void> | void;
89
+
90
+ interface HealRecipe {
91
+ priority?: number;
92
+ steps?: string[];
93
+ fn: (context: { step: any; error: Error; prevSteps?: any[] }) => HealFn | Promise<HealFn | null> | null;
94
+ }
95
+
96
+ interface RerunnerAgentConfig extends AgentConfig {
97
+ healLimit?: number;
98
+ ariaSnapshotLimit?: number;
99
+ retryFailedStep?: Record<string, any>;
100
+ screenshotOnFail?: Record<string, any>;
101
+ recipes?: Record<string, HealRecipe>;
102
+ }
103
+
88
104
  interface PlannerAgentConfig extends AgentConfig {
89
105
  styles?: string[];
90
106
  stylesDir?: string;
@@ -103,6 +119,7 @@ interface AgentsConfig {
103
119
  fisherman?: AgentConfig;
104
120
  chief?: AgentConfig;
105
121
  curler?: AgentConfig;
122
+ rerunner?: RerunnerAgentConfig;
106
123
  }
107
124
 
108
125
  interface AIConfig {
@@ -214,6 +231,8 @@ export type {
214
231
  ResearcherAgentConfig,
215
232
  NavigatorAgentConfig,
216
233
  PlannerAgentConfig,
234
+ RerunnerAgentConfig,
235
+ HealRecipe,
217
236
  Hook,
218
237
  HookConfig,
219
238
  HooksConfig,
@@ -328,6 +347,10 @@ export class ConfigParser {
328
347
  return outputPath('plans');
329
348
  }
330
349
 
350
+ public getTestsDir(): string {
351
+ return outputPath('tests');
352
+ }
353
+
331
354
  // For testing purposes only
332
355
  public static resetForTesting(): void {
333
356
  if (ConfigParser.instance) {
package/src/explorbot.ts CHANGED
@@ -14,11 +14,13 @@ import { Planner } from './ai/planner.ts';
14
14
  import { AIProvider } from './ai/provider.ts';
15
15
  import { Quartermaster } from './ai/quartermaster.ts';
16
16
  import { Researcher } from './ai/researcher.ts';
17
+ import { Rerunner } from './ai/rerunner.ts';
17
18
  import { Tester } from './ai/tester.ts';
18
19
  import { createAgentTools } from './ai/tools.ts';
19
20
  import type { ExplorbotConfig } from './config.js';
20
21
  import { ConfigParser } from './config.ts';
21
22
  import Explorer from './explorer.ts';
23
+ import type { Suite } from './suite.ts';
22
24
  import { KnowledgeTracker } from './knowledge-tracker.ts';
23
25
  import { WebPageState } from './state-manager.ts';
24
26
  import { Plan } from './test-plan.ts';
@@ -245,6 +247,21 @@ export class ExplorBot {
245
247
  }));
246
248
  }
247
249
 
250
+ agentRerunner(): Rerunner {
251
+ if (!this.agents.rerunner) {
252
+ this.agents.rerunner = this.createAgent(({ ai, explorer }) => {
253
+ const researcher = this.agentResearcher();
254
+ const navigator = this.agentNavigator();
255
+ const tools = createAgentTools({ explorer, researcher, navigator });
256
+ return new Rerunner(explorer, ai, tools);
257
+ });
258
+ const qm = this.agentQuartermaster();
259
+ if (qm) this.agents.rerunner.setQuartermaster(qm);
260
+ this.agents.rerunner.setHistorian(this.agentHistorian());
261
+ }
262
+ return this.agents.rerunner;
263
+ }
264
+
248
265
  agentBosun(): Bosun {
249
266
  return (this.agents.bosun ||= this.createAgent(({ ai, explorer }) => {
250
267
  const researcher = this.agentResearcher();
@@ -291,6 +308,10 @@ export class ExplorBot {
291
308
  return this.currentPlan;
292
309
  }
293
310
 
311
+ getSuite(): Suite | null {
312
+ return this.agentPlanner().getSuite();
313
+ }
314
+
294
315
  getPlanFeature(): string | undefined {
295
316
  return this.planFeature;
296
317
  }
package/src/explorer.ts CHANGED
@@ -309,8 +309,9 @@ class Explorer {
309
309
  return action;
310
310
  }
311
311
 
312
- async annotateElements(): Promise<{ ariaSnapshot: string; elements: WebElement[] }> {
313
- return annotatePageElements(this.playwrightHelper.page);
312
+ async annotateElements(): Promise<WebElement[]> {
313
+ const { elements } = await annotatePageElements(this.playwrightHelper.page);
314
+ return elements;
314
315
  }
315
316
 
316
317
  async visuallyAnnotateElements(opts?: { containers?: Array<{ css: string; label: string }> }): Promise<number> {
package/src/suite.ts ADDED
@@ -0,0 +1,135 @@
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.ts';
5
+ import { normalizeUrl } from './state-manager.ts';
6
+ import { parsePlanFromMarkdown } from './utils/test-plan-markdown.ts';
7
+ import { createDebug } from './utils/logger.ts';
8
+
9
+ const debugLog = createDebug('explorbot:suite');
10
+
11
+ export class Suite {
12
+ readonly url: string;
13
+ private _automatedTests: AutomatedTest[] | null = null;
14
+ private _plannedScenarios: string[] | null = null;
15
+
16
+ constructor(url: string) {
17
+ this.url = url;
18
+ }
19
+
20
+ getAutomatedTests(): AutomatedTest[] {
21
+ if (this._automatedTests !== null) return this._automatedTests;
22
+ this._automatedTests = this.loadAutomatedTests();
23
+ return this._automatedTests;
24
+ }
25
+
26
+ getPlannedScenarios(): string[] {
27
+ if (this._plannedScenarios !== null) return this._plannedScenarios;
28
+ this._plannedScenarios = this.loadPlannedScenarios();
29
+ return this._plannedScenarios;
30
+ }
31
+
32
+ getActiveScenarioTitles(): Set<string> {
33
+ return new Set(
34
+ this.getAutomatedTests()
35
+ .filter((t) => !t.pending)
36
+ .map((t) => t.title.toLowerCase())
37
+ );
38
+ }
39
+
40
+ get automatedTestCount(): number {
41
+ return this.getAutomatedTests().filter((t) => !t.pending).length;
42
+ }
43
+
44
+ getAutomatedTestNames(): string[] {
45
+ return this.getAutomatedTests()
46
+ .filter((t) => !t.pending)
47
+ .map((t) => t.title);
48
+ }
49
+
50
+ getAutomatedTestFiles(): string[] {
51
+ return [...new Set(this.getAutomatedTests().map((t) => t.file))];
52
+ }
53
+
54
+ private loadAutomatedTests(): AutomatedTest[] {
55
+ const testsDir = ConfigParser.getInstance().getTestsDir();
56
+ if (!existsSync(testsDir)) return [];
57
+
58
+ const jsFiles = readdirSync(testsDir)
59
+ .filter((f) => f.endsWith('.js'))
60
+ .map((f) => path.resolve(testsDir, f));
61
+
62
+ const results: AutomatedTest[] = [];
63
+
64
+ for (const filePath of jsFiles) {
65
+ const parsed = this.parseTestFile(filePath);
66
+ if (!parsed) continue;
67
+ if (normalizeUrl(parsed.url) !== normalizeUrl(this.url)) continue;
68
+ results.push(...parsed.tests);
69
+ }
70
+
71
+ return results;
72
+ }
73
+
74
+ private parseTestFile(filePath: string): { url: string; tests: AutomatedTest[] } | null {
75
+ try {
76
+ const scanned = Reflection.scanFile(filePath);
77
+ if (!scanned.suites?.length) return null;
78
+
79
+ const content = readFileSync(filePath, 'utf-8');
80
+
81
+ const suiteRef = Reflection.forSuite(scanned.suites[0]);
82
+ const beforeHooks = suiteRef.findHook('Before');
83
+ if (!beforeHooks?.length) return null;
84
+
85
+ const hookBody = content.slice(beforeHooks[0].range.start, beforeHooks[0].range.end);
86
+ const match = hookBody.match(/I\.amOnPage\(['"]([^'"]+)['"]\)/);
87
+ if (!match) return null;
88
+
89
+ const lines = content.split('\n');
90
+ const tests = (scanned.tests || []).map((t: any) => {
91
+ const line = lines[t.line - 1] || '';
92
+ const pending = line.includes('Scenario.skip') || line.includes('Scenario.todo');
93
+ return { title: t.title, pending, file: filePath };
94
+ });
95
+
96
+ return { url: match[1], tests };
97
+ } catch (err: any) {
98
+ debugLog('Failed to parse test file %s: %s', filePath, err.message);
99
+ return null;
100
+ }
101
+ }
102
+
103
+ private loadPlannedScenarios(): string[] {
104
+ try {
105
+ const plansDir = ConfigParser.getInstance().getPlansDir();
106
+ if (!existsSync(plansDir)) return [];
107
+
108
+ const mdFiles = readdirSync(plansDir)
109
+ .filter((f) => f.endsWith('.md'))
110
+ .map((f) => path.resolve(plansDir, f));
111
+
112
+ const scenarios: string[] = [];
113
+
114
+ for (const filePath of mdFiles) {
115
+ const plan = parsePlanFromMarkdown(filePath);
116
+ if (!plan.url) continue;
117
+ if (normalizeUrl(plan.url) !== normalizeUrl(this.url)) continue;
118
+ for (const test of plan.tests) {
119
+ scenarios.push(test.scenario);
120
+ }
121
+ }
122
+
123
+ return scenarios;
124
+ } catch (err: any) {
125
+ debugLog('Failed to load planned scenarios: %s', err.message);
126
+ return [];
127
+ }
128
+ }
129
+ }
130
+
131
+ interface AutomatedTest {
132
+ title: string;
133
+ pending: boolean;
134
+ file: string;
135
+ }
package/src/utils/html.ts CHANGED
@@ -486,18 +486,14 @@ export function htmlMinimalUISnapshot(html: string, htmlConfig?: HtmlConfig['min
486
486
  node.attrs = node.attrs.filter((attr) => {
487
487
  const { name, value } = attr;
488
488
  if (name === 'class') {
489
- // Remove classes containing digits
490
489
  attr.value = value
491
490
  .split(' ')
492
- // remove classes containing digits/
493
491
  .filter((className) => !/\d/.test(className))
494
- // remove popular trash classes
495
492
  .filter((className) => !className.match(trashHtmlClasses))
496
- // remove classes with : and __ in them
497
493
  .filter((className) => !className.match(/(:|__)/))
498
- // remove tailwind utility classes
499
494
  .filter((className) => !TAILWIND_CLASS_PATTERNS.some((pattern) => pattern.test(className)))
500
495
  .join(' ');
496
+ if (attr.value === '') return false;
501
497
  }
502
498
 
503
499
  return allowedAttrs.includes(name) || name.startsWith('data-explorbot-');