explorbot 0.1.16 → 0.1.18

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 (47) hide show
  1. package/bin/explorbot-cli.ts +14 -1
  2. package/boat/doc-collector/bin/doc-collector-cli.ts +5 -0
  3. package/boat/doc-collector/package.json +24 -0
  4. package/boat/doc-collector/src/ai/documentarian.ts +184 -0
  5. package/boat/doc-collector/src/cli.ts +119 -0
  6. package/boat/doc-collector/src/config.ts +162 -0
  7. package/boat/doc-collector/src/docbot.ts +391 -0
  8. package/boat/doc-collector/src/docs-renderer.ts +187 -0
  9. package/boat/doc-collector/src/path-filter.ts +46 -0
  10. package/boat/doc-collector/src/research-navigation.ts +90 -0
  11. package/dist/bin/explorbot-cli.js +15 -1
  12. package/dist/boat/doc-collector/bin/doc-collector-cli.js +4 -0
  13. package/dist/boat/doc-collector/src/ai/documentarian.js +157 -0
  14. package/dist/boat/doc-collector/src/cli.js +104 -0
  15. package/dist/boat/doc-collector/src/config.js +129 -0
  16. package/dist/boat/doc-collector/src/docbot.js +326 -0
  17. package/dist/boat/doc-collector/src/docs-renderer.js +141 -0
  18. package/dist/boat/doc-collector/src/path-filter.js +35 -0
  19. package/dist/boat/doc-collector/src/research-navigation.js +71 -0
  20. package/dist/package.json +4 -1
  21. package/dist/src/ai/pilot.js +3 -8
  22. package/dist/src/ai/researcher/coordinates.js +1 -1
  23. package/dist/src/ai/researcher/parser.js +3 -0
  24. package/dist/src/ai/researcher.js +2 -1
  25. package/dist/src/ai/tester.js +1 -0
  26. package/dist/src/commands/explore-command.js +359 -43
  27. package/dist/src/config.js +10 -3
  28. package/dist/src/explorbot.js +19 -5
  29. package/dist/src/explorer.js +14 -1
  30. package/dist/src/state-manager.js +3 -0
  31. package/dist/src/utils/test-plan-markdown.js +8 -1
  32. package/dist/src/utils/url-matcher.js +5 -3
  33. package/dist/src/utils/web-element.js +3 -2
  34. package/package.json +4 -1
  35. package/src/ai/pilot.ts +3 -8
  36. package/src/ai/researcher/coordinates.ts +1 -1
  37. package/src/ai/researcher/parser.ts +3 -0
  38. package/src/ai/researcher.ts +2 -1
  39. package/src/ai/tester.ts +1 -0
  40. package/src/commands/explore-command.ts +362 -42
  41. package/src/config.ts +13 -3
  42. package/src/explorbot.ts +22 -7
  43. package/src/explorer.ts +12 -1
  44. package/src/state-manager.ts +4 -0
  45. package/src/utils/test-plan-markdown.ts +8 -1
  46. package/src/utils/url-matcher.ts +5 -2
  47. package/src/utils/web-element.ts +3 -2
@@ -90,11 +90,13 @@ export function matchesUrl(pattern, path) {
90
90
  }
91
91
  }
92
92
  export function extractStatePath(url) {
93
- if (url.startsWith('/'))
94
- return url;
93
+ if (url.startsWith('/')) {
94
+ return `/${url.replace(/^\/+/, '')}`;
95
+ }
95
96
  try {
96
97
  const urlObj = new URL(url);
97
- return `${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
98
+ const normalizedPathname = `/${urlObj.pathname.replace(/^\/+/, '')}`;
99
+ return `${normalizedPathname}${urlObj.search}${urlObj.hash}`;
98
100
  }
99
101
  catch {
100
102
  return url;
@@ -109,7 +109,8 @@ export class WebElement {
109
109
  return WebElement.fromPlaywrightLocator(page.locator(`[${EXPLORBOT_ATTRS.eidx}="${eidx}"]`));
110
110
  }
111
111
  static async fromEidxList(page, eidxList) {
112
- if (eidxList.length === 0)
112
+ const validEidxList = eidxList.filter((eidx) => /^e\d+$/i.test(eidx));
113
+ if (validEidxList.length === 0)
113
114
  return [];
114
115
  const rawList = await page.evaluate(([list, extractFnStr, config]) => {
115
116
  const extract = new Function(`return ${extractFnStr}`)();
@@ -123,7 +124,7 @@ export class WebElement {
123
124
  results.push(data);
124
125
  }
125
126
  return results;
126
- }, [eidxList, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG]);
127
+ }, [validEidxList, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG]);
127
128
  return rawList.map((d) => WebElement.fromRawData(d));
128
129
  }
129
130
  static async findByXPath(html, xpath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -20,6 +20,9 @@
20
20
  "src/**/*.tsx",
21
21
  "bin/**/*.ts",
22
22
  "boat/api-tester/src/**/*.ts",
23
+ "boat/doc-collector/src/**/*.ts",
24
+ "boat/doc-collector/bin/**/*.ts",
25
+ "boat/doc-collector/package.json",
23
26
  "rules/",
24
27
  "assets/sample-files/"
25
28
  ],
package/src/ai/pilot.ts CHANGED
@@ -320,14 +320,9 @@ export class Pilot implements Agent {
320
320
  - "Edit X" → updated value must be persisted (visible in list/detail). Opening edit is NOT enough; redirect after save with the new value visible IS enough.
321
321
  - Negative tests ("without a name", "invalid", "duplicate", "unauthorized") → success means the system PREVENTED the action with validation/error.
322
322
 
323
- PROVENANCE for create/edit scenarios: the task prompt instructs the tester to inject the
324
- session marker "${task.sessionName ?? ''}" into newly created or edited free-text values.
325
- When that marker COULD be injected, the entity used as proof MUST contain it. A record
326
- matching the goal by text alone but missing the marker is a stale leftover from a prior
327
- run — it is NOT evidence the current scenario produced anything. Vote \`fail\`, not \`pass\`.
328
- This does not apply when the field is restricted (numeric only, enum, etc.) or when the
329
- session_log shows no fillField/type/select actions were attempted at all (in that case
330
- the scenario clearly didn't run — also vote \`fail\`).
323
+ PROVENANCE: the entity you cite as proof must appear by name in <notes> or
324
+ <session_log> tool inputs for THIS run. Name absent from tester activity = stale
325
+ coincidence, vote \`fail\`. Same if no fillField/type/select/click on a target ran.
331
326
 
332
327
  Expected results are MILESTONES, not the goal. Never fail because a milestone (toast, icon, styling)
333
328
  didn't match if the scenario goal IS accomplished.
@@ -198,7 +198,7 @@ export function WithCoordinates<T extends Constructor>(Base: T) {
198
198
  const eidxWithoutCoords: string[] = [];
199
199
  for (const section of sections) {
200
200
  for (const el of section.elements) {
201
- if (el.eidx && !el.coordinates) eidxWithoutCoords.push(el.eidx);
201
+ if (el.eidx && /^e\d+$/i.test(el.eidx) && !el.coordinates) eidxWithoutCoords.push(el.eidx);
202
202
  }
203
203
  }
204
204
  if (eidxWithoutCoords.length === 0) return;
@@ -64,6 +64,9 @@ export function mapRowToElement(row: Record<string, string>): ResearchElement |
64
64
 
65
65
  let eidxRaw = (colMap.eidx || '').trim();
66
66
  if (eidxRaw && /^\d+$/.test(eidxRaw)) eidxRaw = `e${eidxRaw}`;
67
+ if (eidxRaw && !/^e\d+$/i.test(eidxRaw)) {
68
+ eidxRaw = '';
69
+ }
67
70
 
68
71
  const aria = parseAriaLocator(colMap.aria || '-');
69
72
 
@@ -121,7 +121,8 @@ export class Researcher extends ResearcherBase implements Agent {
121
121
 
122
122
  const sessionName = `researcher: ${state.url}`;
123
123
  return Observability.run(sessionName, { tags: ['researcher'], sessionId: stateHash }, async () => {
124
- tag('info').log(`Researching ${state.url} to understand the context...`);
124
+ const displayUrl = state.fullUrl || state.url;
125
+ tag('info').log(`Researching ${displayUrl} to understand the context...`);
125
126
  setActivity(`${this.emoji} Researching...`, 'action');
126
127
 
127
128
  await this.ensureNavigated(state.url, screenshot && this.provider.hasVision());
package/src/ai/tester.ts CHANGED
@@ -730,6 +730,7 @@ export class Tester extends TaskAgent implements Agent {
730
730
  - Use pressKey() for pressing special keys (Enter, Escape, Tab, Arrow keys) or key combinations with modifiers (Ctrl+A, Shift+Delete, etc.)
731
731
  - Use container CSS locators from <page_ui_map> to interact with elements inside sections
732
732
  - Systematically use record({ notes: ["..."] }) to write your findings, planned actions, observations, etc.
733
+ - When creating/editing/deleting a named entity, include its identifier verbatim in the note — Pilot uses it to confirm provenance.
733
734
  - Call record({ notes: ["..."], status: "success" }) when you see success/info message on a page or when expected outcome is achieved
734
735
  - Call record({ notes: ["..."], status: "fail" }) when an expected outcome cannot be achieved or has failed or you see error/alert/warning message on a page
735
736
  - NEVER call record(status: "success") if your last verify() or see() call FAILED. A failed check means the outcome is NOT confirmed — use record(status: "fail") instead, or retry with a different approach.
@@ -3,7 +3,7 @@ import { getStyles } from '../ai/planner/styles.js';
3
3
  import { outputPath } from '../config.js';
4
4
  import { normalizeUrl } from '../state-manager.js';
5
5
  import { Stats } from '../stats.js';
6
- import type { Plan } from '../test-plan.js';
6
+ import { type Plan, type Test, TestResult } from '../test-plan.js';
7
7
  import { getCliName } from '../utils/cli-name.ts';
8
8
  import { ErrorPageError } from '../utils/error-page.ts';
9
9
  import { tag } from '../utils/logger.js';
@@ -13,6 +13,7 @@ import { safeFilename } from '../utils/strings.ts';
13
13
  import { BaseCommand, type Suggestion } from './base-command.js';
14
14
 
15
15
  const MAX_SUB_PAGE_ATTEMPTS = 30;
16
+ const PRIORITY_ORDER: Record<string, number> = { critical: 0, important: 1, high: 2, normal: 3, low: 4 };
16
17
 
17
18
  export class ExploreCommand extends BaseCommand {
18
19
  name = 'explore';
@@ -20,6 +21,8 @@ export class ExploreCommand extends BaseCommand {
20
21
  options = [
21
22
  { flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
22
23
  { flags: '--focus <feature>', description: 'Focus area for exploration' },
24
+ { flags: '--configure <spec>', description: 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"' },
25
+ { flags: '--dry-run', description: 'Mark picked tests as skipped without executing or generating new ones' },
23
26
  ];
24
27
  suggestions: Suggestion[] = [
25
28
  { command: 'navigate <page>', hint: 'go to another page' },
@@ -28,9 +31,12 @@ export class ExploreCommand extends BaseCommand {
28
31
  ];
29
32
 
30
33
  maxTests?: number;
34
+ dryRun = false;
31
35
  private testsRun = 0;
32
36
  private completedPlans: Plan[] = [];
33
37
  private failedSubPages = new Set<string>();
38
+ private oldTestRefs = new Set<Test>();
39
+ private priorityFilter?: Set<string>;
34
40
 
35
41
  async execute(args: string): Promise<void> {
36
42
  const { opts, args: remaining } = this.parseArgs(args);
@@ -39,86 +45,359 @@ export class ExploreCommand extends BaseCommand {
39
45
  }
40
46
 
41
47
  const feature = (opts.focus as string) || remaining.join(' ') || undefined;
48
+ const cfg = this.parseConfigure(opts.configure as string | undefined);
49
+ if (cfg.priorities) this.priorityFilter = new Set(cfg.priorities);
50
+ if (opts.dryRun) this.dryRun = true;
51
+ if (this.dryRun) tag('info').log('Dry-run mode: planner runs to discover new tests; test execution is skipped');
42
52
  Stats.mode ??= 'explore';
43
53
  Stats.focus ??= feature;
44
54
  const mainUrl = this.explorBot.getExplorer().getStateManager().getCurrentState()?.url;
45
55
 
46
- await this.runAllStyles(mainUrl, feature);
56
+ if (cfg.enabled) {
57
+ await this.runReuseMode(mainUrl, feature, cfg);
58
+ } else {
59
+ await this.runFreshMode(mainUrl, feature, cfg.styles);
60
+ }
61
+
62
+ const mainPlan = this.completedPlans[0];
63
+ if (mainPlan) this.explorBot.setCurrentPlan(mainPlan);
64
+ if (this.dryRun) {
65
+ this.printResults();
66
+ return;
67
+ }
68
+ if (mainUrl) await this.explorBot.visit(mainUrl);
69
+ const savedPath = this.explorBot.savePlans(this.completedPlans);
70
+ this.printResults();
71
+ await this.explorBot.printSessionAnalysis();
72
+ this.printNextSteps(savedPath);
73
+ }
74
+
75
+ private originLabel(test: Test): string {
76
+ return this.oldTestRefs.has(test) ? 'OLD' : 'NEW';
77
+ }
78
+
79
+ private printPreview(label: string, tests: Test[]): void {
80
+ if (tests.length === 0) return;
81
+ const lines = [label];
82
+ for (let i = 0; i < tests.length; i++) {
83
+ const t = tests[i];
84
+ lines.push(` ${String(i + 1).padStart(2)}. [${this.originLabel(t)}] [${t.priority.padEnd(9)}] ${t.scenario}`);
85
+ }
86
+ tag('multiline').log(lines.join('\n'));
87
+ }
88
+
89
+ private async runFreshMode(mainUrl: string | undefined, feature: string | undefined, styles?: string[]): Promise<void> {
90
+ await this.runAllStyles(mainUrl, feature, undefined, undefined, styles);
47
91
  const mainPlan = this.explorBot.getCurrentPlan();
48
92
  if (!mainPlan) return;
49
93
  this.completedPlans.push(mainPlan);
50
94
 
51
- if (!feature && !this.isLimitReached()) {
52
- const planner = this.explorBot.agentPlanner();
53
- let attempts = 0;
54
- while (attempts < MAX_SUB_PAGE_ATTEMPTS) {
55
- attempts++;
56
- if (this.isLimitReached()) break;
95
+ if (feature || this.isLimitReached()) return;
96
+
97
+ await this.discoverNewSubPages(mainPlan, mainUrl, styles, new Set());
98
+ }
99
+
100
+ private async runReuseMode(mainUrl: string | undefined, feature: string | undefined, cfg: ConfigureSpec): Promise<void> {
101
+ const filename = cfg.fromPath || this.explorBot.generatePlanFilename(feature);
102
+
103
+ let loadedPlans: Plan[] = [];
104
+ try {
105
+ loadedPlans = this.explorBot.loadPlans(filename);
106
+ } catch (err) {
107
+ tag('warning').log(`Reuse plan not found (${err instanceof Error ? err.message : err}); falling back to fresh planning`);
108
+ await this.runFreshMode(mainUrl, feature, cfg.styles);
109
+ return;
110
+ }
57
111
 
58
- const candidates = planner.collectSubPageCandidates(mainPlan, mainUrl || '/').filter((c) => !this.failedSubPages.has(normalizeUrl(c.url)));
59
- if (candidates.length === 0) break;
112
+ if (loadedPlans.length === 0) {
113
+ tag('warning').log('Reuse plan empty; falling back to fresh planning');
114
+ await this.runFreshMode(mainUrl, feature, cfg.styles);
115
+ return;
116
+ }
117
+
118
+ const mainPlan = loadedPlans[0];
119
+ const subPlans = loadedPlans.slice(1);
60
120
 
61
- const pick = await planner.pickNextSubPage(candidates);
62
- if (!pick) break;
121
+ const totalCap = this.maxTests ?? Number.POSITIVE_INFINITY;
122
+ let newQuota = Number.POSITIVE_INFINITY;
123
+ let oldQuota = Number.POSITIVE_INFINITY;
124
+ if (Number.isFinite(totalCap)) {
125
+ newQuota = Math.round(totalCap * cfg.newRatio);
126
+ oldQuota = Math.max(0, totalCap - newQuota);
127
+ }
63
128
 
64
- tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
129
+ for (const p of loadedPlans) {
130
+ for (const t of p.tests) this.oldTestRefs.add(t);
131
+ }
132
+
133
+ const allOldTests = loadedPlans.flatMap((p) => p.tests.filter((t) => t.status === 'pending'));
134
+ let matchingOldTests: Test[] = allOldTests;
135
+ if (cfg.styles) {
136
+ matchingOldTests = matchingOldTests.filter((t) => !t.style || cfg.styles!.includes(t.style));
137
+ }
138
+ if (this.priorityFilter) {
139
+ matchingOldTests = matchingOldTests.filter((t) => this.priorityFilter!.has(t.priority));
140
+ }
141
+ const pickBy = cfg.pickBy ?? 'priority';
142
+ const orderedOldTests = matchingOldTests.slice();
143
+ if (pickBy === 'priority') {
144
+ orderedOldTests.sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99));
145
+ } else if (pickBy === 'random') {
146
+ for (let i = orderedOldTests.length - 1; i > 0; i--) {
147
+ const j = Math.floor(Math.random() * (i + 1));
148
+ [orderedOldTests[i], orderedOldTests[j]] = [orderedOldTests[j], orderedOldTests[i]];
149
+ }
150
+ }
151
+
152
+ let pickCount = orderedOldTests.length;
153
+ if (Number.isFinite(oldQuota)) pickCount = Math.min(oldQuota, orderedOldTests.length);
154
+ const picked = orderedOldTests.slice(0, pickCount);
155
+ const pickedSet = new Set(picked);
156
+
157
+ for (const t of allOldTests) {
158
+ if (!pickedSet.has(t)) t.enabled = false;
159
+ }
160
+
161
+ let newQuotaLabel = 'unlimited';
162
+ if (Number.isFinite(newQuota)) newQuotaLabel = String(newQuota);
163
+ let priorityNote = '';
164
+ if (this.priorityFilter) priorityNote = `, priority=[${[...this.priorityFilter].join(',')}]`;
165
+ tag('info').log(`Reuse: loaded ${allOldTests.length} old test(s), running ${picked.length} (pick_by=${pickBy}${priorityNote}), reserving ${newQuotaLabel} for new`);
166
+
167
+ const planner = this.explorBot.agentPlanner();
168
+ for (const p of loadedPlans) planner.registerPlanInSession(p);
169
+
170
+ this.completedPlans.push(...loadedPlans);
171
+
172
+ this.printPreview(`Picked old tests (${picked.length}):`, picked);
173
+
174
+ let currentPlanRef: Plan | undefined;
175
+ for (const test of picked) {
176
+ if (this.isLimitReached()) break;
177
+ const owningPlan = test.plan;
178
+ if (owningPlan && owningPlan !== currentPlanRef) {
179
+ this.explorBot.setCurrentPlan(owningPlan);
180
+ if (owningPlan.url && !this.dryRun) await this.explorBot.visit(owningPlan.url);
181
+ currentPlanRef = owningPlan;
182
+ }
183
+ await this.runOneTest(test);
184
+ }
185
+
186
+ if (this.isLimitReached() || newQuota <= 0) return;
187
+
188
+ const subpagesMode = cfg.subpages || 'both';
189
+
190
+ if (mainUrl && !this.dryRun) await this.explorBot.visit(mainUrl);
191
+ await this.replanAndRun(mainUrl, feature, mainPlan, cfg.styles);
192
+
193
+ if (this.isLimitReached()) return;
194
+
195
+ if (subpagesMode === 'same' || subpagesMode === 'both') {
196
+ for (const subPlan of subPlans) {
197
+ if (this.isLimitReached()) break;
198
+ if (!subPlan.url) continue;
65
199
  try {
66
- await this.explorBot.visit(pick.url);
67
- await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans);
68
- const subPlan = this.explorBot.getCurrentPlan();
69
- if (subPlan) {
70
- this.completedPlans.push(subPlan);
71
- }
200
+ if (!this.dryRun) await this.explorBot.visit(subPlan.url);
201
+ await this.replanAndRun(subPlan.url, undefined, subPlan, cfg.styles);
72
202
  } catch (err) {
73
- this.failedSubPages.add(normalizeUrl(pick.url));
74
- tag('warning').log(`Sub-page exploration failed: ${err instanceof Error ? err.message : err}`);
203
+ this.failedSubPages.add(normalizeUrl(subPlan.url));
204
+ tag('warning').log(`Sub-page re-planning failed: ${err instanceof Error ? err.message : err}`);
75
205
  }
76
206
  }
77
207
  }
78
208
 
79
- this.explorBot.setCurrentPlan(mainPlan);
80
- if (mainUrl) await this.explorBot.visit(mainUrl);
81
- const savedPath = this.explorBot.savePlans(this.completedPlans);
82
- this.printResults();
83
- await this.explorBot.printSessionAnalysis();
84
- this.printNextSteps(savedPath);
209
+ if (this.isLimitReached()) return;
210
+
211
+ if (subpagesMode === 'new' || subpagesMode === 'both') {
212
+ const knownUrls = new Set<string>();
213
+ for (const p of loadedPlans) {
214
+ if (p.url) knownUrls.add(normalizeUrl(p.url));
215
+ }
216
+ await this.discoverNewSubPages(mainPlan, mainUrl, cfg.styles, knownUrls);
217
+ }
218
+ }
219
+
220
+ private async discoverNewSubPages(mainPlan: Plan, mainUrl: string | undefined, styles: string[] | undefined, knownUrls: Set<string>): Promise<void> {
221
+ const planner = this.explorBot.agentPlanner();
222
+ let attempts = 0;
223
+ while (attempts < MAX_SUB_PAGE_ATTEMPTS) {
224
+ attempts++;
225
+ if (this.isLimitReached()) break;
226
+
227
+ const candidates = planner.collectSubPageCandidates(mainPlan, mainUrl || '/').filter((c) => {
228
+ const norm = normalizeUrl(c.url);
229
+ return !this.failedSubPages.has(norm) && !knownUrls.has(norm);
230
+ });
231
+ if (candidates.length === 0) break;
232
+
233
+ const pick = await planner.pickNextSubPage(candidates);
234
+ if (!pick) break;
235
+
236
+ tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
237
+ try {
238
+ await this.explorBot.visit(pick.url);
239
+ await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans, styles);
240
+ const subPlan = this.explorBot.getCurrentPlan();
241
+ if (subPlan && !this.completedPlans.includes(subPlan)) {
242
+ this.completedPlans.push(subPlan);
243
+ }
244
+ knownUrls.add(normalizeUrl(pick.url));
245
+ } catch (err) {
246
+ this.failedSubPages.add(normalizeUrl(pick.url));
247
+ tag('warning').log(`Sub-page exploration failed: ${err instanceof Error ? err.message : err}`);
248
+ }
249
+ }
85
250
  }
86
251
 
87
- private async runAllStyles(pageUrl?: string, feature?: string, parentPlan?: Plan, completedPlans?: Plan[]): Promise<void> {
252
+ private async replanAndRun(pageUrl: string | undefined, feature: string | undefined, existingPlan: Plan, styles?: string[]): Promise<void> {
253
+ const styleList = styles ?? Object.keys(getStyles());
254
+ for (const style of styleList) {
255
+ if (this.isLimitReached()) break;
256
+ this.explorBot.setCurrentPlan(existingPlan);
257
+ const opts: { fresh: boolean; style: string; completedPlans?: Plan[]; noSave?: boolean } = { fresh: false, style, completedPlans: this.completedPlans };
258
+ if (this.dryRun) opts.noSave = true;
259
+ await this.planWithRetry(feature, opts, pageUrl);
260
+ await this.runPendingTests();
261
+ }
262
+ }
263
+
264
+ private async runAllStyles(pageUrl?: string, feature?: string, parentPlan?: Plan, completedPlans?: Plan[], styles?: string[]): Promise<void> {
265
+ const styleList = styles ?? Object.keys(getStyles());
88
266
  let fresh = true;
89
- for (const style of Object.keys(getStyles())) {
90
- if (!fresh && pageUrl) {
267
+ for (const style of styleList) {
268
+ if (!fresh && pageUrl && !this.dryRun) {
91
269
  await this.explorBot.visit(pageUrl);
92
270
  }
93
- const opts: { fresh: boolean; style: string; extend?: Plan; completedPlans?: Plan[] } = { fresh, style, completedPlans };
271
+ const opts: { fresh: boolean; style: string; extend?: Plan; completedPlans?: Plan[]; noSave?: boolean } = { fresh, style, completedPlans };
94
272
  if (fresh && parentPlan) opts.extend = parentPlan;
273
+ if (this.dryRun) opts.noSave = true;
95
274
  await this.planWithRetry(feature, opts, pageUrl);
96
275
  await this.runPendingTests();
97
276
  fresh = false;
98
277
  }
99
278
  }
100
279
 
101
- private async planWithRetry(feature: string | undefined, opts: { fresh: boolean; style: string; extend?: Plan; completedPlans?: Plan[] }, pageUrl?: string): Promise<void> {
102
- await this.explorBot.plan(feature, opts);
103
- if (!this.explorBot.lastPlanError) return;
104
- if (this.explorBot.lastPlanError instanceof ErrorPageError) {
105
- throw this.explorBot.lastPlanError;
106
- }
280
+ private async planWithRetry(feature: string | undefined, opts: { fresh: boolean; style: string; extend?: Plan; completedPlans?: Plan[]; noSave?: boolean }, pageUrl?: string): Promise<void> {
281
+ const before = new Set(this.explorBot.getCurrentPlan()?.tests ?? []);
107
282
 
108
- tag('info').log(`Retrying planning style '${opts.style}'...`);
109
- if (pageUrl) await this.explorBot.visit(pageUrl);
110
283
  await this.explorBot.plan(feature, opts);
111
284
  if (this.explorBot.lastPlanError) {
112
- tag('warning').log(`Planning style '${opts.style}' failed after retry, skipping`);
285
+ if (this.explorBot.lastPlanError instanceof ErrorPageError) {
286
+ throw this.explorBot.lastPlanError;
287
+ }
288
+ tag('info').log(`Retrying planning style '${opts.style}'...`);
289
+ if (pageUrl && !this.dryRun) await this.explorBot.visit(pageUrl);
290
+ await this.explorBot.plan(feature, opts);
291
+ if (this.explorBot.lastPlanError) {
292
+ tag('warning').log(`Planning style '${opts.style}' failed after retry, skipping`);
293
+ return;
294
+ }
295
+ }
296
+
297
+ const planAfter = this.explorBot.getCurrentPlan();
298
+ if (!planAfter) return;
299
+ const added = planAfter.tests.filter((t) => !before.has(t));
300
+ if (added.length === 0) return;
301
+ const urlNote = pageUrl ? ` for ${pageUrl}` : '';
302
+ this.printPreview(`Planner added ${added.length} new test(s) [style=${opts.style}]${urlNote}:`, added);
303
+ }
304
+
305
+ private parseConfigure(raw: string | undefined): ConfigureSpec {
306
+ const cfg: ConfigureSpec = { enabled: false, newRatio: 1.0 };
307
+ if (!raw) return cfg;
308
+
309
+ const allStyles = Object.keys(getStyles());
310
+ const validSubpages = new Set(['none', 'same', 'new', 'both']);
311
+ let hasReuseSignal = false;
312
+
313
+ for (const pair of raw.split(';')) {
314
+ const trimmed = pair.trim();
315
+ if (!trimmed) continue;
316
+ const sepMatch = trimmed.match(/^([^:=]+)\s*[:=]\s*(.*)$/);
317
+ if (!sepMatch) {
318
+ tag('warning').log(`Ignoring malformed configure pair: ${trimmed}`);
319
+ continue;
320
+ }
321
+ const key = sepMatch[1].trim().toLowerCase();
322
+ const value = sepMatch[2].trim();
323
+
324
+ if (key === 'new') {
325
+ const ratio = parseRatio(value);
326
+ if (ratio == null) {
327
+ tag('warning').log(`Ignoring invalid 'new' value: ${value}`);
328
+ continue;
329
+ }
330
+ cfg.newRatio = ratio;
331
+ hasReuseSignal = true;
332
+ continue;
333
+ }
334
+ if (key === 'from') {
335
+ cfg.fromPath = value;
336
+ hasReuseSignal = true;
337
+ continue;
338
+ }
339
+ if (key === 'style' || key === 'styles') {
340
+ const requested = value
341
+ .split(',')
342
+ .map((s) => s.trim())
343
+ .filter(Boolean);
344
+ const valid: string[] = [];
345
+ for (const s of requested) {
346
+ if (allStyles.includes(s)) {
347
+ valid.push(s);
348
+ continue;
349
+ }
350
+ tag('warning').log(`Unknown planning style: ${s}`);
351
+ }
352
+ if (valid.length) cfg.styles = valid;
353
+ continue;
354
+ }
355
+ if (key === 'subpages') {
356
+ if (!validSubpages.has(value)) {
357
+ tag('warning').log(`Ignoring invalid 'subpages' value: ${value}`);
358
+ continue;
359
+ }
360
+ cfg.subpages = value as ConfigureSpec['subpages'];
361
+ continue;
362
+ }
363
+ if (key === 'pick_by' || key === 'pickby' || key === 'pick-by') {
364
+ if (value === 'priority' || value === 'random' || value === 'index') {
365
+ cfg.pickBy = value;
366
+ continue;
367
+ }
368
+ tag('warning').log(`Ignoring invalid 'pick_by' value: ${value} (use priority|random|index)`);
369
+ continue;
370
+ }
371
+ if (key === 'priority' || key === 'priorities') {
372
+ const requested = value
373
+ .split(',')
374
+ .map((s) => s.trim().toLowerCase())
375
+ .filter(Boolean);
376
+ const valid: string[] = [];
377
+ for (const p of requested) {
378
+ if (p in PRIORITY_ORDER) {
379
+ valid.push(p);
380
+ continue;
381
+ }
382
+ tag('warning').log(`Unknown priority: ${p} (use ${Object.keys(PRIORITY_ORDER).join('|')})`);
383
+ }
384
+ if (valid.length) cfg.priorities = valid;
385
+ continue;
386
+ }
387
+ tag('warning').log(`Unknown configure key: ${key}`);
113
388
  }
389
+
390
+ cfg.enabled = hasReuseSignal;
391
+ return cfg;
114
392
  }
115
393
 
116
394
  private printResults(): void {
117
- const allTests = this.completedPlans.flatMap((plan) => plan.tests.filter((t) => t.startTime != null).map((test) => ({ test, planTitle: plan.title })));
395
+ const allTests = this.completedPlans.flatMap((plan) => plan.tests.filter((t) => t.startTime != null).map((test) => ({ test, planTitle: plan.title }))).sort((a, b) => (a.test.startTime ?? 0) - (b.test.startTime ?? 0));
118
396
 
119
397
  if (allTests.length === 0) return;
120
398
 
121
399
  const hasSubPages = this.completedPlans.length > 1;
400
+ const hasOrigin = this.oldTestRefs.size > 0;
122
401
  const rows = allTests.map(({ test, planTitle }, index) => {
123
402
  const durationMs = test.getDurationMs();
124
403
  const duration = durationMs != null ? `${(durationMs / 1000).toFixed(1)}s` : '-';
@@ -133,12 +412,16 @@ export class ExploreCommand extends BaseCommand {
133
412
  Time: duration,
134
413
  Steps: String(Object.keys(test.notes).length),
135
414
  };
415
+ if (hasOrigin) {
416
+ row.Origin = this.originLabel(test);
417
+ }
136
418
  if (hasSubPages) {
137
419
  row.Plan = planTitle;
138
420
  }
139
421
  return row;
140
422
  });
141
423
  const columns = ['#', 'Status', 'Title', 'Priority', 'Time', 'Steps'];
424
+ if (hasOrigin) columns.push('Origin');
142
425
  if (hasSubPages) columns.push('Plan');
143
426
  tag('multiline').log(jsonToTable(rows, columns));
144
427
  tag('info').log(`${figureSet.tick} ${allTests.length} tests completed`);
@@ -197,10 +480,47 @@ export class ExploreCommand extends BaseCommand {
197
480
  private async runPendingTests(): Promise<void> {
198
481
  const plan = this.explorBot.getCurrentPlan();
199
482
  if (!plan) return;
483
+ if (this.priorityFilter) {
484
+ for (const t of plan.getPendingTests()) {
485
+ if (!this.priorityFilter.has(t.priority)) t.enabled = false;
486
+ }
487
+ }
200
488
  for (const test of plan.getPendingTests()) {
201
489
  if (this.isLimitReached()) break;
490
+ await this.runOneTest(test);
491
+ }
492
+ }
493
+
494
+ private async runOneTest(test: Test): Promise<void> {
495
+ if (this.dryRun) {
496
+ test.start();
497
+ test.finish(TestResult.SKIPPED);
498
+ } else {
202
499
  await this.explorBot.agentTester().test(test);
203
- this.testsRun++;
204
500
  }
501
+ this.testsRun++;
502
+ }
503
+ }
504
+
505
+ interface ConfigureSpec {
506
+ enabled: boolean;
507
+ newRatio: number;
508
+ fromPath?: string;
509
+ styles?: string[];
510
+ subpages?: 'none' | 'same' | 'new' | 'both';
511
+ pickBy?: 'priority' | 'random' | 'index';
512
+ priorities?: string[];
513
+ }
514
+
515
+ function parseRatio(s: string): number | null {
516
+ const trimmed = s.trim();
517
+ if (!trimmed) return null;
518
+ if (trimmed.endsWith('%')) {
519
+ const n = Number.parseFloat(trimmed.slice(0, -1));
520
+ if (Number.isNaN(n) || n < 0 || n > 100) return null;
521
+ return n / 100;
205
522
  }
523
+ const n = Number.parseFloat(trimmed);
524
+ if (Number.isNaN(n) || n < 0 || n > 1) return null;
525
+ return n;
206
526
  }