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
@@ -77,7 +77,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
77
77
  }
78
78
 
79
79
  private async _discoverExpandables(researchText: string): Promise<ExpandableElement[]> {
80
- const allElements = new Map<number, ExpandableElement>();
80
+ const allElements = new Map<string, ExpandableElement>();
81
81
  for (const section of parseResearchSections(researchText)) {
82
82
  for (const el of section.elements) {
83
83
  if (el.eidx != null) allElements.set(el.eidx, { ...el, container: section.containerCss });
@@ -91,7 +91,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
91
91
  From this UI research, identify elements that could reveal hidden UI when clicked
92
92
  (dropdown menus, popups, expandable panels, accordion sections, overflow menus, tab switches).
93
93
 
94
- Available eidx numbers: ${eidxList}
94
+ Available eidx refs: ${eidxList}
95
95
 
96
96
  ${researchText}
97
97
 
@@ -99,7 +99,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
99
99
  - Only pick elements that HIDE content until clicked (menus, dropdowns, accordions, tabs)
100
100
  - Skip regular links, data items, and navigation
101
101
  - For repeated elements (same expand button on every row), pick only the FIRST one
102
- - Respond with comma-separated eidx numbers only, e.g.: 3, 7, 15
102
+ - Respond with comma-separated eidx refs only, e.g.: e3, e7, e15
103
103
  `;
104
104
 
105
105
  const model = this.provider.getModelForAgent('researcher');
@@ -112,7 +112,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
112
112
  const screenshot = this.actionResult?.screenshot;
113
113
  if (screenshot && this.provider.hasVision()) {
114
114
  const visionPrompt = dedent`
115
- This screenshot has interactive elements labeled with eidx numbers (solid bordered boxes with numbers).
115
+ This screenshot has interactive elements labeled with eidx refs (solid bordered boxes with labels).
116
116
  Identify elements that could reveal hidden UI when clicked.
117
117
 
118
118
  Look for: overflow/ellipsis menus, chevron dropdowns, hamburger menus,
@@ -121,33 +121,30 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
121
121
  Rules:
122
122
  - For repeated icons (same icon on every list row), pick only the FIRST one
123
123
  - Skip regular text buttons, links, and navigation items
124
- - Respond with comma-separated eidx numbers only, e.g.: 3, 7, 15
124
+ - Respond with comma-separated eidx refs only, e.g.: e3, e7, e15
125
125
  `;
126
126
  visionCall = this.provider.processImage(visionPrompt, screenshot.toString('base64'));
127
127
  }
128
128
 
129
129
  const [textRes, visionRes] = await Promise.all([textCall, visionCall]);
130
130
 
131
- const eidxSet = new Set<number>();
131
+ const eidxSet = new Set<string>();
132
+ const parseRefs = (text: string | undefined) => {
133
+ if (!text) return [];
134
+ const matches = text.match(/e?\d+/g) || [];
135
+ const refs = matches.map((m) => (m.startsWith('e') ? m : `e${m}`));
136
+ return refs.filter((r) => allElements.has(r));
137
+ };
138
+
132
139
  for (const res of [textRes, visionRes]) {
133
- if (!res?.text) continue;
134
- const nums = res.text.match(/\d+/g)?.map(Number) || [];
135
- for (const n of nums) {
136
- if (allElements.has(n)) eidxSet.add(n);
140
+ for (const ref of parseRefs(res?.text)) {
141
+ eidxSet.add(ref);
137
142
  }
138
143
  }
139
144
 
140
- const textNums =
141
- textRes?.text
142
- ?.match(/\d+/g)
143
- ?.map(Number)
144
- .filter((n) => allElements.has(n)) || [];
145
- const visionNums =
146
- visionRes?.text
147
- ?.match(/\d+/g)
148
- ?.map(Number)
149
- .filter((n) => allElements.has(n)) || [];
150
- debugLog(`Text model picked eidx: [${textNums.join(', ')}], Vision model picked eidx: [${visionNums.join(', ')}]`);
145
+ const textRefs = parseRefs(textRes?.text);
146
+ const visionRefs = parseRefs(visionRes?.text);
147
+ debugLog(`Text model picked eidx: [${textRefs.join(', ')}], Vision model picked eidx: [${visionRefs.join(', ')}]`);
151
148
 
152
149
  return [...eidxSet].map((eidx) => allElements.get(eidx)!);
153
150
  }
@@ -146,7 +146,7 @@ export function WithLocators<T extends Constructor>(Base: T) {
146
146
  for (const fixedSection of fixedSections) {
147
147
  const originalSections = parseResearchSections(result.text);
148
148
  const original = originalSections.find((s) => s.name === fixedSection.name);
149
- if (!original) continue;
149
+ if (!original || original.elements.length === 0) continue;
150
150
 
151
151
  if (fixedSection.containerCss && fixedSection.containerCss !== original.containerCss) {
152
152
  debugLog(`Fixed container for "${fixedSection.name}": '${original.containerCss}' → '${fixedSection.containerCss}'`);
@@ -177,8 +177,8 @@ export function WithLocators<T extends Constructor>(Base: T) {
177
177
  const sections = parseResearchSections(result.text);
178
178
  const brokenCss = new Set(result.locators.filter((l) => l.type === 'css' && l.valid === false).map((l) => `${l.section}::${l.element}`));
179
179
 
180
- const needsXpath: number[] = [];
181
- const needsXpathEls = new Map<number, { section: (typeof sections)[0]; el: (typeof sections)[0]['elements'][0] }>();
180
+ const needsXpath: string[] = [];
181
+ const needsXpathEls = new Map<string, { section: (typeof sections)[0]; el: (typeof sections)[0]['elements'][0] }>();
182
182
 
183
183
  for (const section of sections) {
184
184
  for (const el of section.elements) {
@@ -13,7 +13,7 @@ export interface ResearchElement {
13
13
  coordinates: string | null;
14
14
  color: string | null;
15
15
  icon: string | null;
16
- eidx: number | null;
16
+ eidx: string | null;
17
17
  }
18
18
 
19
19
  export interface ResearchSection {
@@ -62,8 +62,8 @@ export function mapRowToElement(row: Record<string, string>): ResearchElement |
62
62
  const name = stripQuotes(colMap.element || '');
63
63
  if (!name) return null;
64
64
 
65
- const eidxRaw = (colMap.eidx || '').trim();
66
- const eidxNum = eidxRaw ? Number.parseInt(eidxRaw, 10) : Number.NaN;
65
+ let eidxRaw = (colMap.eidx || '').trim();
66
+ if (eidxRaw && /^\d+$/.test(eidxRaw)) eidxRaw = `e${eidxRaw}`;
67
67
 
68
68
  const aria = parseAriaLocator(colMap.aria || '-');
69
69
 
@@ -76,7 +76,7 @@ export function mapRowToElement(row: Record<string, string>): ResearchElement |
76
76
  coordinates: (colMap.coordinates || '-').trim() === '-' ? null : colMap.coordinates.trim(),
77
77
  color: (colMap.color || '-').trim() === '-' || (colMap.color || '').trim() === '' ? null : colMap.color.trim(),
78
78
  icon: (colMap.icon || '-').trim() === '-' || (colMap.icon || '').trim() === '' ? null : colMap.icon.trim(),
79
- eidx: Number.isNaN(eidxNum) ? null : eidxNum,
79
+ eidx: eidxRaw && eidxRaw !== '-' ? eidxRaw : null,
80
80
  };
81
81
  }
82
82
 
@@ -65,6 +65,7 @@ export class ResearchResult {
65
65
  }
66
66
 
67
67
  rebuildSectionInText(section: ResearchSection): void {
68
+ if (section.elements.length === 0) return;
68
69
  const newTable = rebuildSectionMarkdown(section);
69
70
  const escaped = section.name.replace(/"/g, '\\"');
70
71
  let sectionQuery = mdq(this.text).query(`section2(~"${escaped}")`);
@@ -131,8 +131,8 @@ export class Researcher extends ResearcherBase implements Agent {
131
131
  await this.ensureNavigated(state.url, screenshot && this.provider.hasVision());
132
132
  await this.hooksRunner.runBeforeHook('researcher', state.url);
133
133
 
134
- const annotatedCount = await this.explorer.annotateElements();
135
- debugLog(`Annotated ${annotatedCount} interactive elements with eidx`);
134
+ const annotatedElements = await this.explorer.annotateElements();
135
+ debugLog(`Annotated ${annotatedElements.length} interactive elements with eidx`);
136
136
  this.actionResult = await this.explorer.createAction().capturePageState({ includeScreenshot: screenshot && this.provider.hasVision() });
137
137
 
138
138
  if (isErrorPage(this.actionResult!)) {
@@ -154,7 +154,7 @@ export class Researcher extends ResearcherBase implements Agent {
154
154
 
155
155
  const combinedHtml = await this.actionResult!.combinedHtml();
156
156
 
157
- if (!deep) {
157
+ if (!deep && !force) {
158
158
  const similar = await findSimilarResearch(combinedHtml);
159
159
  if (similar) {
160
160
  tag('info').log('Similar research found, reusing cached result');
package/src/ai/rules.ts CHANGED
@@ -266,7 +266,7 @@ export const actionRule = dedent`
266
266
  I.fillField('Username', 'John', '.login-form'); // fills Username inside .login-form
267
267
  I.fillField('Username', 'John'); // fills the field located by name or placeholder or label "Username" with the text "John"
268
268
  I.fillField('//user/input', 'John'); // fills the field located by XPath "//user/input" with the text "John"
269
- </example>
269
+ </example>
270
270
 
271
271
  ### I.type
272
272
 
@@ -303,7 +303,7 @@ export const actionRule = dedent`
303
303
  </example>
304
304
 
305
305
  IMPORTANT: Requires an active/focused element for most keys.
306
- Commonly used after I.type() to submit forms or navigate dropdowns.
306
+ Commonly used after I.type() or I.fillField() to submit forms or navigate dropdowns.
307
307
 
308
308
  ### I.switchTo
309
309
 
package/src/ai/tools.ts CHANGED
@@ -310,7 +310,7 @@ export function createCodeceptJSTools(explorer: Explorer, task: Task) {
310
310
  I.selectOption({"role":"combobox","text":"Category"}, 'Technology')
311
311
 
312
312
  Do not submit form - use verify() first to check fields were filled correctly, then click() to submit.
313
- Do not use: wait functions, amOnPage, reloadPage, saveScreenshot
313
+ Do not use: wait functions, amOnPage, reloadPage, saveScreenshot
314
314
  `,
315
315
  inputSchema: z.object({
316
316
  codeBlock: z.string().describe('Valid CodeceptJS code starting with I. Can contain multiple commands separated by newlines.'),
@@ -340,8 +340,13 @@ export function createCodeceptJSTools(explorer: Explorer, task: Task) {
340
340
  const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
341
341
  const formLocator = codeLines[0] || 'form';
342
342
  const action = explorer.createAction();
343
+ const wasInIframe = await explorer.isInsideIframe();
343
344
  await action.attempt(codeBlock, explanation);
344
345
 
346
+ if (action.lastError && !wasInIframe && (await explorer.isInsideIframe())) {
347
+ await explorer.switchToMainFrame();
348
+ }
349
+
345
350
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, formLocator);
346
351
 
347
352
  if (action.lastError) {
@@ -380,7 +385,7 @@ export function createCodeceptJSTools(explorer: Explorer, task: Task) {
380
385
  message: `Form completed successfully with ${lines.length} commands.`,
381
386
  commandsExecuted: lines.length,
382
387
  code: codeBlock,
383
- suggestion: 'Verify the form was filled in correctly using see() tool. Submit if needed by using click() tool.',
388
+ suggestion: 'Verify the form was filled in correctly using see() tool. If needed to submit: try click() tool or form() with I.pressKey("Enter").',
384
389
  });
385
390
  } catch (error) {
386
391
  activeNote.commit(TestResult.FAILED);
@@ -6,8 +6,7 @@ import { tag } from '../utils/logger.js';
6
6
  import { BaseCommand } from './base-command.js';
7
7
 
8
8
  export class AddRuleCommand extends BaseCommand {
9
- name = 'rules:add';
10
- aliases = ['add-rule'];
9
+ name = 'add-rule';
11
10
  description = 'Create a rule file for an agent';
12
11
  suggestions = ['/add-rule researcher check-tooltips'];
13
12
 
@@ -1,3 +1,4 @@
1
+ import { Command } from 'commander';
1
2
  import type { ExplorBot } from '../explorbot.js';
2
3
 
3
4
  export interface CommandOption {
@@ -24,4 +25,16 @@ export abstract class BaseCommand {
24
25
  matches(commandName: string): boolean {
25
26
  return this.name === commandName || this.aliases.includes(commandName);
26
27
  }
28
+
29
+ protected parseArgs(args: string): { opts: Record<string, string | boolean>; args: string[] } {
30
+ const cmd = new Command();
31
+ cmd.exitOverride();
32
+ for (const opt of this.options) {
33
+ cmd.option(opt.flags, opt.description);
34
+ }
35
+ cmd.argument('[args...]');
36
+ const argv = (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).map((s) => s.replace(/^["']|["']$/g, ''));
37
+ cmd.parse(argv, { from: 'user' });
38
+ return { opts: cmd.opts(), args: cmd.args };
39
+ }
27
40
  }
@@ -10,6 +10,12 @@ export class ContextCommand extends BaseCommand {
10
10
  name = 'context';
11
11
  description = 'Show page context summary (URL, headings, experience, knowledge, ARIA, HTML, research)';
12
12
  suggestions = ['context:aria', 'context:html', 'context:knowledge', 'context:experience', 'context:data'];
13
+ options = [
14
+ { flags: '--visual', description: 'Include annotated screenshot' },
15
+ { flags: '--screenshot', description: 'Include annotated screenshot' },
16
+ { flags: '--full', description: 'Show full context with HTML' },
17
+ { flags: '--attached', description: 'Show attached context mode' },
18
+ ];
13
19
 
14
20
  async execute(args: string): Promise<void> {
15
21
  const explorer = this.explorBot.getExplorer();
@@ -19,7 +25,8 @@ export class ContextCommand extends BaseCommand {
19
25
  throw new Error('No active page to show context for');
20
26
  }
21
27
 
22
- const isVisual = args.includes('--visual') || args.includes('--screenshot');
28
+ const { opts } = this.parseArgs(args);
29
+ const isVisual = !!(opts.visual || opts.screenshot);
23
30
 
24
31
  await explorer.annotateElements();
25
32
 
@@ -34,9 +41,9 @@ export class ContextCommand extends BaseCommand {
34
41
  const knowledgeTracker = this.explorBot.getKnowledgeTracker();
35
42
 
36
43
  let mode: ContextMode = 'compact';
37
- if (args.includes('--full')) {
44
+ if (opts.full) {
38
45
  mode = 'full';
39
- } else if (args.includes('--attached')) {
46
+ } else if (opts.attached) {
40
47
  mode = 'attached';
41
48
  }
42
49
 
@@ -3,7 +3,6 @@ import { BaseCommand } from './base-command.js';
3
3
  export class DrillCommand extends BaseCommand {
4
4
  name = 'drill';
5
5
  description = 'Drill all components on current page to learn interactions';
6
- aliases = ['bosun'];
7
6
  suggestions = ['/research - to see UI map first', '/navigate <page> - to go to another page'];
8
7
 
9
8
  async execute(args: string): Promise<void> {
@@ -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> {
@@ -78,7 +80,7 @@ export class ExploreCommand extends BaseCommand {
78
80
  }
79
81
 
80
82
  private printResults(savedPath?: string | null): void {
81
- const allTests = this.completedPlans.flatMap((plan) => plan.tests.filter((t) => t.status !== 'pending').map((test) => ({ test, planTitle: plan.title })));
83
+ const allTests = this.completedPlans.flatMap((plan) => plan.tests.filter((t) => t.startTime != null).map((test) => ({ test, planTitle: plan.title })));
82
84
 
83
85
  if (allTests.length === 0) return;
84
86
 
@@ -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,
@@ -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.ts';
6
7
 
7
8
  const DEFAULT_CONFIG_TEMPLATE = `import { createOpenRouter } from '@openrouter/ai-sdk-provider';
@@ -22,11 +23,11 @@ const config = {
22
23
 
23
24
  ai: {
24
25
  // fast model with tool calling capabilities
25
- model: openrouter('<your base model here>'),
26
+ model: openrouter('openai/gpt-oss-20b:nitro'),
26
27
  // vision model for screenshot analysis
27
- visionModel: openrouter('<your vision model here>'),
28
+ visionModel: openrouter('meta-llama/llama-4-scout-17b-16e-instruct'),
28
29
  // agentic model for decision making
29
- agenticModel: openrouter('<your agentic model here>'),
30
+ agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
30
31
  },
31
32
  };
32
33
 
@@ -103,9 +104,11 @@ export function runInitCommand(options: InitCommandOptions): void {
103
104
  log('2. Set AI models config file');
104
105
  log('3. Set web application URL in the config file');
105
106
  log('4. Add initial knowledge (how to authorize to the application, etc.)');
106
- tag('substep').log(`${getCliName()} learn * 'to aurhorize use these credentials: admin@example.com / secret123'`);
107
+ tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to aurhorize use these credentials: admin@example.com / secret123'`));
108
+ tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
109
+
107
110
  log('5. Launch application on a relative URL');
108
- tag('substep').log(`${getCliName()} start /dashboard`);
111
+ tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
109
112
 
110
113
  if (!existsSync('./output')) {
111
114
  mkdirSync('./output', { recursive: true });
@@ -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
 
@@ -9,20 +12,14 @@ export class PlanCommand extends BaseCommand {
9
12
  { flags: '--fresh', description: 'Regenerate plan from scratch' },
10
13
  { flags: '--clear', description: 'Clear plan before regenerating' },
11
14
  { flags: '--style <name>', description: 'Planning style (normal, curious, psycho, performer)' },
15
+ { flags: '--focus <feature>', description: 'Focus area for test planning' },
12
16
  ];
13
17
 
14
18
  async execute(args: string): Promise<void> {
15
- const clear = args.includes('--clear');
16
- const fresh = args.includes('--fresh') || clear;
17
- const styleMatch = args.match(/--style\s+(\S+)/);
18
- const style = styleMatch?.[1];
19
- const focus = args
20
- .replace('--clear', '')
21
- .replace('--fresh', '')
22
- .replace(/--style\s+\S+/, '')
23
- .trim();
24
-
25
- 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) {
26
23
  this.explorBot.clearPlan();
27
24
  tag('success').log('Plan cleared');
28
25
  }
@@ -31,11 +28,48 @@ export class PlanCommand extends BaseCommand {
31
28
  tag('info').log(`Planning focus: ${focus}`);
32
29
  }
33
30
 
34
- await this.explorBot.plan(focus || undefined, { fresh, style });
31
+ await this.explorBot.plan(focus, { fresh: !!(opts.fresh || opts.clear), style: opts.style as string });
35
32
 
36
33
  const plan = this.explorBot.getCurrentPlan();
37
34
  if (!plan?.tests.length) {
38
35
  throw new Error('No test scenarios in the current plan.');
39
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');
40
74
  }
41
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