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.
- package/bin/explorbot-cli.ts +14 -1
- package/boat/doc-collector/bin/doc-collector-cli.ts +5 -0
- package/boat/doc-collector/package.json +24 -0
- package/boat/doc-collector/src/ai/documentarian.ts +184 -0
- package/boat/doc-collector/src/cli.ts +119 -0
- package/boat/doc-collector/src/config.ts +162 -0
- package/boat/doc-collector/src/docbot.ts +391 -0
- package/boat/doc-collector/src/docs-renderer.ts +187 -0
- package/boat/doc-collector/src/path-filter.ts +46 -0
- package/boat/doc-collector/src/research-navigation.ts +90 -0
- package/dist/bin/explorbot-cli.js +15 -1
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +4 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +157 -0
- package/dist/boat/doc-collector/src/cli.js +104 -0
- package/dist/boat/doc-collector/src/config.js +129 -0
- package/dist/boat/doc-collector/src/docbot.js +326 -0
- package/dist/boat/doc-collector/src/docs-renderer.js +141 -0
- package/dist/boat/doc-collector/src/path-filter.js +35 -0
- package/dist/boat/doc-collector/src/research-navigation.js +71 -0
- package/dist/package.json +4 -1
- package/dist/src/ai/pilot.js +3 -8
- package/dist/src/ai/researcher/coordinates.js +1 -1
- package/dist/src/ai/researcher/parser.js +3 -0
- package/dist/src/ai/researcher.js +2 -1
- package/dist/src/ai/tester.js +1 -0
- package/dist/src/commands/explore-command.js +359 -43
- package/dist/src/config.js +10 -3
- package/dist/src/explorbot.js +19 -5
- package/dist/src/explorer.js +14 -1
- package/dist/src/state-manager.js +3 -0
- package/dist/src/utils/test-plan-markdown.js +8 -1
- package/dist/src/utils/url-matcher.js +5 -3
- package/dist/src/utils/web-element.js +3 -2
- package/package.json +4 -1
- package/src/ai/pilot.ts +3 -8
- package/src/ai/researcher/coordinates.ts +1 -1
- package/src/ai/researcher/parser.ts +3 -0
- package/src/ai/researcher.ts +2 -1
- package/src/ai/tester.ts +1 -0
- package/src/commands/explore-command.ts +362 -42
- package/src/config.ts +13 -3
- package/src/explorbot.ts +22 -7
- package/src/explorer.ts +12 -1
- package/src/state-manager.ts +4 -0
- package/src/utils/test-plan-markdown.ts +8 -1
- package/src/utils/url-matcher.ts +5 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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.
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
package/src/ai/researcher.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|