explorbot 0.5.9 → 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/README.md +1 -1
- package/bin/explorbot-cli.ts +495 -482
- package/boat/api-tester/src/ai/curler.ts +1 -1
- package/boat/api-tester/src/cli.ts +4 -4
- package/boat/api-tester/src/commands/init-command.ts +2 -0
- package/boat/doc-collector/bin/doc-collector-cli.ts +2 -1
- package/boat/doc-collector/src/screenshots.ts +1 -1
- package/boat/prima/bin/prima-cli.ts +2 -1
- package/dist/bin/explorbot-cli.js +456 -444
- package/dist/boat/api-tester/bin/apibot-cli.js +2 -1
- package/dist/boat/api-tester/src/ai/curler.js +1 -1
- package/dist/boat/api-tester/src/cli.js +4 -4
- package/dist/boat/api-tester/src/commands/init-command.js +2 -0
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -1
- package/dist/boat/prima/bin/prima-cli.js +2 -1
- 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 +8 -4
- package/dist/src/commands/learn-command.js +0 -2
- package/dist/src/commands/options/auth-option.d.ts +6 -0
- package/dist/src/commands/options/auth-option.js +14 -0
- package/dist/src/commands/options/index.d.ts +2 -0
- package/dist/src/commands/options/index.js +2 -0
- 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 +3 -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 +18 -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/docs/workflow/agentic-usage.md +7 -0
- package/docs/workflow/knowledge.md +6 -0
- 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 +10 -3
- package/src/commands/learn-command.ts +0 -2
- package/src/commands/options/auth-option.ts +15 -0
- package/src/commands/options/index.ts +2 -0
- 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 +5 -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
package/src/components/App.tsx
CHANGED
|
@@ -3,14 +3,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
3
3
|
import { CommandHandler } from '../command-handler.js';
|
|
4
4
|
import type { ExplorbotConfig } from '../config.js';
|
|
5
5
|
import { executionController } from '../execution-controller.ts';
|
|
6
|
-
import type { ExplorBot
|
|
6
|
+
import type { ExplorBot } from '../explorbot.ts';
|
|
7
7
|
import type { KnowledgeTracker } from '../knowledge-tracker.js';
|
|
8
8
|
import type { StateTransition, WebPageState } from '../state-manager.js';
|
|
9
9
|
import { Test } from '../test-plan.ts';
|
|
10
10
|
import { tag } from '../utils/logger.js';
|
|
11
11
|
import ActivityPane from './ActivityPane.js';
|
|
12
12
|
import Autocomplete from './Autocomplete.js';
|
|
13
|
-
import InputPane from './InputPane.js';
|
|
14
13
|
import InputReadline from './InputReadline.js';
|
|
15
14
|
import LogPane from './LogPane.js';
|
|
16
15
|
import PlanEditor from './PlanEditor.js';
|
|
@@ -23,17 +22,15 @@ import WelcomeCommands from './WelcomeCommands.js';
|
|
|
23
22
|
interface AppProps {
|
|
24
23
|
explorBot: ExplorBot;
|
|
25
24
|
initialShowInput?: boolean;
|
|
26
|
-
exitOnEmptyInput?: boolean;
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
export function App({ explorBot, initialShowInput = false
|
|
27
|
+
export function App({ explorBot, initialShowInput = false }: AppProps) {
|
|
30
28
|
const sessionStartedAtRef = useRef<number>(Date.now());
|
|
31
29
|
const InputComponent = InputReadline;
|
|
32
30
|
const [showSessionTimer, setShowSessionTimer] = useState(false);
|
|
33
31
|
const [showInput, setShowInput] = useState(initialShowInput);
|
|
34
32
|
const [interruptPrompt, setInterruptPrompt] = useState<string | null>(null);
|
|
35
33
|
const [currentState, setCurrentState] = useState<WebPageState | null>(null);
|
|
36
|
-
const [lastTransition, setLastTransition] = useState<StateTransition | null>(null);
|
|
37
34
|
const [tasks, setTasks] = useState<Test[]>([]);
|
|
38
35
|
const tasksRef = useRef<Test[]>([]);
|
|
39
36
|
const [showPlanEditor, setShowPlanEditor] = useState(false);
|
|
@@ -72,7 +69,6 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
|
|
|
72
69
|
|
|
73
70
|
unsubscribe = manager.onStateChange((transition: StateTransition) => {
|
|
74
71
|
if (mounted) {
|
|
75
|
-
setLastTransition(transition);
|
|
76
72
|
setCurrentState(transition.toState);
|
|
77
73
|
}
|
|
78
74
|
});
|
|
@@ -19,7 +19,6 @@ const InputPane: React.FC<InputPaneProps> = ({ commandHandler, exitOnEmptyInput
|
|
|
19
19
|
const [cursorPosition, setCursorPosition] = useState(0);
|
|
20
20
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
|
21
21
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
22
|
-
const [autoCompleteTriggered, setAutoCompleteTriggered] = useState(false);
|
|
23
22
|
const inputRef = useRef(inputValue);
|
|
24
23
|
const cursorRef = useRef(cursorPosition);
|
|
25
24
|
|
|
@@ -166,7 +165,6 @@ const InputPane: React.FC<InputPaneProps> = ({ commandHandler, exitOnEmptyInput
|
|
|
166
165
|
setShowAutocomplete(false);
|
|
167
166
|
setSelectedIndex(0);
|
|
168
167
|
setCursorPosition(selectedCommand.length);
|
|
169
|
-
setAutoCompleteTriggered(true);
|
|
170
168
|
}
|
|
171
169
|
return;
|
|
172
170
|
}
|
|
@@ -182,7 +180,6 @@ const InputPane: React.FC<InputPaneProps> = ({ commandHandler, exitOnEmptyInput
|
|
|
182
180
|
setInputValue(newValue);
|
|
183
181
|
setCursorPosition(nextCursor);
|
|
184
182
|
setSelectedIndex(0);
|
|
185
|
-
setAutoCompleteTriggered(false);
|
|
186
183
|
setShowAutocomplete(shouldShowAutocomplete(newValue));
|
|
187
184
|
}
|
|
188
185
|
return;
|
|
@@ -198,7 +195,6 @@ const InputPane: React.FC<InputPaneProps> = ({ commandHandler, exitOnEmptyInput
|
|
|
198
195
|
setInputValue(newValue);
|
|
199
196
|
setCursorPosition(nextCursor);
|
|
200
197
|
setSelectedIndex(0);
|
|
201
|
-
setAutoCompleteTriggered(false);
|
|
202
198
|
setShowAutocomplete(shouldShowAutocomplete(newValue));
|
|
203
199
|
}
|
|
204
200
|
},
|
|
@@ -247,7 +243,6 @@ const InputPane: React.FC<InputPaneProps> = ({ commandHandler, exitOnEmptyInput
|
|
|
247
243
|
setShowAutocomplete(false);
|
|
248
244
|
setSelectedIndex(0);
|
|
249
245
|
setCursorPosition(selectedCommand.length);
|
|
250
|
-
setAutoCompleteTriggered(true);
|
|
251
246
|
}
|
|
252
247
|
}}
|
|
253
248
|
visible={showAutocomplete}
|
package/src/config.ts
CHANGED
|
@@ -132,6 +132,8 @@ interface PlannerAgentConfig extends AgentConfig {
|
|
|
132
132
|
styles?: string[];
|
|
133
133
|
stylesDir?: string;
|
|
134
134
|
docsWeight?: number;
|
|
135
|
+
minTests?: number;
|
|
136
|
+
maxTests?: number;
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
interface ScoutAgentConfig extends AgentConfig {
|
|
@@ -245,7 +247,7 @@ interface ExplorbotConfig {
|
|
|
245
247
|
knowledge: string;
|
|
246
248
|
experience: string;
|
|
247
249
|
output: string;
|
|
248
|
-
spec?: string;
|
|
250
|
+
spec?: string | string[];
|
|
249
251
|
};
|
|
250
252
|
experience?: {
|
|
251
253
|
maxReadLines?: number;
|
|
@@ -258,17 +260,6 @@ interface ExplorbotConfig {
|
|
|
258
260
|
dynamicPageRegex?: string;
|
|
259
261
|
}
|
|
260
262
|
|
|
261
|
-
const config: ExplorbotConfig = {
|
|
262
|
-
playwright: {
|
|
263
|
-
browser: 'chromium',
|
|
264
|
-
url: 'http://localhost:3000',
|
|
265
|
-
},
|
|
266
|
-
|
|
267
|
-
ai: {
|
|
268
|
-
model: null as any,
|
|
269
|
-
},
|
|
270
|
-
};
|
|
271
|
-
|
|
272
263
|
type RuleEntry = string | Record<string, string>;
|
|
273
264
|
|
|
274
265
|
export { EXPLORBOT_CONFIG_PATHS };
|
|
@@ -284,6 +275,7 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
|
|
|
284
275
|
{ name: 'EXPLORBOT_EPHEMERAL', description: 'Keep no state between runs — output goes to a fresh temp directory instead of the site dir' },
|
|
285
276
|
{ name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
|
|
286
277
|
{ name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
|
|
278
|
+
{ name: 'EXPLORBOT_AUTH', description: 'Auth details in free form (credentials, a token), kept in memory for the run and never written to disk; same as --auth', secret: true },
|
|
287
279
|
{ name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
|
|
288
280
|
{ name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
|
|
289
281
|
{ name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
|
|
@@ -762,10 +754,6 @@ export class ConfigParser {
|
|
|
762
754
|
}
|
|
763
755
|
}
|
|
764
756
|
|
|
765
|
-
private getNestedValue(obj: any, path: string): any {
|
|
766
|
-
return path.split('.').reduce((current, key) => current?.[key], obj);
|
|
767
|
-
}
|
|
768
|
-
|
|
769
757
|
public mergeWithDefaults(config: Partial<ExplorbotConfig>): ExplorbotConfig {
|
|
770
758
|
const defaults = {
|
|
771
759
|
playwright: {
|
|
@@ -878,6 +866,7 @@ export function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>
|
|
|
878
866
|
const models: Record<string, ConfiguredModel> = { model: describe(ai.model) };
|
|
879
867
|
if (ai.agenticModel) models.agenticModel = describe(ai.agenticModel);
|
|
880
868
|
if (ai.visionModel) models.visionModel = describe(ai.visionModel);
|
|
869
|
+
if (ai.decisionModel) models.decisionModel = { name: ai.decisionModel.model, provider: ai.decisionModel.provider };
|
|
881
870
|
for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
|
|
882
871
|
if (agentConfig?.model) models[agent] = describe(agentConfig.model);
|
|
883
872
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, join } from 'node:path';
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import type { ActionResult } from './action-result.js';
|
package/src/explorbot.ts
CHANGED
|
@@ -55,7 +55,7 @@ export interface ExplorBotOptions {
|
|
|
55
55
|
optionalAi?: boolean;
|
|
56
56
|
reporter?: ReporterConfig;
|
|
57
57
|
attachedBrowser?: Browser;
|
|
58
|
-
applicationSpec?: string;
|
|
58
|
+
applicationSpec?: string | string[];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
export type UserResolveFunction = (error?: Error, showWelcome?: boolean) => Promise<string | null>;
|
|
@@ -156,7 +156,13 @@ export class ExplorBot {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
async visit(url: string): Promise<void> {
|
|
159
|
-
|
|
159
|
+
const target = this.configParser.resolveTargetPath(url);
|
|
160
|
+
await this.agentNavigator()
|
|
161
|
+
.visit(target)
|
|
162
|
+
.catch(async (err) => {
|
|
163
|
+
if (await this.agentCaptain().resolveBlocker(err, () => this.agentNavigator().isOn(target))) return;
|
|
164
|
+
throw err;
|
|
165
|
+
});
|
|
160
166
|
}
|
|
161
167
|
|
|
162
168
|
async openTab(): Promise<void> {
|
|
@@ -274,7 +280,7 @@ export class ExplorBot {
|
|
|
274
280
|
}
|
|
275
281
|
if (this.isHistorianEnabled()) this.agents.tester.setHistorian(this.agentHistorian());
|
|
276
282
|
this.agents.tester.setPilot(this.agentPilot());
|
|
277
|
-
this.agents.tester.
|
|
283
|
+
this.agents.tester.setSupervisor((input: string, test: Test) => this.agentCaptain().processSupervisorInterrupt(input, test));
|
|
278
284
|
|
|
279
285
|
const fisherman = this.agentFisherman();
|
|
280
286
|
if (fisherman) this.agentPilot().setFisherman(fisherman);
|
|
@@ -377,9 +383,14 @@ export class ExplorBot {
|
|
|
377
383
|
|
|
378
384
|
agentScout(): Scout | null {
|
|
379
385
|
const scoutConfig = this.config.ai?.agents?.scout;
|
|
380
|
-
if (scoutConfig?.enabled !== true) return null;
|
|
381
|
-
|
|
382
386
|
const dirs = this.scoutCorpusDirs();
|
|
387
|
+
const hasSpec = dirs.length > 0;
|
|
388
|
+
const explicitlyDisabled = scoutConfig?.enabled === false;
|
|
389
|
+
const explicitlyEnabled = scoutConfig?.enabled === true;
|
|
390
|
+
|
|
391
|
+
if (explicitlyDisabled) return null;
|
|
392
|
+
if (!explicitlyEnabled && !hasSpec) return null;
|
|
393
|
+
|
|
383
394
|
if (dirs.length === 0) {
|
|
384
395
|
tag('warning').log('Scout enabled but no documentation found — set --spec or ai.agents.scout.dirs');
|
|
385
396
|
return null;
|
|
@@ -391,11 +402,10 @@ export class ExplorBot {
|
|
|
391
402
|
private scoutCorpusDirs(): string[] {
|
|
392
403
|
const dirs: string[] = [];
|
|
393
404
|
|
|
394
|
-
const
|
|
395
|
-
|
|
405
|
+
const specPaths = normalizeSpecPaths(this.options.applicationSpec || this.config.dirs?.spec);
|
|
406
|
+
for (const specPath of specPaths) {
|
|
396
407
|
const bundle = resolveSpecBundlePath(specPath);
|
|
397
|
-
|
|
398
|
-
if (pagesDir && existsSync(pagesDir)) dirs.push(pagesDir);
|
|
408
|
+
if (bundle && existsSync(bundle)) dirs.push(bundle);
|
|
399
409
|
}
|
|
400
410
|
|
|
401
411
|
for (const dir of this.config.ai?.agents?.scout?.dirs || []) {
|
|
@@ -423,7 +433,7 @@ export class ExplorBot {
|
|
|
423
433
|
this.agents.planner = undefined;
|
|
424
434
|
}
|
|
425
435
|
|
|
426
|
-
async plan(feature?: string, opts: { fresh?: boolean; style?: string; extend?: Plan;
|
|
436
|
+
async plan(feature?: string, opts: { fresh?: boolean; style?: string; extend?: Plan; noSave?: boolean; minTests?: number; maxTests?: number } = {}) {
|
|
427
437
|
this.planFeature = feature;
|
|
428
438
|
|
|
429
439
|
if (opts.fresh) {
|
|
@@ -440,13 +450,18 @@ export class ExplorBot {
|
|
|
440
450
|
|
|
441
451
|
const planner = this.agentPlanner();
|
|
442
452
|
planner.freshStart = !!opts.fresh;
|
|
453
|
+
if (opts.minTests !== undefined || opts.maxTests !== undefined) {
|
|
454
|
+
planner.setTestCount({ minTests: opts.minTests, maxTests: opts.maxTests });
|
|
455
|
+
}
|
|
443
456
|
if (this.currentPlan) {
|
|
444
457
|
planner.setPlan(this.currentPlan);
|
|
445
458
|
}
|
|
446
459
|
this.lastPlanError = null;
|
|
460
|
+
const before = new Set(this.currentPlan?.tests ?? []);
|
|
447
461
|
try {
|
|
448
|
-
this.setCurrentPlan(await planner.plan(feature, opts.style, opts.extend
|
|
462
|
+
this.setCurrentPlan(await planner.plan(feature, opts.style, opts.extend));
|
|
449
463
|
} catch (err) {
|
|
464
|
+
if (await this.agentCaptain().resolveBlocker(err, () => !!this.currentPlan?.tests.some((t) => !before.has(t)))) return this.currentPlan;
|
|
450
465
|
this.lastPlanError = err instanceof Error ? err : new Error(String(err));
|
|
451
466
|
tag('warning').log(`Planning failed: ${this.lastPlanError.message}`);
|
|
452
467
|
if (!this.currentPlan) return undefined;
|
|
@@ -575,3 +590,8 @@ export class ExplorBot {
|
|
|
575
590
|
}
|
|
576
591
|
}
|
|
577
592
|
}
|
|
593
|
+
|
|
594
|
+
function normalizeSpecPaths(spec: string | string[] | undefined): string[] {
|
|
595
|
+
if (!spec) return [];
|
|
596
|
+
return Array.isArray(spec) ? spec : [spec];
|
|
597
|
+
}
|
package/src/explorer.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { KnowledgeTracker } from './knowledge-tracker.js';
|
|
|
17
17
|
import type { PlaywrightRecorder } from './playwright-recorder.ts';
|
|
18
18
|
import type { Reporter } from './reporter.ts';
|
|
19
19
|
import type { StateManager } from './state-manager.js';
|
|
20
|
-
import { Test
|
|
20
|
+
import { Test } from './test-plan.ts';
|
|
21
21
|
import { BrowserRecoveryError, browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
|
|
22
22
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
23
23
|
import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
|
|
@@ -190,7 +190,6 @@ class Explorer {
|
|
|
190
190
|
if (!(await this.recoverOrRestart())) {
|
|
191
191
|
return { ok: false, action: 'stop', message: 'Browser page could not be recovered' };
|
|
192
192
|
}
|
|
193
|
-
this.watchActiveTestPage();
|
|
194
193
|
return { ok: true, action: 'continue', recovered: true, message: 'Browser page was recovered' };
|
|
195
194
|
}
|
|
196
195
|
|
|
@@ -201,7 +200,7 @@ class Explorer {
|
|
|
201
200
|
|
|
202
201
|
this._activeTest = test;
|
|
203
202
|
test.start();
|
|
204
|
-
await this.reporter.reportTestStart(
|
|
203
|
+
await this.reporter.reportTestStart();
|
|
205
204
|
await this.closeOtherTabs();
|
|
206
205
|
this.stateManager.otherTabs = [];
|
|
207
206
|
|
|
@@ -414,7 +413,7 @@ class Explorer {
|
|
|
414
413
|
|
|
415
414
|
private async runWithRecovery<T>(label: string, operation: () => Promise<T>): Promise<T> {
|
|
416
415
|
if (!this.page && !(await this.recoverOrRestart())) {
|
|
417
|
-
throw new Error(
|
|
416
|
+
throw new BrowserRecoveryError(label, new Error('Browser page is unavailable'), false);
|
|
418
417
|
}
|
|
419
418
|
|
|
420
419
|
try {
|
|
@@ -516,7 +515,6 @@ class Explorer {
|
|
|
516
515
|
return { ok: false, action: 'stop', recovered: false, message: `Browser could not be recovered after fatal error: ${message}` };
|
|
517
516
|
}
|
|
518
517
|
|
|
519
|
-
this.watchActiveTestPage();
|
|
520
518
|
return {
|
|
521
519
|
ok: true,
|
|
522
520
|
action: 'continue',
|
|
@@ -531,8 +529,9 @@ class Explorer {
|
|
|
531
529
|
}
|
|
532
530
|
|
|
533
531
|
private async recoverOrRestart(): Promise<boolean> {
|
|
534
|
-
if (await this.recoverPage()) return
|
|
535
|
-
|
|
532
|
+
if (!(await this.recoverPage()) && !(await this.restartBrowser())) return false;
|
|
533
|
+
this.watchActiveTestPage();
|
|
534
|
+
return true;
|
|
536
535
|
}
|
|
537
536
|
|
|
538
537
|
private async recoverPage(): Promise<boolean> {
|
package/src/knowledge-tracker.ts
CHANGED
|
@@ -22,7 +22,7 @@ export class KnowledgeTracker {
|
|
|
22
22
|
private knowledgeFiles: Knowledge[] = [];
|
|
23
23
|
private sessionKnowledge: Knowledge[] = [];
|
|
24
24
|
private isLoaded = false;
|
|
25
|
-
private
|
|
25
|
+
private applicationSpecs: ApplicationSpec[] = [];
|
|
26
26
|
|
|
27
27
|
static appendSessionKnowledge(text: string): void {
|
|
28
28
|
sessionEntries.push(text);
|
|
@@ -49,9 +49,14 @@ export class KnowledgeTracker {
|
|
|
49
49
|
mkdirSync(this.knowledgeDir, { recursive: true });
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
for (const p of normalizeSpecPaths(specPath)) {
|
|
53
|
+
const spec = new ApplicationSpec(p);
|
|
54
|
+
this.applicationSpecs.push(spec);
|
|
55
|
+
if (spec.pageCount > 0) {
|
|
56
|
+
tag('info').log(`Loaded application spec with ${spec.pageCount} documented pages`);
|
|
57
|
+
} else if (spec.markdownFileCount > 0) {
|
|
58
|
+
tag('info').log(`Loaded spec corpus with ${spec.markdownFileCount} markdown files`);
|
|
59
|
+
}
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
this.sessionKnowledge = sessionEntries.map((entry, index) => {
|
|
@@ -99,11 +104,12 @@ export class KnowledgeTracker {
|
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
renderApplicationSpec(state: ActionResult): string {
|
|
102
|
-
|
|
107
|
+
const parts = this.applicationSpecs.map((spec) => spec.renderFor(state)).filter(Boolean);
|
|
108
|
+
return parts.join('\n\n');
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
applicationSpecUrls(state: ActionResult): string[] {
|
|
106
|
-
return this.
|
|
112
|
+
return this.applicationSpecs.flatMap((spec) => spec.matchedUrls(state));
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
addKnowledge(urlPattern: string, description: string, opts?: { replace?: boolean }): { filename: string; filePath: string; isNewFile: boolean } {
|
|
@@ -293,6 +299,11 @@ export class KnowledgeTracker {
|
|
|
293
299
|
}
|
|
294
300
|
}
|
|
295
301
|
|
|
302
|
+
function normalizeSpecPaths(spec: string | string[] | undefined): string[] {
|
|
303
|
+
if (!spec) return [];
|
|
304
|
+
return Array.isArray(spec) ? spec : [spec];
|
|
305
|
+
}
|
|
306
|
+
|
|
296
307
|
export interface Knowledge {
|
|
297
308
|
filePath: string;
|
|
298
309
|
url?: string;
|
|
@@ -302,6 +313,6 @@ export interface Knowledge {
|
|
|
302
313
|
}
|
|
303
314
|
|
|
304
315
|
export interface KnowledgeTrackerOptions {
|
|
305
|
-
applicationSpec?: string;
|
|
316
|
+
applicationSpec?: string | string[];
|
|
306
317
|
knowledgeDir?: string;
|
|
307
318
|
}
|
|
@@ -39,7 +39,6 @@ export interface VerificationStep {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export class PlaywrightRecorder {
|
|
42
|
-
private context: any = null;
|
|
43
42
|
private tracing: any = null;
|
|
44
43
|
private active = false;
|
|
45
44
|
private nextGroupId = 0;
|
|
@@ -68,7 +67,6 @@ export class PlaywrightRecorder {
|
|
|
68
67
|
debugLog('start: no tracing on browserContext, recorder inactive');
|
|
69
68
|
return;
|
|
70
69
|
}
|
|
71
|
-
this.context = browserContext;
|
|
72
70
|
this.tracing = browserContext.tracing;
|
|
73
71
|
try {
|
|
74
72
|
await this.tracing.start({});
|
package/src/reporter.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
1
|
import { Client } from '@testomatio/reporter';
|
|
3
2
|
import type { Step } from '@testomatio/reporter/types/types.js';
|
|
4
3
|
import { outputPath } from './config.js';
|
|
@@ -132,14 +131,14 @@ export class Reporter {
|
|
|
132
131
|
process.env.TESTOMATIO_STEPS_PASSED = 'true';
|
|
133
132
|
}
|
|
134
133
|
|
|
135
|
-
async reportTestStart(
|
|
134
|
+
async reportTestStart(): Promise<void> {
|
|
136
135
|
await this.startRun();
|
|
137
136
|
}
|
|
138
137
|
|
|
139
138
|
protected combineStepsAndNotes(test: Test, lastScreenshotFile?: string): Step[] {
|
|
140
139
|
const noteEntries = Object.entries(test.notes)
|
|
141
140
|
.filter(([, note]) => !note.observation)
|
|
142
|
-
.map(([
|
|
141
|
+
.map(([, note]) => ({
|
|
143
142
|
startTime: note.startTime,
|
|
144
143
|
endTime: note.endTime,
|
|
145
144
|
message: note.message,
|
|
@@ -150,7 +149,7 @@ export class Reporter {
|
|
|
150
149
|
.sort((a, b) => a.startTime - b.startTime);
|
|
151
150
|
|
|
152
151
|
const stepEntries = Object.entries(test.steps)
|
|
153
|
-
.map(([
|
|
152
|
+
.map(([, stepData]) => ({
|
|
154
153
|
noteStartTime: stepData.noteStartTime,
|
|
155
154
|
text: stepData.text,
|
|
156
155
|
duration: stepData.duration,
|
package/src/test-plan.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import figures from 'figures';
|
|
5
4
|
import type { ActionResult } from './action-result.ts';
|
|
6
5
|
import { listSitePlanDirs } from './global-config.ts';
|
|
7
6
|
import { WebPageState } from './state-manager.ts';
|
|
@@ -545,7 +544,7 @@ export class Plan {
|
|
|
545
544
|
if (!plansDir) dirs.push(...listSitePlanDirs());
|
|
546
545
|
|
|
547
546
|
for (const dir of dirs) {
|
|
548
|
-
const filePath = names.map((name) => path.resolve(dir, name)).find(existsSync);
|
|
547
|
+
const filePath = names.map((name) => path.resolve(dir, name)).find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
|
|
549
548
|
if (filePath) return filePath;
|
|
550
549
|
}
|
|
551
550
|
return null;
|
package/src/utils/error-page.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import dedent from 'dedent';
|
|
1
2
|
import { ActionResult } from '../action-result.js';
|
|
2
3
|
import type { WebPageState } from '../state-manager.js';
|
|
3
|
-
import { isBodyEmpty } from './html.js';
|
|
4
|
+
import { isBodyEmpty, sanitizeHtmlString } from './html.js';
|
|
4
5
|
import { extractStatePath } from './url-matcher.js';
|
|
5
6
|
|
|
6
7
|
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'];
|
|
@@ -33,7 +34,7 @@ export function detectPageCondition(actionResult: ActionResult): PageCondition {
|
|
|
33
34
|
|
|
34
35
|
if (!actionResult.html || isBodyEmpty(actionResult.html)) return 'loading';
|
|
35
36
|
|
|
36
|
-
const bodyMatch = actionResult.html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
37
|
+
const bodyMatch = sanitizeHtmlString(actionResult.html).match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
37
38
|
if (bodyMatch && bodyMatch[1].trim().length < SMALL_PAGE_THRESHOLD) return 'loading';
|
|
38
39
|
|
|
39
40
|
return 'ok';
|
|
@@ -67,3 +68,64 @@ export class ErrorPageError extends Error {
|
|
|
67
68
|
this.name = 'ErrorPageError';
|
|
68
69
|
}
|
|
69
70
|
}
|
|
71
|
+
|
|
72
|
+
export class BlockedError extends Error {
|
|
73
|
+
constructor(
|
|
74
|
+
message: string,
|
|
75
|
+
public readonly url: string,
|
|
76
|
+
public readonly blocker: string,
|
|
77
|
+
public readonly request: string
|
|
78
|
+
) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = 'BlockedError';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class PlanningBlockedError extends BlockedError {
|
|
85
|
+
constructor(
|
|
86
|
+
url: string,
|
|
87
|
+
prerequisite: string,
|
|
88
|
+
public readonly feature?: string,
|
|
89
|
+
public readonly style?: string
|
|
90
|
+
) {
|
|
91
|
+
let focus = '';
|
|
92
|
+
if (feature) focus = ` focused on "${feature}"`;
|
|
93
|
+
let styleNote = '';
|
|
94
|
+
if (style) styleNote = ` in ${style} style`;
|
|
95
|
+
super(
|
|
96
|
+
`Planner could not plan ${url}: ${prerequisite}`,
|
|
97
|
+
url,
|
|
98
|
+
prerequisite,
|
|
99
|
+
dedent`
|
|
100
|
+
Planner was asked to plan tests for ${url}${focus}${styleNote} but stopped: ${prerequisite}.
|
|
101
|
+
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.
|
|
102
|
+
`
|
|
103
|
+
);
|
|
104
|
+
this.name = 'PlanningBlockedError';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class NavigationBlockedError extends BlockedError {
|
|
109
|
+
constructor(url: string, currentUrl: string, reason: string, tried: string[]) {
|
|
110
|
+
const lines = [`Navigator was asked to open ${url} but stopped on ${currentUrl}: ${reason}.`];
|
|
111
|
+
if (tried.length) lines.push(`Already tried: ${tried.join('; ')}.`);
|
|
112
|
+
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.`);
|
|
113
|
+
super(`Navigation to ${url} failed: ${reason}`, url, reason, lines.join('\n'));
|
|
114
|
+
this.name = 'NavigationBlockedError';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class TestStartBlockedError extends BlockedError {
|
|
119
|
+
constructor(scenario: string, url: string, currentUrl: string) {
|
|
120
|
+
super(
|
|
121
|
+
`Test "${scenario}" could not start: ${currentUrl} is not the page it starts on`,
|
|
122
|
+
url,
|
|
123
|
+
'not the start page of this test',
|
|
124
|
+
dedent`
|
|
125
|
+
Tester was asked to start the test "${scenario}" at ${url}, but the page open at ${currentUrl} is not where this test can start.
|
|
126
|
+
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.
|
|
127
|
+
`
|
|
128
|
+
);
|
|
129
|
+
this.name = 'TestStartBlockedError';
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/utils/html-diff.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { parse, serialize } from 'parse5';
|
|
2
2
|
import type * as parse5TreeAdapter from 'parse5/lib/tree-adapters/default';
|
|
3
|
-
import type { HtmlConfig } from '../config.ts';
|
|
4
3
|
import { isTailwindClass, isTrashClass, minifyHtml } from './html.ts';
|
|
5
4
|
import { isDynamicId, isGenericClass } from './xpath.ts';
|
|
6
5
|
|
|
@@ -203,9 +202,9 @@ export function computeHtmlFingerprint(html: string): string[] {
|
|
|
203
202
|
/**
|
|
204
203
|
* Compares two HTML documents and returns differences along with a diff subtree.
|
|
205
204
|
*/
|
|
206
|
-
export async function htmlDiff(originalHtml: string, modifiedHtml: string
|
|
207
|
-
const originalDocument = parseDocument(originalHtml
|
|
208
|
-
const modifiedDocument = parseDocument(modifiedHtml
|
|
205
|
+
export async function htmlDiff(originalHtml: string, modifiedHtml: string): Promise<HtmlDiffResult> {
|
|
206
|
+
const originalDocument = parseDocument(originalHtml);
|
|
207
|
+
const modifiedDocument = parseDocument(modifiedHtml);
|
|
209
208
|
|
|
210
209
|
const originalRoot = getRootNodeForFlatten(originalDocument);
|
|
211
210
|
const modifiedRoot = getRootNodeForFlatten(modifiedDocument);
|
|
@@ -308,7 +307,7 @@ function isLiveRegion(element: ElementNode): boolean {
|
|
|
308
307
|
* Parse HTML into a document, wrapping fragments with html/body for consistency.
|
|
309
308
|
* Uses custom sanitization that removes iframes for diff purposes.
|
|
310
309
|
*/
|
|
311
|
-
function parseDocument(html: string
|
|
310
|
+
function parseDocument(html: string): DocumentNode {
|
|
312
311
|
const document = parse(html);
|
|
313
312
|
const documentTitle = getDocumentTitle(document);
|
|
314
313
|
sanitizeDocumentTreeForDiff(document);
|
|
@@ -432,42 +431,6 @@ const attributesDiffer = (current: ElementNode, previous: ElementNode): boolean
|
|
|
432
431
|
return false;
|
|
433
432
|
};
|
|
434
433
|
|
|
435
|
-
const getDirectTextValues = (element: ElementNode): string[] => {
|
|
436
|
-
if (!element.childNodes || element.childNodes.length === 0) {
|
|
437
|
-
return [];
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const values: string[] = [];
|
|
441
|
-
|
|
442
|
-
for (const child of element.childNodes) {
|
|
443
|
-
if (child.nodeName === '#text') {
|
|
444
|
-
const value = (child as parse5TreeAdapter.TextNode).value.trim();
|
|
445
|
-
if (value.length > 0) {
|
|
446
|
-
values.push(value);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
return values;
|
|
452
|
-
};
|
|
453
|
-
|
|
454
|
-
const directTextDiffer = (current: ElementNode, previous: ElementNode): boolean => {
|
|
455
|
-
const currentText = getDirectTextValues(current);
|
|
456
|
-
const previousText = getDirectTextValues(previous);
|
|
457
|
-
|
|
458
|
-
if (currentText.length !== previousText.length) {
|
|
459
|
-
return true;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
for (let i = 0; i < currentText.length; i++) {
|
|
463
|
-
if (currentText[i] !== previousText[i]) {
|
|
464
|
-
return true;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
return false;
|
|
469
|
-
};
|
|
470
|
-
|
|
471
434
|
function filterContainerClasses(classes: string[]): string[] {
|
|
472
435
|
return classes
|
|
473
436
|
.filter((cls) => !/\d/.test(cls))
|
|
@@ -792,21 +755,6 @@ function findHtmlElement(document: DocumentNode): ElementNode | null {
|
|
|
792
755
|
return null;
|
|
793
756
|
}
|
|
794
757
|
|
|
795
|
-
function findHeadElement(document: DocumentNode): ElementNode | null {
|
|
796
|
-
const html = findHtmlElement(document);
|
|
797
|
-
if (!html || !html.childNodes) {
|
|
798
|
-
return null;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
for (const node of html.childNodes) {
|
|
802
|
-
if ('tagName' in node && node.tagName && node.tagName.toLowerCase() === 'head') {
|
|
803
|
-
return node as ElementNode;
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
return null;
|
|
808
|
-
}
|
|
809
|
-
|
|
810
758
|
function findBodyElement(document: DocumentNode): ElementNode | null {
|
|
811
759
|
const html = findHtmlElement(document);
|
|
812
760
|
if (!html || !html.childNodes) {
|
package/src/utils/html.ts
CHANGED