explorbot 0.5.10 → 0.6.0
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 +494 -482
- package/boat/api-tester/src/ai/curler.ts +1 -1
- package/boat/api-tester/src/cli.ts +4 -4
- package/boat/doc-collector/src/screenshots.ts +1 -1
- package/dist/bin/explorbot-cli.js +455 -444
- package/dist/boat/api-tester/src/ai/curler.js +1 -1
- package/dist/boat/api-tester/src/cli.js +4 -4
- package/dist/models.json +1 -1
- package/dist/package.json +2 -1
- package/dist/rules/navigator/output.md +2 -8
- package/dist/rules/navigator/verification-actions.md +6 -2
- package/dist/rules/planner/ignored-pages.md +1 -0
- package/dist/src/action-result.js +1 -1
- package/dist/src/action.d.ts +0 -1
- package/dist/src/action.js +0 -4
- package/dist/src/ai/captain/idle-mode.js +42 -26
- package/dist/src/ai/captain/mixin.d.ts +2 -0
- package/dist/src/ai/captain/web-mode.js +11 -0
- package/dist/src/ai/captain.d.ts +6 -4
- package/dist/src/ai/captain.js +25 -1
- package/dist/src/ai/driller.js +1 -1
- package/dist/src/ai/experience-compactor.js +2 -2
- package/dist/src/ai/judge.d.ts +7 -0
- package/dist/src/ai/judge.js +8 -1
- package/dist/src/ai/navigator.d.ts +1 -0
- package/dist/src/ai/navigator.js +11 -7
- package/dist/src/ai/planner/subpages.js +1 -1
- package/dist/src/ai/planner.d.ts +12 -5
- package/dist/src/ai/planner.js +138 -86
- package/dist/src/ai/provider.d.ts +1 -1
- package/dist/src/ai/provider.js +16 -5
- package/dist/src/ai/rerunner.js +1 -1
- package/dist/src/ai/researcher/cache.js +14 -6
- package/dist/src/ai/researcher/deep-analysis.d.ts +2 -1
- package/dist/src/ai/researcher/deep-analysis.js +7 -2
- package/dist/src/ai/researcher/fingerprint-worker.js +4 -4
- package/dist/src/ai/researcher.js +44 -24
- package/dist/src/ai/rules.js +6 -7
- package/dist/src/ai/scout.d.ts +12 -2
- package/dist/src/ai/scout.js +103 -8
- package/dist/src/ai/tester.d.ts +9 -5
- package/dist/src/ai/tester.js +22 -42
- package/dist/src/ai/tools.js +10 -8
- package/dist/src/application-spec.d.ts +2 -0
- package/dist/src/application-spec.js +17 -12
- package/dist/src/commands/context-experience-command.js +0 -1
- package/dist/src/commands/context-knowledge-command.js +0 -1
- package/dist/src/commands/explore-command.d.ts +3 -2
- package/dist/src/commands/explore-command.js +35 -23
- package/dist/src/commands/help-json-command.js +3 -1
- package/dist/src/commands/init-command.js +4 -4
- package/dist/src/commands/learn-command.js +0 -2
- package/dist/src/commands/plan-command.d.ts +3 -0
- package/dist/src/commands/plan-command.js +43 -17
- package/dist/src/commands/plan-save-command.js +6 -17
- package/dist/src/commands/plans-command.js +1 -1
- package/dist/src/commands/test-command.js +12 -2
- package/dist/src/components/App.d.ts +1 -2
- package/dist/src/components/App.js +1 -3
- package/dist/src/components/InputPane.js +0 -5
- package/dist/src/config.d.ts +3 -2
- package/dist/src/config.js +2 -12
- package/dist/src/explorbot.d.ts +3 -2
- package/dist/src/explorbot.js +32 -10
- package/dist/src/explorer.js +6 -7
- package/dist/src/knowledge-tracker.d.ts +2 -2
- package/dist/src/knowledge-tracker.js +18 -6
- package/dist/src/playwright-recorder.d.ts +0 -1
- package/dist/src/playwright-recorder.js +0 -2
- package/dist/src/reporter.d.ts +1 -1
- package/dist/src/reporter.js +3 -3
- package/dist/src/test-plan.js +1 -1
- package/dist/src/utils/error-page.d.ts +17 -0
- package/dist/src/utils/error-page.js +53 -2
- package/dist/src/utils/html-diff.d.ts +1 -2
- package/dist/src/utils/html-diff.js +4 -44
- package/dist/src/utils/logger.js +27 -10
- package/dist/src/utils/markdown-terminal.js +1 -2
- package/dist/src/utils/next-steps.js +20 -16
- package/dist/src/utils/page-readiness.d.ts +2 -1
- package/dist/src/utils/page-readiness.js +24 -5
- package/dist/src/utils/test-plan-markdown.d.ts +3 -0
- package/dist/src/utils/test-plan-markdown.js +35 -4
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +5 -0
- package/docs/basics/providers.md +1 -1
- package/docs/contributing/regression-tests.md +19 -4
- package/docs/reference/commands.md +6 -2
- package/docs/reference/configuration.md +1 -1
- package/docs/web-testing/agents.md +8 -0
- package/docs/web-testing/customization.md +2 -0
- package/docs/web-testing/researcher.md +1 -1
- package/models.json +1 -1
- package/package.json +2 -1
- package/rules/navigator/output.md +2 -8
- package/rules/navigator/verification-actions.md +6 -2
- package/rules/planner/ignored-pages.md +1 -0
- package/src/action-result.ts +1 -2
- package/src/action.ts +0 -5
- package/src/ai/captain/idle-mode.ts +38 -26
- package/src/ai/captain/mixin.ts +2 -1
- package/src/ai/captain/web-mode.ts +12 -0
- package/src/ai/captain.ts +25 -7
- package/src/ai/driller.ts +1 -2
- package/src/ai/experience-compactor.ts +2 -2
- package/src/ai/judge.ts +10 -1
- package/src/ai/navigator.ts +12 -7
- package/src/ai/planner/subpages.ts +1 -2
- package/src/ai/planner.ts +140 -92
- package/src/ai/provider.ts +15 -6
- package/src/ai/rerunner.ts +1 -2
- package/src/ai/researcher/cache.ts +16 -7
- package/src/ai/researcher/deep-analysis.ts +7 -2
- package/src/ai/researcher/fingerprint-worker.ts +5 -5
- package/src/ai/researcher.ts +44 -28
- package/src/ai/rules.ts +6 -7
- package/src/ai/scout.ts +98 -10
- package/src/ai/tester.ts +29 -47
- package/src/ai/tools.ts +11 -9
- package/src/application-spec.ts +19 -13
- package/src/commands/clean-command.ts +1 -1
- package/src/commands/context-command.ts +0 -1
- package/src/commands/context-experience-command.ts +0 -1
- package/src/commands/context-knowledge-command.ts +0 -1
- package/src/commands/explore-command.ts +36 -23
- package/src/commands/help-json-command.ts +8 -6
- package/src/commands/init-command.ts +5 -3
- package/src/commands/learn-command.ts +0 -2
- package/src/commands/plan-command.ts +44 -17
- package/src/commands/plan-save-command.ts +6 -17
- package/src/commands/plans-command.ts +1 -1
- package/src/commands/test-command.ts +11 -2
- package/src/components/App.tsx +2 -6
- package/src/components/InputPane.tsx +0 -5
- package/src/config.ts +4 -16
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +31 -11
- package/src/explorer.ts +6 -7
- package/src/knowledge-tracker.ts +18 -7
- package/src/playwright-recorder.ts +0 -2
- package/src/reporter.ts +3 -4
- package/src/test-plan.ts +1 -2
- package/src/utils/error-page.ts +64 -2
- package/src/utils/html-diff.ts +4 -56
- package/src/utils/html.ts +0 -1
- package/src/utils/logger.ts +30 -12
- package/src/utils/markdown-terminal.ts +1 -2
- package/src/utils/next-steps.ts +19 -17
- package/src/utils/page-readiness.ts +33 -7
- package/src/utils/test-plan-markdown.ts +36 -4
- package/src/utils/url-matcher.ts +6 -0
|
@@ -7,7 +7,6 @@ const InputPane = ({ commandHandler, exitOnEmptyInput = false, onSubmit, onComma
|
|
|
7
7
|
const [cursorPosition, setCursorPosition] = useState(0);
|
|
8
8
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
|
9
9
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
10
|
-
const [autoCompleteTriggered, setAutoCompleteTriggered] = useState(false);
|
|
11
10
|
const inputRef = useRef(inputValue);
|
|
12
11
|
const cursorRef = useRef(cursorPosition);
|
|
13
12
|
const addLog = useCallback((entry) => {
|
|
@@ -137,7 +136,6 @@ const InputPane = ({ commandHandler, exitOnEmptyInput = false, onSubmit, onComma
|
|
|
137
136
|
setShowAutocomplete(false);
|
|
138
137
|
setSelectedIndex(0);
|
|
139
138
|
setCursorPosition(selectedCommand.length);
|
|
140
|
-
setAutoCompleteTriggered(true);
|
|
141
139
|
}
|
|
142
140
|
return;
|
|
143
141
|
}
|
|
@@ -152,7 +150,6 @@ const InputPane = ({ commandHandler, exitOnEmptyInput = false, onSubmit, onComma
|
|
|
152
150
|
setInputValue(newValue);
|
|
153
151
|
setCursorPosition(nextCursor);
|
|
154
152
|
setSelectedIndex(0);
|
|
155
|
-
setAutoCompleteTriggered(false);
|
|
156
153
|
setShowAutocomplete(shouldShowAutocomplete(newValue));
|
|
157
154
|
}
|
|
158
155
|
return;
|
|
@@ -167,7 +164,6 @@ const InputPane = ({ commandHandler, exitOnEmptyInput = false, onSubmit, onComma
|
|
|
167
164
|
setInputValue(newValue);
|
|
168
165
|
setCursorPosition(nextCursor);
|
|
169
166
|
setSelectedIndex(0);
|
|
170
|
-
setAutoCompleteTriggered(false);
|
|
171
167
|
setShowAutocomplete(shouldShowAutocomplete(newValue));
|
|
172
168
|
}
|
|
173
169
|
}, { isActive });
|
|
@@ -199,7 +195,6 @@ const InputPane = ({ commandHandler, exitOnEmptyInput = false, onSubmit, onComma
|
|
|
199
195
|
setShowAutocomplete(false);
|
|
200
196
|
setSelectedIndex(0);
|
|
201
197
|
setCursorPosition(selectedCommand.length);
|
|
202
|
-
setAutoCompleteTriggered(true);
|
|
203
198
|
}
|
|
204
199
|
}, visible: showAutocomplete })));
|
|
205
200
|
};
|
package/dist/src/config.d.ts
CHANGED
|
@@ -106,6 +106,8 @@ interface PlannerAgentConfig extends AgentConfig {
|
|
|
106
106
|
styles?: string[];
|
|
107
107
|
stylesDir?: string;
|
|
108
108
|
docsWeight?: number;
|
|
109
|
+
minTests?: number;
|
|
110
|
+
maxTests?: number;
|
|
109
111
|
}
|
|
110
112
|
interface ScoutAgentConfig extends AgentConfig {
|
|
111
113
|
dirs?: string[];
|
|
@@ -219,7 +221,7 @@ interface ExplorbotConfig {
|
|
|
219
221
|
knowledge: string;
|
|
220
222
|
experience: string;
|
|
221
223
|
output: string;
|
|
222
|
-
spec?: string;
|
|
224
|
+
spec?: string | string[];
|
|
223
225
|
};
|
|
224
226
|
experience?: {
|
|
225
227
|
maxReadLines?: number;
|
|
@@ -282,7 +284,6 @@ export declare class ConfigParser {
|
|
|
282
284
|
loadConfigModule(configPath: string): Promise<any>;
|
|
283
285
|
resolveConfig(config: ExplorbotConfig): ExplorbotConfig;
|
|
284
286
|
validateConfig(config: ExplorbotConfig): void;
|
|
285
|
-
getNestedValue(obj: any, path: string): any;
|
|
286
287
|
mergeWithDefaults(config: Partial<ExplorbotConfig>): ExplorbotConfig;
|
|
287
288
|
ensureDirectory(path: string): void;
|
|
288
289
|
}
|
package/dist/src/config.js
CHANGED
|
@@ -29,15 +29,6 @@ export const PROVIDERS = {
|
|
|
29
29
|
export const MODEL_ROLES = ['model', 'visionModel', 'agenticModel'];
|
|
30
30
|
let cachedOutputRoot = null;
|
|
31
31
|
let runOutputDir = null;
|
|
32
|
-
const config = {
|
|
33
|
-
playwright: {
|
|
34
|
-
browser: 'chromium',
|
|
35
|
-
url: 'http://localhost:3000',
|
|
36
|
-
},
|
|
37
|
-
ai: {
|
|
38
|
-
model: null,
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
32
|
export { EXPLORBOT_CONFIG_PATHS };
|
|
42
33
|
export const EXPLORBOT_ENV_VARS = [
|
|
43
34
|
{ name: 'EXPLORBOT_AI_PROVIDER', required: true, description: 'Provider name; fills every model role from its recommended models. Turns on config-free mode' },
|
|
@@ -455,9 +446,6 @@ export class ConfigParser {
|
|
|
455
446
|
throw new Error(`Invalid URL in configuration: ${url}`);
|
|
456
447
|
}
|
|
457
448
|
}
|
|
458
|
-
getNestedValue(obj, path) {
|
|
459
|
-
return path.split('.').reduce((current, key) => current?.[key], obj);
|
|
460
|
-
}
|
|
461
449
|
mergeWithDefaults(config) {
|
|
462
450
|
const defaults = {
|
|
463
451
|
playwright: {
|
|
@@ -560,6 +548,8 @@ export function configuredModels(ai) {
|
|
|
560
548
|
models.agenticModel = describe(ai.agenticModel);
|
|
561
549
|
if (ai.visionModel)
|
|
562
550
|
models.visionModel = describe(ai.visionModel);
|
|
551
|
+
if (ai.decisionModel)
|
|
552
|
+
models.decisionModel = { name: ai.decisionModel.model, provider: ai.decisionModel.provider };
|
|
563
553
|
for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
|
|
564
554
|
if (agentConfig?.model)
|
|
565
555
|
models[agent] = describe(agentConfig.model);
|
package/dist/src/explorbot.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ export interface ExplorBotOptions {
|
|
|
41
41
|
optionalAi?: boolean;
|
|
42
42
|
reporter?: ReporterConfig;
|
|
43
43
|
attachedBrowser?: Browser;
|
|
44
|
-
applicationSpec?: string;
|
|
44
|
+
applicationSpec?: string | string[];
|
|
45
45
|
}
|
|
46
46
|
export type UserResolveFunction = (error?: Error, showWelcome?: boolean) => Promise<string | null>;
|
|
47
47
|
export declare class ExplorBot {
|
|
@@ -113,8 +113,9 @@ export declare class ExplorBot {
|
|
|
113
113
|
fresh?: boolean;
|
|
114
114
|
style?: string;
|
|
115
115
|
extend?: Plan;
|
|
116
|
-
completedPlans?: Plan[];
|
|
117
116
|
noSave?: boolean;
|
|
117
|
+
minTests?: number;
|
|
118
|
+
maxTests?: number;
|
|
118
119
|
}): Promise<Plan>;
|
|
119
120
|
getPlansDir(): string;
|
|
120
121
|
savePlan(filename?: string): string | null;
|
package/dist/src/explorbot.js
CHANGED
|
@@ -126,7 +126,14 @@ export class ExplorBot {
|
|
|
126
126
|
await this.visit(this.options.from || '/');
|
|
127
127
|
}
|
|
128
128
|
async visit(url) {
|
|
129
|
-
|
|
129
|
+
const target = this.configParser.resolveTargetPath(url);
|
|
130
|
+
await this.agentNavigator()
|
|
131
|
+
.visit(target)
|
|
132
|
+
.catch(async (err) => {
|
|
133
|
+
if (await this.agentCaptain().resolveBlocker(err, () => this.agentNavigator().isOn(target)))
|
|
134
|
+
return;
|
|
135
|
+
throw err;
|
|
136
|
+
});
|
|
130
137
|
}
|
|
131
138
|
async openTab() {
|
|
132
139
|
await this.explorer.openTab();
|
|
@@ -226,7 +233,7 @@ export class ExplorBot {
|
|
|
226
233
|
if (this.isHistorianEnabled())
|
|
227
234
|
this.agents.tester.setHistorian(this.agentHistorian());
|
|
228
235
|
this.agents.tester.setPilot(this.agentPilot());
|
|
229
|
-
this.agents.tester.
|
|
236
|
+
this.agents.tester.setSupervisor((input, test) => this.agentCaptain().processSupervisorInterrupt(input, test));
|
|
230
237
|
const fisherman = this.agentFisherman();
|
|
231
238
|
if (fisherman)
|
|
232
239
|
this.agentPilot().setFisherman(fisherman);
|
|
@@ -320,9 +327,14 @@ export class ExplorBot {
|
|
|
320
327
|
}
|
|
321
328
|
agentScout() {
|
|
322
329
|
const scoutConfig = this.config.ai?.agents?.scout;
|
|
323
|
-
if (scoutConfig?.enabled !== true)
|
|
324
|
-
return null;
|
|
325
330
|
const dirs = this.scoutCorpusDirs();
|
|
331
|
+
const hasSpec = dirs.length > 0;
|
|
332
|
+
const explicitlyDisabled = scoutConfig?.enabled === false;
|
|
333
|
+
const explicitlyEnabled = scoutConfig?.enabled === true;
|
|
334
|
+
if (explicitlyDisabled)
|
|
335
|
+
return null;
|
|
336
|
+
if (!explicitlyEnabled && !hasSpec)
|
|
337
|
+
return null;
|
|
326
338
|
if (dirs.length === 0) {
|
|
327
339
|
tag('warning').log('Scout enabled but no documentation found — set --spec or ai.agents.scout.dirs');
|
|
328
340
|
return null;
|
|
@@ -331,12 +343,11 @@ export class ExplorBot {
|
|
|
331
343
|
}
|
|
332
344
|
scoutCorpusDirs() {
|
|
333
345
|
const dirs = [];
|
|
334
|
-
const
|
|
335
|
-
|
|
346
|
+
const specPaths = normalizeSpecPaths(this.options.applicationSpec || this.config.dirs?.spec);
|
|
347
|
+
for (const specPath of specPaths) {
|
|
336
348
|
const bundle = resolveSpecBundlePath(specPath);
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
dirs.push(pagesDir);
|
|
349
|
+
if (bundle && existsSync(bundle))
|
|
350
|
+
dirs.push(bundle);
|
|
340
351
|
}
|
|
341
352
|
for (const dir of this.config.ai?.agents?.scout?.dirs || []) {
|
|
342
353
|
const resolved = this.configParser.resolveProjectDir(dir);
|
|
@@ -372,14 +383,20 @@ export class ExplorBot {
|
|
|
372
383
|
}
|
|
373
384
|
const planner = this.agentPlanner();
|
|
374
385
|
planner.freshStart = !!opts.fresh;
|
|
386
|
+
if (opts.minTests !== undefined || opts.maxTests !== undefined) {
|
|
387
|
+
planner.setTestCount({ minTests: opts.minTests, maxTests: opts.maxTests });
|
|
388
|
+
}
|
|
375
389
|
if (this.currentPlan) {
|
|
376
390
|
planner.setPlan(this.currentPlan);
|
|
377
391
|
}
|
|
378
392
|
this.lastPlanError = null;
|
|
393
|
+
const before = new Set(this.currentPlan?.tests ?? []);
|
|
379
394
|
try {
|
|
380
|
-
this.setCurrentPlan(await planner.plan(feature, opts.style, opts.extend
|
|
395
|
+
this.setCurrentPlan(await planner.plan(feature, opts.style, opts.extend));
|
|
381
396
|
}
|
|
382
397
|
catch (err) {
|
|
398
|
+
if (await this.agentCaptain().resolveBlocker(err, () => !!this.currentPlan?.tests.some((t) => !before.has(t))))
|
|
399
|
+
return this.currentPlan;
|
|
383
400
|
this.lastPlanError = err instanceof Error ? err : new Error(String(err));
|
|
384
401
|
tag('warning').log(`Planning failed: ${this.lastPlanError.message}`);
|
|
385
402
|
if (!this.currentPlan)
|
|
@@ -504,3 +521,8 @@ export class ExplorBot {
|
|
|
504
521
|
}
|
|
505
522
|
}
|
|
506
523
|
}
|
|
524
|
+
function normalizeSpecPaths(spec) {
|
|
525
|
+
if (!spec)
|
|
526
|
+
return [];
|
|
527
|
+
return Array.isArray(spec) ? spec : [spec];
|
|
528
|
+
}
|
package/dist/src/explorer.js
CHANGED
|
@@ -148,7 +148,6 @@ class Explorer {
|
|
|
148
148
|
if (!(await this.recoverOrRestart())) {
|
|
149
149
|
return { ok: false, action: 'stop', message: 'Browser page could not be recovered' };
|
|
150
150
|
}
|
|
151
|
-
this.watchActiveTestPage();
|
|
152
151
|
return { ok: true, action: 'continue', recovered: true, message: 'Browser page was recovered' };
|
|
153
152
|
}
|
|
154
153
|
async beginTest(test) {
|
|
@@ -157,7 +156,7 @@ class Explorer {
|
|
|
157
156
|
}
|
|
158
157
|
this._activeTest = test;
|
|
159
158
|
test.start();
|
|
160
|
-
await this.reporter.reportTestStart(
|
|
159
|
+
await this.reporter.reportTestStart();
|
|
161
160
|
await this.closeOtherTabs();
|
|
162
161
|
this.stateManager.otherTabs = [];
|
|
163
162
|
const codeceptjsTest = toCodeceptjsTest(test);
|
|
@@ -340,7 +339,7 @@ class Explorer {
|
|
|
340
339
|
}
|
|
341
340
|
async runWithRecovery(label, operation) {
|
|
342
341
|
if (!this.page && !(await this.recoverOrRestart())) {
|
|
343
|
-
throw new Error(
|
|
342
|
+
throw new BrowserRecoveryError(label, new Error('Browser page is unavailable'), false);
|
|
344
343
|
}
|
|
345
344
|
try {
|
|
346
345
|
return await operation();
|
|
@@ -432,7 +431,6 @@ class Explorer {
|
|
|
432
431
|
if (!(await this.recoverOrRestart())) {
|
|
433
432
|
return { ok: false, action: 'stop', recovered: false, message: `Browser could not be recovered after fatal error: ${message}` };
|
|
434
433
|
}
|
|
435
|
-
this.watchActiveTestPage();
|
|
436
434
|
return {
|
|
437
435
|
ok: true,
|
|
438
436
|
action: 'continue',
|
|
@@ -446,9 +444,10 @@ class Explorer {
|
|
|
446
444
|
};
|
|
447
445
|
}
|
|
448
446
|
async recoverOrRestart() {
|
|
449
|
-
if (await this.recoverPage())
|
|
450
|
-
return
|
|
451
|
-
|
|
447
|
+
if (!(await this.recoverPage()) && !(await this.restartBrowser()))
|
|
448
|
+
return false;
|
|
449
|
+
this.watchActiveTestPage();
|
|
450
|
+
return true;
|
|
452
451
|
}
|
|
453
452
|
async recoverPage() {
|
|
454
453
|
try {
|
|
@@ -5,7 +5,7 @@ export declare class KnowledgeTracker {
|
|
|
5
5
|
knowledgeFiles: Knowledge[];
|
|
6
6
|
sessionKnowledge: Knowledge[];
|
|
7
7
|
isLoaded: boolean;
|
|
8
|
-
|
|
8
|
+
applicationSpecs: ApplicationSpec[];
|
|
9
9
|
static appendSessionKnowledge(text: string): void;
|
|
10
10
|
static resetSessionKnowledge(): void;
|
|
11
11
|
constructor(options?: KnowledgeTrackerOptions);
|
|
@@ -48,6 +48,6 @@ export interface Knowledge {
|
|
|
48
48
|
[key: string]: any;
|
|
49
49
|
}
|
|
50
50
|
export interface KnowledgeTrackerOptions {
|
|
51
|
-
applicationSpec?: string;
|
|
51
|
+
applicationSpec?: string | string[];
|
|
52
52
|
knowledgeDir?: string;
|
|
53
53
|
}
|
|
@@ -19,7 +19,7 @@ export class KnowledgeTracker {
|
|
|
19
19
|
knowledgeFiles = [];
|
|
20
20
|
sessionKnowledge = [];
|
|
21
21
|
isLoaded = false;
|
|
22
|
-
|
|
22
|
+
applicationSpecs = [];
|
|
23
23
|
static appendSessionKnowledge(text) {
|
|
24
24
|
sessionEntries.push(text);
|
|
25
25
|
}
|
|
@@ -39,9 +39,15 @@ export class KnowledgeTracker {
|
|
|
39
39
|
if (!existsSync(this.knowledgeDir)) {
|
|
40
40
|
mkdirSync(this.knowledgeDir, { recursive: true });
|
|
41
41
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
for (const p of normalizeSpecPaths(specPath)) {
|
|
43
|
+
const spec = new ApplicationSpec(p);
|
|
44
|
+
this.applicationSpecs.push(spec);
|
|
45
|
+
if (spec.pageCount > 0) {
|
|
46
|
+
tag('info').log(`Loaded application spec with ${spec.pageCount} documented pages`);
|
|
47
|
+
}
|
|
48
|
+
else if (spec.markdownFileCount > 0) {
|
|
49
|
+
tag('info').log(`Loaded spec corpus with ${spec.markdownFileCount} markdown files`);
|
|
50
|
+
}
|
|
45
51
|
}
|
|
46
52
|
this.sessionKnowledge = sessionEntries.map((entry, index) => {
|
|
47
53
|
const parsed = matter(entry);
|
|
@@ -77,10 +83,11 @@ export class KnowledgeTracker {
|
|
|
77
83
|
return [this.renderRelevantKnowledge(state), this.renderApplicationSpec(state)].filter(Boolean).join('\n\n');
|
|
78
84
|
}
|
|
79
85
|
renderApplicationSpec(state) {
|
|
80
|
-
|
|
86
|
+
const parts = this.applicationSpecs.map((spec) => spec.renderFor(state)).filter(Boolean);
|
|
87
|
+
return parts.join('\n\n');
|
|
81
88
|
}
|
|
82
89
|
applicationSpecUrls(state) {
|
|
83
|
-
return this.
|
|
90
|
+
return this.applicationSpecs.flatMap((spec) => spec.matchedUrls(state));
|
|
84
91
|
}
|
|
85
92
|
addKnowledge(urlPattern, description, opts) {
|
|
86
93
|
const configParser = ConfigParser.getInstance();
|
|
@@ -236,3 +243,8 @@ export class KnowledgeTracker {
|
|
|
236
243
|
`;
|
|
237
244
|
}
|
|
238
245
|
}
|
|
246
|
+
function normalizeSpecPaths(spec) {
|
|
247
|
+
if (!spec)
|
|
248
|
+
return [];
|
|
249
|
+
return Array.isArray(spec) ? spec : [spec];
|
|
250
|
+
}
|
|
@@ -23,7 +23,6 @@ function getAsLocator() {
|
|
|
23
23
|
return cachedAsLocator;
|
|
24
24
|
}
|
|
25
25
|
export class PlaywrightRecorder {
|
|
26
|
-
context = null;
|
|
27
26
|
tracing = null;
|
|
28
27
|
active = false;
|
|
29
28
|
nextGroupId = 0;
|
|
@@ -52,7 +51,6 @@ export class PlaywrightRecorder {
|
|
|
52
51
|
debugLog('start: no tracing on browserContext, recorder inactive');
|
|
53
52
|
return;
|
|
54
53
|
}
|
|
55
|
-
this.context = browserContext;
|
|
56
54
|
this.tracing = browserContext.tracing;
|
|
57
55
|
try {
|
|
58
56
|
await this.tracing.start({});
|
package/dist/src/reporter.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export declare class Reporter {
|
|
|
22
22
|
configureMarkdownPipe(): void;
|
|
23
23
|
configureRunGroup(runGroup: string | null | undefined): void;
|
|
24
24
|
startRun(): Promise<void>;
|
|
25
|
-
reportTestStart(
|
|
25
|
+
reportTestStart(): Promise<void>;
|
|
26
26
|
combineStepsAndNotes(test: Test, lastScreenshotFile?: string): Step[];
|
|
27
27
|
buildVerificationStep(test: Test, lastScreenshotFile?: string): Step | undefined;
|
|
28
28
|
reportTest(test: Test, meta?: ReporterMeta): Promise<void>;
|
package/dist/src/reporter.js
CHANGED
|
@@ -106,13 +106,13 @@ export class Reporter {
|
|
|
106
106
|
process.env.TESTOMATIO_STACK_PASSED = 'true';
|
|
107
107
|
process.env.TESTOMATIO_STEPS_PASSED = 'true';
|
|
108
108
|
}
|
|
109
|
-
async reportTestStart(
|
|
109
|
+
async reportTestStart() {
|
|
110
110
|
await this.startRun();
|
|
111
111
|
}
|
|
112
112
|
combineStepsAndNotes(test, lastScreenshotFile) {
|
|
113
113
|
const noteEntries = Object.entries(test.notes)
|
|
114
114
|
.filter(([, note]) => !note.observation)
|
|
115
|
-
.map(([
|
|
115
|
+
.map(([, note]) => ({
|
|
116
116
|
startTime: note.startTime,
|
|
117
117
|
endTime: note.endTime,
|
|
118
118
|
message: note.message,
|
|
@@ -122,7 +122,7 @@ export class Reporter {
|
|
|
122
122
|
}))
|
|
123
123
|
.sort((a, b) => a.startTime - b.startTime);
|
|
124
124
|
const stepEntries = Object.entries(test.steps)
|
|
125
|
-
.map(([
|
|
125
|
+
.map(([, stepData]) => ({
|
|
126
126
|
noteStartTime: stepData.noteStartTime,
|
|
127
127
|
text: stepData.text,
|
|
128
128
|
duration: stepData.duration,
|
package/dist/src/test-plan.js
CHANGED
|
@@ -461,7 +461,7 @@ export class Plan {
|
|
|
461
461
|
if (!plansDir)
|
|
462
462
|
dirs.push(...listSitePlanDirs());
|
|
463
463
|
for (const dir of dirs) {
|
|
464
|
-
const filePath = names.map((name) => path.resolve(dir, name)).find(existsSync);
|
|
464
|
+
const filePath = names.map((name) => path.resolve(dir, name)).find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
|
|
465
465
|
if (filePath)
|
|
466
466
|
return filePath;
|
|
467
467
|
}
|
|
@@ -11,3 +11,20 @@ export declare class ErrorPageError extends Error {
|
|
|
11
11
|
readonly httpStatus?: number;
|
|
12
12
|
constructor(url: string, title?: string, httpStatus?: number);
|
|
13
13
|
}
|
|
14
|
+
export declare class BlockedError extends Error {
|
|
15
|
+
readonly url: string;
|
|
16
|
+
readonly blocker: string;
|
|
17
|
+
readonly request: string;
|
|
18
|
+
constructor(message: string, url: string, blocker: string, request: string);
|
|
19
|
+
}
|
|
20
|
+
export declare class PlanningBlockedError extends BlockedError {
|
|
21
|
+
readonly feature?: string;
|
|
22
|
+
readonly style?: string;
|
|
23
|
+
constructor(url: string, prerequisite: string, feature?: string, style?: string);
|
|
24
|
+
}
|
|
25
|
+
export declare class NavigationBlockedError extends BlockedError {
|
|
26
|
+
constructor(url: string, currentUrl: string, reason: string, tried: string[]);
|
|
27
|
+
}
|
|
28
|
+
export declare class TestStartBlockedError extends BlockedError {
|
|
29
|
+
constructor(scenario: string, url: string, currentUrl: string);
|
|
30
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import dedent from 'dedent';
|
|
1
2
|
import { ActionResult } from '../action-result.js';
|
|
2
|
-
import { isBodyEmpty } from './html.js';
|
|
3
|
+
import { isBodyEmpty, sanitizeHtmlString } from './html.js';
|
|
3
4
|
import { extractStatePath } from './url-matcher.js';
|
|
4
5
|
const HTTP_ERRORS = ['400 Bad Request', '401 Unauthorized', '403 Forbidden', '404 Not Found', '405 Method Not Allowed', '408 Request Timeout', '500 Internal Server Error', '502 Bad Gateway', '503 Service Unavailable', '504 Gateway Timeout'];
|
|
5
6
|
const SMALL_PAGE_THRESHOLD = 500;
|
|
@@ -27,7 +28,7 @@ export function detectPageCondition(actionResult) {
|
|
|
27
28
|
}
|
|
28
29
|
if (!actionResult.html || isBodyEmpty(actionResult.html))
|
|
29
30
|
return 'loading';
|
|
30
|
-
const bodyMatch = actionResult.html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
31
|
+
const bodyMatch = sanitizeHtmlString(actionResult.html).match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
31
32
|
if (bodyMatch && bodyMatch[1].trim().length < SMALL_PAGE_THRESHOLD)
|
|
32
33
|
return 'loading';
|
|
33
34
|
return 'ok';
|
|
@@ -62,3 +63,53 @@ export class ErrorPageError extends Error {
|
|
|
62
63
|
this.name = 'ErrorPageError';
|
|
63
64
|
}
|
|
64
65
|
}
|
|
66
|
+
export class BlockedError extends Error {
|
|
67
|
+
url;
|
|
68
|
+
blocker;
|
|
69
|
+
request;
|
|
70
|
+
constructor(message, url, blocker, request) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.url = url;
|
|
73
|
+
this.blocker = blocker;
|
|
74
|
+
this.request = request;
|
|
75
|
+
this.name = 'BlockedError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export class PlanningBlockedError extends BlockedError {
|
|
79
|
+
feature;
|
|
80
|
+
style;
|
|
81
|
+
constructor(url, prerequisite, feature, style) {
|
|
82
|
+
let focus = '';
|
|
83
|
+
if (feature)
|
|
84
|
+
focus = ` focused on "${feature}"`;
|
|
85
|
+
let styleNote = '';
|
|
86
|
+
if (style)
|
|
87
|
+
styleNote = ` in ${style} style`;
|
|
88
|
+
super(`Planner could not plan ${url}: ${prerequisite}`, url, prerequisite, dedent `
|
|
89
|
+
Planner was asked to plan tests for ${url}${focus}${styleNote} but stopped: ${prerequisite}.
|
|
90
|
+
Look at the current page to find out what blocks planning, resolve it, then call plan() to re-plan this page. If it cannot be resolved, finish without planning.
|
|
91
|
+
`);
|
|
92
|
+
this.feature = feature;
|
|
93
|
+
this.style = style;
|
|
94
|
+
this.name = 'PlanningBlockedError';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export class NavigationBlockedError extends BlockedError {
|
|
98
|
+
constructor(url, currentUrl, reason, tried) {
|
|
99
|
+
const lines = [`Navigator was asked to open ${url} but stopped on ${currentUrl}: ${reason}.`];
|
|
100
|
+
if (tried.length)
|
|
101
|
+
lines.push(`Already tried: ${tried.join('; ')}.`);
|
|
102
|
+
lines.push(`Look at the current page to find out what keeps it from ${url}, resolve it, then open ${url}. If it cannot be resolved, finish without opening it.`);
|
|
103
|
+
super(`Navigation to ${url} failed: ${reason}`, url, reason, lines.join('\n'));
|
|
104
|
+
this.name = 'NavigationBlockedError';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export class TestStartBlockedError extends BlockedError {
|
|
108
|
+
constructor(scenario, url, currentUrl) {
|
|
109
|
+
super(`Test "${scenario}" could not start: ${currentUrl} is not the page it starts on`, url, 'not the start page of this test', dedent `
|
|
110
|
+
Tester was asked to start the test "${scenario}" at ${url}, but the page open at ${currentUrl} is not where this test can start.
|
|
111
|
+
Look at the current page to find out what keeps the test from starting, resolve it, then open ${url}. When you finish, the test runs again from ${url}. If it cannot be resolved, finish and the test is skipped.
|
|
112
|
+
`);
|
|
113
|
+
this.name = 'TestStartBlockedError';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { HtmlConfig } from '../config.js';
|
|
2
1
|
export interface HtmlDiffPart {
|
|
3
2
|
container: string;
|
|
4
3
|
subtree: string;
|
|
@@ -21,7 +20,7 @@ export declare function computeHtmlFingerprint(html: string): string[];
|
|
|
21
20
|
/**
|
|
22
21
|
* Compares two HTML documents and returns differences along with a diff subtree.
|
|
23
22
|
*/
|
|
24
|
-
export declare function htmlDiff(originalHtml: string, modifiedHtml: string
|
|
23
|
+
export declare function htmlDiff(originalHtml: string, modifiedHtml: string): Promise<HtmlDiffResult>;
|
|
25
24
|
/**
|
|
26
25
|
* Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
|
|
27
26
|
*/
|
|
@@ -138,9 +138,9 @@ export function computeHtmlFingerprint(html) {
|
|
|
138
138
|
/**
|
|
139
139
|
* Compares two HTML documents and returns differences along with a diff subtree.
|
|
140
140
|
*/
|
|
141
|
-
export async function htmlDiff(originalHtml, modifiedHtml
|
|
142
|
-
const originalDocument = parseDocument(originalHtml
|
|
143
|
-
const modifiedDocument = parseDocument(modifiedHtml
|
|
141
|
+
export async function htmlDiff(originalHtml, modifiedHtml) {
|
|
142
|
+
const originalDocument = parseDocument(originalHtml);
|
|
143
|
+
const modifiedDocument = parseDocument(modifiedHtml);
|
|
144
144
|
const originalRoot = getRootNodeForFlatten(originalDocument);
|
|
145
145
|
const modifiedRoot = getRootNodeForFlatten(modifiedDocument);
|
|
146
146
|
const originalLines = flattenHtml(originalRoot);
|
|
@@ -229,7 +229,7 @@ function isLiveRegion(element) {
|
|
|
229
229
|
* Parse HTML into a document, wrapping fragments with html/body for consistency.
|
|
230
230
|
* Uses custom sanitization that removes iframes for diff purposes.
|
|
231
231
|
*/
|
|
232
|
-
function parseDocument(html
|
|
232
|
+
function parseDocument(html) {
|
|
233
233
|
const document = parse(html);
|
|
234
234
|
const documentTitle = getDocumentTitle(document);
|
|
235
235
|
sanitizeDocumentTreeForDiff(document);
|
|
@@ -336,34 +336,6 @@ const attributesDiffer = (current, previous) => {
|
|
|
336
336
|
}
|
|
337
337
|
return false;
|
|
338
338
|
};
|
|
339
|
-
const getDirectTextValues = (element) => {
|
|
340
|
-
if (!element.childNodes || element.childNodes.length === 0) {
|
|
341
|
-
return [];
|
|
342
|
-
}
|
|
343
|
-
const values = [];
|
|
344
|
-
for (const child of element.childNodes) {
|
|
345
|
-
if (child.nodeName === '#text') {
|
|
346
|
-
const value = child.value.trim();
|
|
347
|
-
if (value.length > 0) {
|
|
348
|
-
values.push(value);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
return values;
|
|
353
|
-
};
|
|
354
|
-
const directTextDiffer = (current, previous) => {
|
|
355
|
-
const currentText = getDirectTextValues(current);
|
|
356
|
-
const previousText = getDirectTextValues(previous);
|
|
357
|
-
if (currentText.length !== previousText.length) {
|
|
358
|
-
return true;
|
|
359
|
-
}
|
|
360
|
-
for (let i = 0; i < currentText.length; i++) {
|
|
361
|
-
if (currentText[i] !== previousText[i]) {
|
|
362
|
-
return true;
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return false;
|
|
366
|
-
};
|
|
367
339
|
function filterContainerClasses(classes) {
|
|
368
340
|
return classes
|
|
369
341
|
.filter((cls) => !/\d/.test(cls))
|
|
@@ -654,18 +626,6 @@ function findHtmlElement(document) {
|
|
|
654
626
|
}
|
|
655
627
|
return null;
|
|
656
628
|
}
|
|
657
|
-
function findHeadElement(document) {
|
|
658
|
-
const html = findHtmlElement(document);
|
|
659
|
-
if (!html || !html.childNodes) {
|
|
660
|
-
return null;
|
|
661
|
-
}
|
|
662
|
-
for (const node of html.childNodes) {
|
|
663
|
-
if ('tagName' in node && node.tagName && node.tagName.toLowerCase() === 'head') {
|
|
664
|
-
return node;
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
return null;
|
|
668
|
-
}
|
|
669
629
|
function findBodyElement(document) {
|
|
670
630
|
const html = findHtmlElement(document);
|
|
671
631
|
if (!html || !html.childNodes) {
|
package/dist/src/utils/logger.js
CHANGED
|
@@ -5,6 +5,7 @@ import chalk from 'chalk';
|
|
|
5
5
|
import debug from 'debug';
|
|
6
6
|
import dedent from 'dedent';
|
|
7
7
|
import stripAnsi from 'strip-ansi';
|
|
8
|
+
import wrapAnsi from 'wrap-ansi';
|
|
8
9
|
import { ConfigParser } from '../config.js';
|
|
9
10
|
import { Observability } from "../observability.js";
|
|
10
11
|
import { RecentStepFilter } from "./log-filters.js";
|
|
@@ -54,6 +55,10 @@ class ConsoleDestination {
|
|
|
54
55
|
forceEnabled = false;
|
|
55
56
|
quiet = false;
|
|
56
57
|
recentSteps = new RecentStepFilter();
|
|
58
|
+
width;
|
|
59
|
+
constructor() {
|
|
60
|
+
this.width = process.stdout.columns || 80;
|
|
61
|
+
}
|
|
57
62
|
isEnabled() {
|
|
58
63
|
if (this.quiet)
|
|
59
64
|
return false;
|
|
@@ -87,8 +92,7 @@ class ConsoleDestination {
|
|
|
87
92
|
if (entry.type === 'multiline' || entry.type === 'details') {
|
|
88
93
|
const cleaned = stripAnsi(dedent(entry.content));
|
|
89
94
|
const parsed = parseMarkdownToTerminal(cleaned);
|
|
90
|
-
content = parsed
|
|
91
|
-
content = `${chalk.gray(content)}\n`;
|
|
95
|
+
content = `${chalk.dim('\n ┌'.padEnd(this.width, '─'))}\n${this.prefixLines(parsed, ' │ ', chalk.dim, false)}\n`;
|
|
92
96
|
}
|
|
93
97
|
else if (entry.type === 'success') {
|
|
94
98
|
content = `${chalk.green(content)}\n`;
|
|
@@ -100,16 +104,33 @@ class ConsoleDestination {
|
|
|
100
104
|
content = chalk.yellow(content);
|
|
101
105
|
}
|
|
102
106
|
else if (entry.type === 'step') {
|
|
103
|
-
content = chalk.gray
|
|
107
|
+
content = this.prefixLines(content, ' ', chalk.gray);
|
|
104
108
|
}
|
|
105
109
|
else if (entry.type === 'operation') {
|
|
106
|
-
content =
|
|
110
|
+
content = this.prefixLines(content, ' · ', chalk.gray);
|
|
107
111
|
}
|
|
108
112
|
else if (entry.type === 'substep') {
|
|
109
|
-
content =
|
|
113
|
+
content = this.prefixLines(content, ' > ', chalk.gray);
|
|
110
114
|
}
|
|
111
115
|
console.log(content);
|
|
112
116
|
}
|
|
117
|
+
prefixLines(content, prefix, style, strip = true) {
|
|
118
|
+
const prefixWidth = stripAnsi(prefix).length;
|
|
119
|
+
const available = Math.max(20, this.width - prefixWidth);
|
|
120
|
+
const lines = content
|
|
121
|
+
.split('\n')
|
|
122
|
+
.filter((line) => stripAnsi(line).trim() !== '')
|
|
123
|
+
.flatMap((line) => {
|
|
124
|
+
const text = strip ? stripAnsi(line) : line;
|
|
125
|
+
const wrapped = wrapAnsi(text, available, { hard: true });
|
|
126
|
+
return wrapped.split('\n').map((part) => style(`${prefix}${part}`));
|
|
127
|
+
});
|
|
128
|
+
if (lines.length > 10) {
|
|
129
|
+
const remaining = lines.length - 10;
|
|
130
|
+
return [...lines.slice(0, 10), chalk.gray(` │ --- +${remaining} lines ---`)].join('\n');
|
|
131
|
+
}
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
113
134
|
}
|
|
114
135
|
class DebugDestination {
|
|
115
136
|
verboseMode = false;
|
|
@@ -138,13 +159,9 @@ class DebugDestination {
|
|
|
138
159
|
class FileDestination {
|
|
139
160
|
initialized = false;
|
|
140
161
|
logFilePath = null;
|
|
141
|
-
verboseMode = true;
|
|
142
162
|
isEnabled() {
|
|
143
163
|
return true;
|
|
144
164
|
}
|
|
145
|
-
setVerboseMode(enabled) {
|
|
146
|
-
this.verboseMode = enabled;
|
|
147
|
-
}
|
|
148
165
|
write(entry) {
|
|
149
166
|
if (entry.type === 'html')
|
|
150
167
|
return;
|
|
@@ -358,7 +375,7 @@ class Logger {
|
|
|
358
375
|
truncateTagContent(content, tagName) {
|
|
359
376
|
const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
360
377
|
const tagRegex = new RegExp(`<${escapedTag}>([\\s\\S]*?)<\\/${escapedTag}>`, 'g');
|
|
361
|
-
return content.replace(tagRegex, (
|
|
378
|
+
return content.replace(tagRegex, (_, innerContent) => {
|
|
362
379
|
const trimmed = innerContent.trim();
|
|
363
380
|
const totalLength = trimmed.length;
|
|
364
381
|
if (totalLength <= 100) {
|