explorbot 0.1.0 → 0.1.2

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 (77) 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/session-dedup.js +3 -0
  9. package/dist/src/ai/planner/styles.js +3 -0
  10. package/dist/src/ai/planner.js +29 -10
  11. package/dist/src/ai/rerunner.js +472 -0
  12. package/dist/src/ai/researcher/cache.js +4 -3
  13. package/dist/src/ai/researcher/fingerprint-worker.js +7 -6
  14. package/dist/src/ai/researcher.js +3 -4
  15. package/dist/src/ai/rules.js +2 -2
  16. package/dist/src/ai/tools.js +2 -2
  17. package/dist/src/commands/add-rule-command.js +1 -2
  18. package/dist/src/commands/base-command.js +12 -0
  19. package/dist/src/commands/context-command.js +12 -5
  20. package/dist/src/commands/drill-command.js +0 -1
  21. package/dist/src/commands/explore-command.js +20 -5
  22. package/dist/src/commands/freesail-command.js +8 -22
  23. package/dist/src/commands/index.js +4 -0
  24. package/dist/src/commands/init-command.js +3 -3
  25. package/dist/src/commands/path-command.js +2 -1
  26. package/dist/src/commands/plan-command.js +37 -15
  27. package/dist/src/commands/rerun-command.js +42 -0
  28. package/dist/src/commands/research-command.js +10 -4
  29. package/dist/src/commands/runs-command.js +22 -0
  30. package/dist/src/commands/start-command.js +0 -1
  31. package/dist/src/commands/test-command.js +3 -3
  32. package/dist/src/components/App.js +8 -0
  33. package/dist/src/config.js +3 -0
  34. package/dist/src/explorbot.js +19 -0
  35. package/dist/src/explorer.js +2 -1
  36. package/dist/src/suite.js +115 -0
  37. package/dist/src/utils/html.js +2 -5
  38. package/dist/src/utils/rules-loader.js +33 -17
  39. package/dist/src/utils/test-files.js +103 -0
  40. package/package.json +3 -1
  41. package/rules/rerunner/healing-approach.md +19 -0
  42. package/src/action.ts +7 -9
  43. package/src/ai/historian.ts +37 -3
  44. package/src/ai/navigator.ts +35 -28
  45. package/src/ai/pilot.ts +33 -9
  46. package/src/ai/planner/session-dedup.ts +4 -0
  47. package/src/ai/planner/styles.ts +4 -0
  48. package/src/ai/planner.ts +28 -9
  49. package/src/ai/rerunner.ts +532 -0
  50. package/src/ai/researcher/cache.ts +4 -3
  51. package/src/ai/researcher/fingerprint-worker.ts +7 -13
  52. package/src/ai/researcher.ts +3 -4
  53. package/src/ai/rules.ts +2 -2
  54. package/src/ai/tools.ts +2 -2
  55. package/src/commands/add-rule-command.ts +1 -2
  56. package/src/commands/base-command.ts +13 -0
  57. package/src/commands/context-command.ts +12 -5
  58. package/src/commands/drill-command.ts +0 -1
  59. package/src/commands/explore-command.ts +21 -5
  60. package/src/commands/freesail-command.ts +6 -23
  61. package/src/commands/index.ts +4 -0
  62. package/src/commands/init-command.ts +3 -3
  63. package/src/commands/path-command.ts +2 -1
  64. package/src/commands/plan-command.ts +45 -16
  65. package/src/commands/rerun-command.ts +46 -0
  66. package/src/commands/research-command.ts +10 -4
  67. package/src/commands/runs-command.ts +27 -0
  68. package/src/commands/start-command.ts +0 -1
  69. package/src/commands/test-command.ts +3 -3
  70. package/src/components/App.tsx +8 -0
  71. package/src/config.ts +23 -0
  72. package/src/explorbot.ts +21 -0
  73. package/src/explorer.ts +3 -2
  74. package/src/suite.ts +135 -0
  75. package/src/utils/html.ts +1 -5
  76. package/src/utils/rules-loader.ts +35 -17
  77. package/src/utils/test-files.ts +122 -0
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-');
@@ -65,29 +65,47 @@ export class RulesLoader {
65
65
  return { name, approach: styles[name] };
66
66
  }
67
67
 
68
- static extractStyles(agentName: string, targetDir: string): string[] {
69
- const sourceDir = join(BUILT_IN_DIR, agentName, 'styles');
70
- if (!existsSync(sourceDir)) throw new Error(`No built-in styles found for agent: ${agentName}`);
68
+ static extractRules(agentName: string, targetDir: string): string[] {
69
+ const sourceDir = join(BUILT_IN_DIR, agentName);
70
+ if (!existsSync(sourceDir)) throw new Error(`No built-in rules found for agent: ${agentName}`);
71
71
 
72
+ const extracted: string[] = [];
73
+ copyMarkdownTree(sourceDir, targetDir, '', extracted);
74
+ return extracted;
75
+ }
76
+ }
77
+
78
+ function copyMarkdownTree(sourceDir: string, targetDir: string, relative: string, extracted: string[]): void {
79
+ const entries = readdirSync(sourceDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
80
+
81
+ let dirCreated = false;
82
+ const ensureTargetDir = () => {
83
+ if (dirCreated) return;
72
84
  mkdirSync(targetDir, { recursive: true });
85
+ dirCreated = true;
86
+ };
73
87
 
74
- const files = readdirSync(sourceDir)
75
- .filter((f) => f.endsWith('.md'))
76
- .sort();
77
- const extracted: string[] = [];
88
+ for (const entry of entries) {
89
+ const sourcePath = join(sourceDir, entry.name);
90
+ const targetPath = join(targetDir, entry.name);
91
+ const relPath = relative ? `${relative}/${entry.name}` : entry.name;
78
92
 
79
- for (const file of files) {
80
- const target = join(targetDir, file);
81
- if (existsSync(target)) {
82
- tag('info').log(`Skipping ${file} (already exists)`);
83
- continue;
84
- }
85
- writeFileSync(target, readFileSync(join(sourceDir, file), 'utf8'));
86
- extracted.push(file);
87
- tag('success').log(`Extracted ${file}`);
93
+ if (entry.isDirectory()) {
94
+ copyMarkdownTree(sourcePath, targetPath, relPath, extracted);
95
+ continue;
88
96
  }
89
97
 
90
- return extracted;
98
+ if (!entry.name.endsWith('.md')) continue;
99
+
100
+ if (existsSync(targetPath)) {
101
+ tag('info').log(`Skipping ${relPath} (already exists)`);
102
+ continue;
103
+ }
104
+
105
+ ensureTargetDir();
106
+ writeFileSync(targetPath, readFileSync(sourcePath, 'utf8'));
107
+ extracted.push(relPath);
108
+ tag('success').log(`Extracted ${relPath}`);
91
109
  }
92
110
  }
93
111
 
@@ -0,0 +1,122 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { highlight } from 'cli-highlight';
5
+ import * as codeceptjs from 'codeceptjs';
6
+ import store from 'codeceptjs/lib/store';
7
+ import stepsListener from 'codeceptjs/lib/listener/steps';
8
+ import storeListener from 'codeceptjs/lib/listener/store';
9
+ import figureSet from 'figures';
10
+ import { ConfigParser } from '../config.ts';
11
+
12
+ export function loadTestSuites(testsDir: string): any[] {
13
+ if (!existsSync(testsDir)) return [];
14
+
15
+ const jsFiles = readdirSync(testsDir)
16
+ .filter((f) => f.endsWith('.js'))
17
+ .map((f) => path.resolve(testsDir, f));
18
+
19
+ if (jsFiles.length === 0) return [];
20
+
21
+ codeceptjs.container.createMocha();
22
+ const mocha = codeceptjs.container.mocha();
23
+ mocha.files = jsFiles;
24
+ mocha.loadFiles();
25
+
26
+ return mocha.suite.suites || [];
27
+ }
28
+
29
+ export function printTestList(suites: any[]): void {
30
+ if (suites.length === 0) {
31
+ console.log(chalk.yellow('No test files found. Run /explore first.'));
32
+ return;
33
+ }
34
+
35
+ let totalActive = 0;
36
+ let totalSkipped = 0;
37
+ let index = 0;
38
+
39
+ for (const suite of suites) {
40
+ const file = path.relative(process.cwd(), suite.file || '');
41
+ const active = suite.tests.filter((t: any) => !t.pending).length;
42
+ const skipped = suite.tests.filter((t: any) => t.pending).length;
43
+ totalActive += active;
44
+ totalSkipped += skipped;
45
+
46
+ console.log(`\n${chalk.bold.cyan(suite.title)}`);
47
+ console.log(chalk.gray(file));
48
+
49
+ for (const test of suite.tests) {
50
+ const idx = chalk.dim(`${++index}.`);
51
+ if (test.pending) {
52
+ console.log(chalk.gray(` ${idx} ${figureSet.line} ${test.title} (skipped)`));
53
+ } else {
54
+ console.log(` ${idx} ${chalk.green(figureSet.pointer)} ${test.title}`);
55
+ }
56
+ }
57
+ }
58
+
59
+ console.log(`\n${chalk.bold(`${totalActive + totalSkipped}`)} scenarios (${chalk.green(`${totalActive} active`)}, ${chalk.gray(`${totalSkipped} skipped`)})`);
60
+ }
61
+
62
+ export async function dryRunTestFile(filePath: string): Promise<void> {
63
+ const absPath = path.resolve(filePath);
64
+ if (!existsSync(absPath)) {
65
+ console.log(chalk.yellow(`File not found: ${absPath}`));
66
+ return;
67
+ }
68
+
69
+ const config = ConfigParser.getInstance().getConfig();
70
+ const configPath = ConfigParser.getInstance().getConfigPath();
71
+ const projectRoot = configPath ? path.dirname(configPath) : process.cwd();
72
+
73
+ const codeceptConfig = {
74
+ helpers: {
75
+ Playwright: { browser: config.playwright.browser, url: config.playwright.url },
76
+ },
77
+ };
78
+
79
+ (global as any).output_dir = path.join(projectRoot, 'output', 'states');
80
+ (global as any).codecept_dir = projectRoot;
81
+
82
+ codeceptjs.container.create(codeceptConfig, {});
83
+ await codeceptjs.recorder.start();
84
+ await codeceptjs.container.started(null);
85
+
86
+ store.dryRun = true;
87
+ (global as any).container = codeceptjs.container;
88
+ storeListener();
89
+ stepsListener();
90
+
91
+ codeceptjs.container.createMocha();
92
+ const mocha = codeceptjs.container.mocha();
93
+ mocha.reporter(class {});
94
+ mocha.files = [absPath];
95
+ mocha.loadFiles();
96
+
97
+ let currentSuite = '';
98
+
99
+ codeceptjs.event.dispatcher.on('suite.before', (suite: any) => {
100
+ if (suite.title && suite.title !== currentSuite) {
101
+ currentSuite = suite.title;
102
+ console.log(`\n${chalk.bold.cyan(suite.title)}`);
103
+ console.log(chalk.gray(path.relative(process.cwd(), suite.file || absPath)));
104
+ }
105
+ });
106
+
107
+ codeceptjs.event.dispatcher.on('test.before', (t: any) => {
108
+ console.log(`\n ${chalk.green(figureSet.pointer)} ${chalk.bold(t.title)}`);
109
+ });
110
+
111
+ codeceptjs.event.dispatcher.on('step.start', (step: any) => {
112
+ const code = highlight(step.toCode(), { language: 'javascript' });
113
+ console.log(chalk.dim(` ${code}`));
114
+ });
115
+
116
+ await new Promise<void>((resolve) => {
117
+ const runner = mocha.run(() => resolve());
118
+ runner.on('pending', (t: any) => {
119
+ console.log(chalk.gray(` ${figureSet.line} ${t.title} (skipped)`));
120
+ });
121
+ });
122
+ }