explorbot 0.1.15 → 0.1.17
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 +12 -1
- package/dist/bin/explorbot-cli.js +13 -1
- package/dist/package.json +1 -1
- package/dist/src/ai/pilot.js +3 -8
- package/dist/src/ai/researcher/focus.js +51 -10
- package/dist/src/ai/researcher/sections.js +8 -4
- package/dist/src/ai/researcher.js +9 -24
- package/dist/src/ai/tester.js +8 -2
- package/dist/src/commands/explore-command.js +359 -43
- package/dist/src/explorbot.js +19 -5
- package/dist/src/utils/test-plan-markdown.js +8 -1
- package/package.json +1 -1
- package/src/ai/pilot.ts +3 -8
- package/src/ai/researcher/focus.ts +57 -8
- package/src/ai/researcher/sections.ts +7 -3
- package/src/ai/researcher.ts +8 -23
- package/src/ai/tester.ts +8 -2
- package/src/commands/explore-command.ts +362 -42
- package/src/explorbot.ts +21 -7
- package/src/utils/test-plan-markdown.ts +8 -1
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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(
|
|
67
|
-
await this.
|
|
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(
|
|
74
|
-
tag('warning').log(`Sub-page
|
|
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.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/explorbot.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { KnowledgeTracker } from './knowledge-tracker.ts';
|
|
|
27
27
|
import { WebPageState } from './state-manager.ts';
|
|
28
28
|
import type { Suite } from './suite.ts';
|
|
29
29
|
import { Plan, type Test } from './test-plan.ts';
|
|
30
|
+
import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
|
|
30
31
|
import { setVerboseMode, tag } from './utils/logger.ts';
|
|
31
32
|
import { relativeToCwd } from './utils/next-steps.ts';
|
|
32
33
|
import { sanitizeFilename } from './utils/strings.ts';
|
|
@@ -349,7 +350,7 @@ export class ExplorBot {
|
|
|
349
350
|
this.agents.planner = undefined;
|
|
350
351
|
}
|
|
351
352
|
|
|
352
|
-
async plan(feature?: string, opts: { fresh?: boolean; style?: string; extend?: Plan; completedPlans?: Plan[] } = {}) {
|
|
353
|
+
async plan(feature?: string, opts: { fresh?: boolean; style?: string; extend?: Plan; completedPlans?: Plan[]; noSave?: boolean } = {}) {
|
|
353
354
|
this.planFeature = feature;
|
|
354
355
|
|
|
355
356
|
if (opts.fresh) {
|
|
@@ -379,7 +380,7 @@ export class ExplorBot {
|
|
|
379
380
|
return this.currentPlan;
|
|
380
381
|
}
|
|
381
382
|
|
|
382
|
-
this.savePlan();
|
|
383
|
+
if (!opts.noSave) this.savePlan();
|
|
383
384
|
|
|
384
385
|
return this.currentPlan;
|
|
385
386
|
}
|
|
@@ -409,19 +410,20 @@ export class ExplorBot {
|
|
|
409
410
|
return planPath;
|
|
410
411
|
}
|
|
411
412
|
|
|
412
|
-
generatePlanFilename(): string {
|
|
413
|
+
generatePlanFilename(feature?: string): string {
|
|
413
414
|
const state = this.explorer?.getStateManager().getCurrentState();
|
|
414
415
|
const urlPath = state?.url || '/';
|
|
415
416
|
const urlPart = sanitizeFilename(urlPath) || 'root';
|
|
416
417
|
const suffix = '.md';
|
|
417
|
-
|
|
418
|
-
|
|
418
|
+
const f = feature ?? this.planFeature;
|
|
419
|
+
if (!f) return urlPart.slice(0, 256 - suffix.length) + suffix;
|
|
420
|
+
const featurePart = `_${sanitizeFilename(f)}`;
|
|
419
421
|
const maxFeatureLen = 256 - suffix.length - urlPart.length;
|
|
420
422
|
if (maxFeatureLen <= 1) return urlPart.slice(0, 256 - suffix.length) + suffix;
|
|
421
423
|
return urlPart + featurePart.slice(0, maxFeatureLen) + suffix;
|
|
422
424
|
}
|
|
423
425
|
|
|
424
|
-
|
|
426
|
+
resolvePlanPath(filename: string): string {
|
|
425
427
|
let planPath = filename;
|
|
426
428
|
|
|
427
429
|
if (path.isAbsolute(filename)) {
|
|
@@ -438,14 +440,26 @@ export class ExplorBot {
|
|
|
438
440
|
}
|
|
439
441
|
}
|
|
440
442
|
|
|
443
|
+
return planPath;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
loadPlan(filename: string): Plan {
|
|
447
|
+
const planPath = this.resolvePlanPath(filename);
|
|
441
448
|
if (!existsSync(planPath)) {
|
|
442
449
|
throw new Error(`Plan file not found: ${planPath}`);
|
|
443
450
|
}
|
|
444
|
-
|
|
445
451
|
this.setCurrentPlan(Plan.fromMarkdown(planPath));
|
|
446
452
|
return this.currentPlan!;
|
|
447
453
|
}
|
|
448
454
|
|
|
455
|
+
loadPlans(filename: string): Plan[] {
|
|
456
|
+
const planPath = this.resolvePlanPath(filename);
|
|
457
|
+
if (!existsSync(planPath)) {
|
|
458
|
+
throw new Error(`Plan file not found: ${planPath}`);
|
|
459
|
+
}
|
|
460
|
+
return parsePlansFromMarkdown(planPath);
|
|
461
|
+
}
|
|
462
|
+
|
|
449
463
|
setCurrentPlan(plan?: Plan): void {
|
|
450
464
|
this.currentPlan = plan;
|
|
451
465
|
if (plan && !this.sessionPlans.includes(plan)) {
|
|
@@ -149,8 +149,15 @@ export function parsePlansFromMarkdown(filePath: string): Plan[] {
|
|
|
149
149
|
|
|
150
150
|
if (line.startsWith('<!-- test')) {
|
|
151
151
|
currentTest = null;
|
|
152
|
-
|
|
152
|
+
let block = line;
|
|
153
|
+
let j = i;
|
|
154
|
+
while (!block.includes('-->') && j + 1 < lines.length) {
|
|
155
|
+
j++;
|
|
156
|
+
block += `\n${lines[j].trim()}`;
|
|
157
|
+
}
|
|
158
|
+
const priorityMatch = block.match(/priority:\s*(\w+)/);
|
|
153
159
|
priority = (priorityMatch?.[1] as 'critical' | 'important' | 'high' | 'normal' | 'low') || 'normal';
|
|
160
|
+
i = j;
|
|
154
161
|
continue;
|
|
155
162
|
}
|
|
156
163
|
|