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.
- package/bin/explorbot-cli.ts +93 -36
- package/dist/bin/explorbot-cli.js +71 -16
- package/dist/rules/rerunner/healing-approach.md +19 -0
- package/dist/src/action.js +8 -10
- package/dist/src/ai/historian.js +34 -3
- package/dist/src/ai/navigator.js +35 -28
- package/dist/src/ai/pilot.js +33 -9
- package/dist/src/ai/planner/session-dedup.js +3 -0
- package/dist/src/ai/planner/styles.js +3 -0
- package/dist/src/ai/planner.js +29 -10
- package/dist/src/ai/rerunner.js +472 -0
- package/dist/src/ai/researcher/cache.js +4 -3
- package/dist/src/ai/researcher/fingerprint-worker.js +7 -6
- package/dist/src/ai/researcher.js +3 -4
- package/dist/src/ai/rules.js +2 -2
- package/dist/src/ai/tools.js +2 -2
- package/dist/src/commands/add-rule-command.js +1 -2
- package/dist/src/commands/base-command.js +12 -0
- package/dist/src/commands/context-command.js +12 -5
- package/dist/src/commands/drill-command.js +0 -1
- package/dist/src/commands/explore-command.js +20 -5
- package/dist/src/commands/freesail-command.js +8 -22
- package/dist/src/commands/index.js +4 -0
- package/dist/src/commands/init-command.js +3 -3
- package/dist/src/commands/path-command.js +2 -1
- package/dist/src/commands/plan-command.js +37 -15
- package/dist/src/commands/rerun-command.js +42 -0
- package/dist/src/commands/research-command.js +10 -4
- package/dist/src/commands/runs-command.js +22 -0
- package/dist/src/commands/start-command.js +0 -1
- package/dist/src/commands/test-command.js +3 -3
- package/dist/src/components/App.js +8 -0
- package/dist/src/config.js +3 -0
- package/dist/src/explorbot.js +19 -0
- package/dist/src/explorer.js +2 -1
- package/dist/src/suite.js +115 -0
- package/dist/src/utils/html.js +2 -5
- package/dist/src/utils/rules-loader.js +33 -17
- package/dist/src/utils/test-files.js +103 -0
- package/package.json +3 -1
- package/rules/rerunner/healing-approach.md +19 -0
- package/src/action.ts +7 -9
- package/src/ai/historian.ts +37 -3
- package/src/ai/navigator.ts +35 -28
- package/src/ai/pilot.ts +33 -9
- package/src/ai/planner/session-dedup.ts +4 -0
- package/src/ai/planner/styles.ts +4 -0
- package/src/ai/planner.ts +28 -9
- package/src/ai/rerunner.ts +532 -0
- package/src/ai/researcher/cache.ts +4 -3
- package/src/ai/researcher/fingerprint-worker.ts +7 -13
- package/src/ai/researcher.ts +3 -4
- package/src/ai/rules.ts +2 -2
- package/src/ai/tools.ts +2 -2
- package/src/commands/add-rule-command.ts +1 -2
- package/src/commands/base-command.ts +13 -0
- package/src/commands/context-command.ts +12 -5
- package/src/commands/drill-command.ts +0 -1
- package/src/commands/explore-command.ts +21 -5
- package/src/commands/freesail-command.ts +6 -23
- package/src/commands/index.ts +4 -0
- package/src/commands/init-command.ts +3 -3
- package/src/commands/path-command.ts +2 -1
- package/src/commands/plan-command.ts +45 -16
- package/src/commands/rerun-command.ts +46 -0
- package/src/commands/research-command.ts +10 -4
- package/src/commands/runs-command.ts +27 -0
- package/src/commands/start-command.ts +0 -1
- package/src/commands/test-command.ts +3 -3
- package/src/components/App.tsx +8 -0
- package/src/config.ts +23 -0
- package/src/explorbot.ts +21 -0
- package/src/explorer.ts +3 -2
- package/src/suite.ts +135 -0
- package/src/utils/html.ts +1 -5
- package/src/utils/rules-loader.ts +35 -17
- 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
|
|
69
|
-
const sourceDir = join(BUILT_IN_DIR, agentName
|
|
70
|
-
if (!existsSync(sourceDir)) throw new Error(`No built-in
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
+
}
|