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
package/src/ai/pilot.ts CHANGED
@@ -256,9 +256,18 @@ export class Pilot implements Agent {
256
256
 
257
257
  Plan the test execution for this scenario.
258
258
 
259
- FIRST: Call precondition() to create fresh data that this test will act on.
260
- Ask: "What will this test edit/delete/use?" — create THAT item via precondition.
261
- Do not describe what's already on the page — create new disposable items for the test.
259
+ FIRST: Decide if precondition() is needed.
260
+
261
+ Call precondition() WHEN:
262
+ - The scenario edits/deletes/modifies an item, and you want a DISPOSABLE item to act on safely
263
+ - The scenario needs specific data clearly NOT on the current page (e.g., items with specific statuses for filtering)
264
+
265
+ SKIP precondition() WHEN:
266
+ - The scenario is "Create X" — the test itself creates the item
267
+ - The current page already shows the item the test will act on (check <state> and <page_summary>)
268
+ - The scenario tests navigation, UI behavior, or viewing — no data mutation needed
269
+
270
+ If needed, call precondition() now. If not, proceed directly to planning.
262
271
 
263
272
  THEN: Based on the page elements and current state, outline:
264
273
  1. Which elements to interact with and in what order
@@ -701,6 +710,8 @@ export class Pilot implements Agent {
701
710
  - Click succeeded but ariaDiff shows elements unrelated to tester's intention (e.g., clicked "Edit" but dropdown appeared) → wrong button or unexpected behavior. Instruct Tester to Escape and try a different approach.
702
711
  - form(I.type()) succeeded → I.type() sends keys to whatever is focused, no guarantee it's the right field. Instruct Tester to verify with see() that text appeared in the correct field. If targetedHtml shows a button/link, text went to wrong element — click the correct field first and retry.
703
712
  - ariaDiff shows 5+ elements removed/added after clicking content → page entered a different mode (editor, panel, modal). Instruct Tester to call context() to see current state before guessing selectors.
713
+ - Dropdown/select opened but contains NO options, or a list/table is empty when items were expected → data doesn't exist yet. Call precondition() to create the missing items (labels, categories, etc.), then instruct Tester to retry.
714
+ - Tester tries to select/filter/assign something but the option list is empty or expected value is not present → missing auxiliary data. Call precondition() to create it.
704
715
 
705
716
  Detecting logically wrong successes — review "executed", "element", and "skipped" fields:
706
717
  - Click SUCCESS but "executed" command differs from "explanation" intent → wrong element was clicked. The intended element wasn't found and a different one was clicked instead.
@@ -750,23 +761,36 @@ export class Pilot implements Agent {
750
761
  YOUR tools (Pilot-only):
751
762
  - precondition(description) — create FRESH test data via API that the test will act on. Do NOT request users.
752
763
 
753
- PRECONDITIONS — what to create:
764
+ PRECONDITIONS — when and what to create:
754
765
  Preconditions create NEW disposable items that the test will modify, delete, or interact with.
755
- Do NOT describe what already exists on the page — describe what NEW data the test needs to act on.
756
766
 
757
767
  Ask yourself: "What object will this test change/delete/use? Create THAT."
758
768
 
759
- Examples:
769
+ When to call precondition():
770
+ - Scenario edits/deletes/modifies an item → create a disposable target
771
+ - Scenario needs auxiliary data (labels, categories, statuses to filter by)
772
+ - Tester failed because required data is missing (empty dropdown, no items to select)
773
+
774
+ When to SKIP precondition():
775
+ - Scenario is "Create X" — the test itself creates the item, no precondition needed
776
+ - Current page already shows the exact data needed (check <state> h1/title and <page_summary>)
777
+ - Scenario tests navigation, search UI, or viewing — no data mutation involved
778
+
779
+ Examples — when to create:
760
780
  - "Edit test description" → precondition("1 test") — the test will edit this item
761
781
  - "Delete a comment" → precondition("1 comment") — the test will delete this item
762
782
  - "Assign a label to item" → precondition("1 item and 1 label named Bug") — test assigns the label
763
783
  - "Filter by status" → precondition("3 items: 2 with status Open, 1 with status Closed")
764
- - "Move item between lists" → precondition("1 item in list A")
765
784
 
766
- WRONG: precondition("1 test suite named Updated Suite with existing tests") this describes the page, not what to create
785
+ Exampleswhen to skip:
786
+ - "Create a new blog post" → SKIP, the test creates it
787
+ - "Edit blog post" while on a blog post page → SKIP, data already exists
788
+ - "View dashboard" → SKIP, no data mutation
789
+
790
+ WRONG: precondition("1 test suite named Updated Suite with existing tests") — describes the page, not what to create
767
791
  RIGHT: precondition("1 test") — create a fresh test that the scenario will edit
768
792
 
769
- Call precondition() for EVERY item the scenario will act on. Keep descriptions short and specific.
793
+ Keep descriptions short and specific.
770
794
 
771
795
  Response format:
772
796
  PROGRESS: <1 sentence assessment>
@@ -1,5 +1,6 @@
1
1
  import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
+ import { ConfigParser } from '../../config.ts';
3
4
  import { normalizeUrl } from '../../state-manager.ts';
4
5
  import type { StateManager } from '../../state-manager.ts';
5
6
  import type { Plan } from '../../test-plan.ts';
@@ -9,9 +10,9 @@ import type { Constructor } from '../researcher/mixin.ts';
9
10
 
10
11
  const planRegistry: Map<string, PlanRecord> = new Map();
11
12
 
12
- export function registerPlan(url: string, plan: Plan, feature?: string): void {
13
+ export function registerPlan(url: string, plan: Plan, feature?: string, stateHash?: string): void {
13
14
  const key = buildKey(url, feature);
14
- planRegistry.set(key, { plan, feature, url });
15
+ planRegistry.set(key, { plan, feature, url, stateHash });
15
16
  }
16
17
 
17
18
  export function getRegisteredPlan(url: string, feature?: string): PlanRecord | undefined {
@@ -37,7 +38,30 @@ function buildKey(url: string, feature?: string): string {
37
38
  return normalized;
38
39
  }
39
40
 
40
- function isTemplateMatch(urlA: string, urlB: string): boolean {
41
+ export function isDynamicSegment(segment: string): boolean {
42
+ try {
43
+ const configRegex = ConfigParser.getInstance().getConfig().dynamicPageRegex;
44
+ if (configRegex) return new RegExp(configRegex, 'i').test(segment);
45
+ } catch {
46
+ /* config not loaded yet */
47
+ }
48
+
49
+ // numeric: /users/123
50
+ if (/^\d+$/.test(segment)) return true;
51
+ // UUID: /items/550e8400-e29b-41d4-a716-446655440000
52
+ if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(segment)) return true;
53
+ // ULID: /items/01ARZ3NDEKTSV4RRFFQ69G5FAV
54
+ if (/^[0-9A-HJKMNP-TV-Z]{26}$/.test(segment)) return true;
55
+ // hex ID (4+ chars): /suite/70dae98a
56
+ if (/^[a-f0-9]{4,}$/i.test(segment)) return true;
57
+ // hex-prefixed slug (8+ hex before dash): /suite/95ef0c94-mobile
58
+ if (/^[a-f0-9]{8,}-/i.test(segment)) return true;
59
+ // short mixed alphanumeric (digits + letters, ≤8 chars, no dash): /item/x7f2
60
+ if (segment.length <= 8 && !segment.includes('-') && /\d/.test(segment) && /[a-z]/i.test(segment)) return true;
61
+ return false;
62
+ }
63
+
64
+ export function isTemplateMatch(urlA: string, urlB: string): boolean {
41
65
  const partsA = normalizeUrl(urlA).split('/');
42
66
  const partsB = normalizeUrl(urlB).split('/');
43
67
  if (partsA.length !== partsB.length) return false;
@@ -47,12 +71,18 @@ function isTemplateMatch(urlA: string, urlB: string): boolean {
47
71
  if (partsA[i] === partsB[i]) continue;
48
72
  diffCount++;
49
73
  if (diffCount > 1) return false;
50
- const isNumericOrShortId = /^\d+$|^[a-f0-9]{4,}$/i;
51
- if (!isNumericOrShortId.test(partsA[i]) && !isNumericOrShortId.test(partsB[i])) return false;
74
+ if (!isDynamicSegment(partsA[i]) && !isDynamicSegment(partsB[i])) return false;
52
75
  }
53
76
  return diffCount === 1;
54
77
  }
55
78
 
79
+ export function getPlannedByStateHash(hash: string): PlanRecord | null {
80
+ for (const record of planRegistry.values()) {
81
+ if (record.stateHash === hash) return record;
82
+ }
83
+ return null;
84
+ }
85
+
56
86
  const SubPagePickSchema = z.object({
57
87
  url: z.string().nullable(),
58
88
  reason: z.string(),
@@ -71,7 +101,7 @@ export function WithSubPages<T extends Constructor>(Base: T) {
71
101
  for (const page of visited) {
72
102
  const pagePath = normalizeUrl(page.url);
73
103
  if (!pagePath.startsWith(currentPath) || pagePath === currentPath) continue;
74
- if (isPagePlanned(page.url)) continue;
104
+ if (this.findSimilarPlan(page.url)) continue;
75
105
  if (candidates.some((c) => normalizeUrl(c.url) === pagePath)) continue;
76
106
 
77
107
  candidates.push({
@@ -136,6 +166,6 @@ export function WithSubPages<T extends Constructor>(Base: T) {
136
166
  };
137
167
  }
138
168
 
139
- type PlanRecord = { plan: Plan; feature?: string; url: string };
169
+ type PlanRecord = { plan: Plan; feature?: string; url: string; stateHash?: string };
140
170
 
141
171
  type SubPageCandidate = { url: string; title?: string; h1?: string; visitCount: number };
package/src/ai/planner.ts CHANGED
@@ -18,10 +18,12 @@ import { Conversation } from './conversation.ts';
18
18
  import type { Fisherman } from './fisherman.ts';
19
19
  import { getActiveStyle, getStyles } from './planner/styles.ts';
20
20
  import { WithSessionDedup } from './planner/session-dedup.ts';
21
- import { WithSubPages, getRegisteredPlan, registerPlan } from './planner/subpages.ts';
21
+ import { WithSubPages, getPlannedByStateHash, getRegisteredPlan, registerPlan } from './planner/subpages.ts';
22
+ import { findSimilarStateHash } from './researcher/cache.ts';
22
23
  import type { Provider } from './provider.js';
23
24
  import { hasFocusedSection } from './researcher/focus.ts';
24
25
  import { POSSIBLE_SECTIONS, Researcher } from './researcher.ts';
26
+ import { Suite } from '../suite.ts';
25
27
  import { fileUploadRule, protectionRule } from './rules.ts';
26
28
 
27
29
  const debugLog = createDebug('explorbot:planner');
@@ -57,6 +59,7 @@ export class Planner extends PlannerBase implements Agent {
57
59
  currentPlan: Plan | null = null;
58
60
  freshStart = false;
59
61
  private lastStyleName = '';
62
+ private lastSuite: Suite | null = null;
60
63
  researcher: Researcher;
61
64
  private fisherman: Fisherman | null = null;
62
65
 
@@ -127,13 +130,25 @@ export class Planner extends PlannerBase implements Agent {
127
130
  debugLog('Planning:', state?.url);
128
131
  if (!state) throw new Error('No state found');
129
132
 
130
- if (!this.freshStart && !feature && !this.currentPlan && state.url) {
133
+ if (!feature && !this.currentPlan && state.url) {
131
134
  const similar = this.findSimilarPlan(state.url);
132
135
  if (similar) {
133
136
  tag('info').log(`Similar page already planned: ${similar.url} (${similar.plan.tests.length} tests)`);
134
137
  this.registerPlanInSession(similar.plan);
135
138
  return similar.plan;
136
139
  }
140
+
141
+ const actionResult = ActionResult.fromState(state);
142
+ const combinedHtml = await actionResult.combinedHtml();
143
+ const similarHash = await findSimilarStateHash(combinedHtml);
144
+ if (similarHash) {
145
+ const planned = getPlannedByStateHash(similarHash);
146
+ if (planned) {
147
+ tag('info').log(`Page content similar to already-planned: ${planned.url} — skipping`);
148
+ this.registerPlanInSession(planned.plan);
149
+ return planned.plan;
150
+ }
151
+ }
137
152
  }
138
153
 
139
154
  if (!this.freshStart && !this.currentPlan && state.url) {
@@ -188,14 +203,14 @@ export class Planner extends PlannerBase implements Agent {
188
203
  this.currentPlan.url = state.url;
189
204
  if (parentPlan) this.currentPlan.parentPlan = parentPlan;
190
205
  const allPreviousScenarios = this.getPreviousSessionScenarios();
206
+ const existingTestScenarios = this.getExistingTestFileScenarios(state.url);
207
+ for (const s of existingTestScenarios) allPreviousScenarios.add(s);
191
208
  for (const t of tests) {
192
209
  if (allPreviousScenarios.has(t.scenario.toLowerCase())) continue;
193
210
  t.style = this.lastStyleName;
194
211
  t.startUrl = state.url;
195
212
  this.currentPlan.addTest(t);
196
213
  }
197
- const summary = `Scenarios:\n${this.currentPlan.tests.map((t) => `- [${t.priority}] ${t.scenario}`).join('\n')}`;
198
- tag('multiline').log(summary);
199
214
  } else {
200
215
  tag('step').log(`Expanding plan: "${this.currentPlan.title}"`);
201
216
  this.currentPlan.nextIteration();
@@ -206,24 +221,19 @@ export class Planner extends PlannerBase implements Agent {
206
221
  }
207
222
  }
208
223
 
209
- this.moveExecutedTestsToEnd();
210
224
  const availableStyles = Object.keys(getStyles()).join(', ');
211
225
  tag('success').log(`Planning complete! ${this.currentPlan.tests.length} tests in plan: ${this.currentPlan.title}`);
212
226
  tag('info').log(`Planning style: ${this.lastStyleName} (available: ${availableStyles})`);
213
227
 
214
- if (state.url) registerPlan(state.url, this.currentPlan, feature);
228
+ if (state.url) registerPlan(state.url, this.currentPlan, feature, state.hash);
215
229
 
216
230
  this.registerPlanInSession(this.currentPlan);
217
231
 
218
232
  return this.currentPlan;
219
233
  }
220
234
 
221
- private moveExecutedTestsToEnd(): void {
222
- if (!this.currentPlan) return;
223
- const pending = this.currentPlan.tests.filter((t) => t.result === null);
224
- const executed = this.currentPlan.tests.filter((t) => t.result !== null);
225
- this.currentPlan.tests = [...pending, ...executed];
226
- this.currentPlan.notifyChange();
235
+ getSuite(): Suite | null {
236
+ return this.lastSuite;
227
237
  }
228
238
 
229
239
  private addNewTests(tests: Test[], defaultStartUrl: string): Test[] {
@@ -249,6 +259,17 @@ export class Planner extends PlannerBase implements Agent {
249
259
  return added;
250
260
  }
251
261
 
262
+ private getExistingTestFileScenarios(currentUrl?: string): Set<string> {
263
+ if (!currentUrl) return new Set<string>();
264
+ try {
265
+ this.lastSuite = new Suite(currentUrl);
266
+ return this.lastSuite.getActiveScenarioTitles();
267
+ } catch (err: any) {
268
+ debugLog('Failed to load existing test files: %s', err.message);
269
+ return new Set<string>();
270
+ }
271
+ }
272
+
252
273
  private cleanExperienceFlows(text: string): string | null {
253
274
  const seenTitles = new Set<string>();
254
275
  let result = text;
@@ -408,6 +429,17 @@ export class Planner extends PlannerBase implements Agent {
408
429
  }
409
430
  }
410
431
 
432
+ if (this.lastSuite && this.lastSuite.automatedTestCount > 0) {
433
+ const automatedNames = this.lastSuite.getAutomatedTestNames();
434
+ conversation.addUserText(dedent`
435
+ <existing_automated_tests>
436
+ The following ${automatedNames.length} tests are already implemented and automated for this URL.
437
+ Do not propose tests that duplicate these:
438
+ ${automatedNames.map((n) => `- ${n}`).join('\n')}
439
+ </existing_automated_tests>
440
+ `);
441
+ }
442
+
411
443
  if (this.currentPlan) {
412
444
  tag('step').log('Analyzing current plan to expand testing');
413
445