explorbot 0.4.8 → 0.4.10
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/boat/api-tester/src/cli.ts +1 -1
- package/boat/api-tester/src/config.ts +34 -26
- package/dist/boat/api-tester/src/cli.js +1 -1
- package/dist/boat/api-tester/src/config.js +32 -26
- package/dist/package.json +1 -1
- package/dist/src/action.js +4 -2
- package/dist/src/ai/fisherman/tools.js +10 -4
- package/dist/src/ai/navigator.d.ts +0 -1
- package/dist/src/ai/navigator.js +11 -24
- package/dist/src/ai/pilot.js +21 -10
- package/dist/src/ai/rerunner.js +7 -0
- package/dist/src/ai/tools.js +8 -1
- package/dist/src/api/xhr-capture.js +2 -1
- package/dist/src/commands/config-command.d.ts +2 -0
- package/dist/src/commands/config-command.js +8 -2
- package/dist/src/commands/init-command.js +6 -26
- package/dist/src/commands/sites-command.js +6 -1
- package/dist/src/config.d.ts +5 -4
- package/dist/src/config.js +25 -23
- package/dist/src/explorer.js +2 -3
- package/dist/src/global-config.d.ts +6 -0
- package/dist/src/global-config.js +82 -7
- package/dist/src/reporter.js +8 -4
- package/dist/src/utils/html.js +6 -0
- package/dist/src/utils/logger.js +1 -1
- package/dist/src/utils/merge.d.ts +1 -0
- package/dist/src/utils/merge.js +11 -0
- package/dist/src/utils/step-analyzer.d.ts +3 -0
- package/dist/src/utils/step-analyzer.js +7 -0
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +7 -0
- package/docs/reference/commands.md +10 -2
- package/docs/reference/configuration.md +37 -4
- package/docs/superpowers/plans/2026-09-15-mdq-package.md +2029 -0
- package/docs/superpowers/specs/2026-09-14-mdq-package-design.md +397 -0
- package/package.json +1 -1
- package/src/action.ts +4 -2
- package/src/ai/fisherman/tools.ts +10 -4
- package/src/ai/navigator.ts +9 -23
- package/src/ai/pilot.ts +21 -10
- package/src/ai/rerunner.ts +4 -0
- package/src/ai/tools.ts +7 -1
- package/src/api/xhr-capture.ts +2 -1
- package/src/commands/config-command.ts +9 -2
- package/src/commands/init-command.ts +6 -27
- package/src/commands/sites-command.ts +6 -1
- package/src/config.ts +28 -25
- package/src/explorer.ts +2 -2
- package/src/global-config.ts +81 -6
- package/src/reporter.ts +8 -4
- package/src/utils/html.ts +6 -0
- package/src/utils/logger.ts +1 -1
- package/src/utils/merge.ts +13 -0
- package/src/utils/step-analyzer.ts +8 -0
- package/src/utils/url-matcher.ts +7 -0
|
@@ -40,7 +40,7 @@ export function createApiCommands(name = 'api'): Command {
|
|
|
40
40
|
if (runOptions.endpoint && URL.canParse(runOptions.endpoint)) runOptions.baseEndpoint ||= runOptions.endpoint;
|
|
41
41
|
try {
|
|
42
42
|
const config = await parser.loadConfig(runOptions);
|
|
43
|
-
console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
|
|
43
|
+
console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json }));
|
|
44
44
|
} catch (error) {
|
|
45
45
|
console.error(error instanceof Error ? error.message : 'Unknown error');
|
|
46
46
|
process.exit(1);
|
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
resolveOutputRoot,
|
|
19
19
|
setOutputDir,
|
|
20
20
|
} from '../../../src/config.ts';
|
|
21
|
-
import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
|
|
21
|
+
import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, loadSiteConfig, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
|
|
22
|
+
import { deepMerge } from '../../../src/utils/merge.ts';
|
|
22
23
|
|
|
23
24
|
export type { AIConfig };
|
|
24
25
|
|
|
@@ -58,6 +59,7 @@ export class ApibotConfigParser {
|
|
|
58
59
|
private config: ApibotConfig | null = null;
|
|
59
60
|
private configPath: string | null = null;
|
|
60
61
|
private site: SiteRecord | null = null;
|
|
62
|
+
private siteConfigPath: string | null = null;
|
|
61
63
|
|
|
62
64
|
private constructor() {}
|
|
63
65
|
|
|
@@ -111,20 +113,22 @@ export class ApibotConfigParser {
|
|
|
111
113
|
};
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
|
|
115
|
-
this.applyEnvSpec(
|
|
116
|
-
this.applyEnvHeaders(
|
|
117
|
-
if (options?.baseEndpoint)
|
|
118
|
-
await resolveConfigModels(
|
|
119
|
-
resolveLangfuse(
|
|
120
|
-
this.configPath = resolvedPath;
|
|
116
|
+
let config = this.mergeWithDefaults(loadedConfig);
|
|
117
|
+
this.applyEnvSpec(config.api);
|
|
118
|
+
this.applyEnvHeaders(config.api);
|
|
119
|
+
if (options?.baseEndpoint) config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
|
|
120
|
+
await resolveConfigModels(config.ai);
|
|
121
|
+
resolveLangfuse(config.ai);
|
|
121
122
|
this.site = null;
|
|
123
|
+
this.siteConfigPath = null;
|
|
122
124
|
|
|
123
125
|
if (isGlobalConfigPath(resolvedPath)) {
|
|
124
|
-
this.enterGlobalMode(
|
|
126
|
+
config = await this.enterGlobalMode(config, options?.endpoint);
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
this.validateConfig(
|
|
129
|
+
this.validateConfig(config);
|
|
130
|
+
this.config = config;
|
|
131
|
+
this.configPath = resolvedPath;
|
|
128
132
|
setOutputDir(this.getOutputDir());
|
|
129
133
|
|
|
130
134
|
return this.config;
|
|
@@ -144,6 +148,10 @@ export class ApibotConfigParser {
|
|
|
144
148
|
return this.configPath;
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
getSiteConfigPath(): string | null {
|
|
152
|
+
return this.siteConfigPath;
|
|
153
|
+
}
|
|
154
|
+
|
|
147
155
|
getOutputDir(): string {
|
|
148
156
|
const config = this.getConfig();
|
|
149
157
|
return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
|
|
@@ -207,17 +215,29 @@ export class ApibotConfigParser {
|
|
|
207
215
|
api.headers = { ...api.headers, ...parseHeaders(process.env.EXPLORBOT_API_HEADERS) };
|
|
208
216
|
}
|
|
209
217
|
|
|
210
|
-
private enterGlobalMode(config: ApibotConfig, endpoint?: string):
|
|
218
|
+
private async enterGlobalMode(config: ApibotConfig, endpoint?: string): Promise<ApibotConfig> {
|
|
211
219
|
const site = resolveSiteTarget(endpoint);
|
|
212
220
|
this.site = registerSite(site.baseUrl);
|
|
213
221
|
|
|
222
|
+
const { path, config: siteConfig } = await loadSiteConfig(this.site.dir, site.baseUrl);
|
|
223
|
+
this.siteConfigPath = path;
|
|
224
|
+
|
|
225
|
+
const overrides: Partial<ApibotConfig> = {};
|
|
226
|
+
if (siteConfig.ai) overrides.ai = siteConfig.ai;
|
|
227
|
+
if (siteConfig.api) overrides.api = siteConfig.api;
|
|
228
|
+
|
|
229
|
+
const merged = deepMerge(config, overrides);
|
|
230
|
+
await resolveConfigModels(merged.ai);
|
|
231
|
+
resolveLangfuse(merged.ai);
|
|
232
|
+
|
|
214
233
|
let baseEndpoint = site.baseUrl;
|
|
215
234
|
const envUrl = process.env.EXPLORBOT_URL;
|
|
216
235
|
if (envUrl && URL.parse(envUrl)?.origin === site.baseUrl) baseEndpoint = envUrl.replace(/\/$/, '');
|
|
217
236
|
|
|
218
|
-
|
|
219
|
-
|
|
237
|
+
merged.dirs = { output: 'output', knowledge: 'knowledge' };
|
|
238
|
+
merged.api = { ...merged.api, baseEndpoint };
|
|
220
239
|
materializeKnowledge(this.site.dir);
|
|
240
|
+
return merged;
|
|
221
241
|
}
|
|
222
242
|
|
|
223
243
|
private async loadEnvConfig(): Promise<ApibotConfig> {
|
|
@@ -310,19 +330,7 @@ export class ApibotConfigParser {
|
|
|
310
330
|
}
|
|
311
331
|
|
|
312
332
|
private mergeWithDefaults(config: Partial<ApibotConfig>): ApibotConfig {
|
|
313
|
-
return
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
private deepMerge(target: any, source: any): any {
|
|
317
|
-
const result = { ...target };
|
|
318
|
-
for (const key in source) {
|
|
319
|
-
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
|
|
320
|
-
result[key] = this.deepMerge(result[key] || {}, source[key]);
|
|
321
|
-
} else {
|
|
322
|
-
result[key] = source[key];
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
return result;
|
|
333
|
+
return deepMerge({ dirs: { output: 'output' }, api: {} }, config);
|
|
326
334
|
}
|
|
327
335
|
}
|
|
328
336
|
|
|
@@ -38,7 +38,7 @@ export function createApiCommands(name = 'api') {
|
|
|
38
38
|
runOptions.baseEndpoint ||= runOptions.endpoint;
|
|
39
39
|
try {
|
|
40
40
|
const config = await parser.loadConfig(runOptions);
|
|
41
|
-
console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
|
|
41
|
+
console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json }));
|
|
42
42
|
}
|
|
43
43
|
catch (error) {
|
|
44
44
|
console.error(error instanceof Error ? error.message : 'Unknown error');
|
|
@@ -11,7 +11,8 @@ import path, { resolve } from 'node:path';
|
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
12
|
import { parseEnv } from 'node:util';
|
|
13
13
|
import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveLangfuse, resolveModel, resolveOutputRoot, setOutputDir, } from "../../../src/config.js";
|
|
14
|
-
import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
|
|
14
|
+
import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, loadSiteConfig, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
|
|
15
|
+
import { deepMerge } from "../../../src/utils/merge.js";
|
|
15
16
|
function isAbsoluteEndpoint(value) {
|
|
16
17
|
return !!value && (value.startsWith('http://') || value.startsWith('https://'));
|
|
17
18
|
}
|
|
@@ -30,6 +31,7 @@ export class ApibotConfigParser {
|
|
|
30
31
|
config = null;
|
|
31
32
|
configPath = null;
|
|
32
33
|
site = null;
|
|
34
|
+
siteConfigPath = null;
|
|
33
35
|
constructor() { }
|
|
34
36
|
static getInstance() {
|
|
35
37
|
if (!ApibotConfigParser.instance) {
|
|
@@ -76,19 +78,21 @@ export class ApibotConfigParser {
|
|
|
76
78
|
dirs: loadedConfig.dirs,
|
|
77
79
|
};
|
|
78
80
|
}
|
|
79
|
-
|
|
80
|
-
this.applyEnvSpec(
|
|
81
|
-
this.applyEnvHeaders(
|
|
81
|
+
let config = this.mergeWithDefaults(loadedConfig);
|
|
82
|
+
this.applyEnvSpec(config.api);
|
|
83
|
+
this.applyEnvHeaders(config.api);
|
|
82
84
|
if (options?.baseEndpoint)
|
|
83
|
-
|
|
84
|
-
await resolveConfigModels(
|
|
85
|
-
resolveLangfuse(
|
|
86
|
-
this.configPath = resolvedPath;
|
|
85
|
+
config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
|
|
86
|
+
await resolveConfigModels(config.ai);
|
|
87
|
+
resolveLangfuse(config.ai);
|
|
87
88
|
this.site = null;
|
|
89
|
+
this.siteConfigPath = null;
|
|
88
90
|
if (isGlobalConfigPath(resolvedPath)) {
|
|
89
|
-
this.enterGlobalMode(
|
|
91
|
+
config = await this.enterGlobalMode(config, options?.endpoint);
|
|
90
92
|
}
|
|
91
|
-
this.validateConfig(
|
|
93
|
+
this.validateConfig(config);
|
|
94
|
+
this.config = config;
|
|
95
|
+
this.configPath = resolvedPath;
|
|
92
96
|
setOutputDir(this.getOutputDir());
|
|
93
97
|
return this.config;
|
|
94
98
|
}
|
|
@@ -106,6 +110,9 @@ export class ApibotConfigParser {
|
|
|
106
110
|
getConfigPath() {
|
|
107
111
|
return this.configPath;
|
|
108
112
|
}
|
|
113
|
+
getSiteConfigPath() {
|
|
114
|
+
return this.siteConfigPath;
|
|
115
|
+
}
|
|
109
116
|
getOutputDir() {
|
|
110
117
|
const config = this.getConfig();
|
|
111
118
|
return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
|
|
@@ -168,16 +175,27 @@ export class ApibotConfigParser {
|
|
|
168
175
|
return;
|
|
169
176
|
api.headers = { ...api.headers, ...parseHeaders(process.env.EXPLORBOT_API_HEADERS) };
|
|
170
177
|
}
|
|
171
|
-
enterGlobalMode(config, endpoint) {
|
|
178
|
+
async enterGlobalMode(config, endpoint) {
|
|
172
179
|
const site = resolveSiteTarget(endpoint);
|
|
173
180
|
this.site = registerSite(site.baseUrl);
|
|
181
|
+
const { path, config: siteConfig } = await loadSiteConfig(this.site.dir, site.baseUrl);
|
|
182
|
+
this.siteConfigPath = path;
|
|
183
|
+
const overrides = {};
|
|
184
|
+
if (siteConfig.ai)
|
|
185
|
+
overrides.ai = siteConfig.ai;
|
|
186
|
+
if (siteConfig.api)
|
|
187
|
+
overrides.api = siteConfig.api;
|
|
188
|
+
const merged = deepMerge(config, overrides);
|
|
189
|
+
await resolveConfigModels(merged.ai);
|
|
190
|
+
resolveLangfuse(merged.ai);
|
|
174
191
|
let baseEndpoint = site.baseUrl;
|
|
175
192
|
const envUrl = process.env.EXPLORBOT_URL;
|
|
176
193
|
if (envUrl && URL.parse(envUrl)?.origin === site.baseUrl)
|
|
177
194
|
baseEndpoint = envUrl.replace(/\/$/, '');
|
|
178
|
-
|
|
179
|
-
|
|
195
|
+
merged.dirs = { output: 'output', knowledge: 'knowledge' };
|
|
196
|
+
merged.api = { ...merged.api, baseEndpoint };
|
|
180
197
|
materializeKnowledge(this.site.dir);
|
|
198
|
+
return merged;
|
|
181
199
|
}
|
|
182
200
|
async loadEnvConfig() {
|
|
183
201
|
const provider = process.env.EXPLORBOT_AI_PROVIDER;
|
|
@@ -261,18 +279,6 @@ export class ApibotConfigParser {
|
|
|
261
279
|
}
|
|
262
280
|
}
|
|
263
281
|
mergeWithDefaults(config) {
|
|
264
|
-
return
|
|
265
|
-
}
|
|
266
|
-
deepMerge(target, source) {
|
|
267
|
-
const result = { ...target };
|
|
268
|
-
for (const key in source) {
|
|
269
|
-
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
|
|
270
|
-
result[key] = this.deepMerge(result[key] || {}, source[key]);
|
|
271
|
-
}
|
|
272
|
-
else {
|
|
273
|
-
result[key] = source[key];
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
return result;
|
|
282
|
+
return deepMerge({ dirs: { output: 'output' }, api: {} }, config);
|
|
277
283
|
}
|
|
278
284
|
}
|
package/dist/package.json
CHANGED
package/dist/src/action.js
CHANGED
|
@@ -12,7 +12,9 @@ import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './util
|
|
|
12
12
|
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
|
|
13
13
|
import { Overlay, OverlayPage } from './utils/overlay.js';
|
|
14
14
|
import { sleep, waitForPageReadiness } from "./utils/page-readiness.js";
|
|
15
|
+
import { isInternalStep } from "./utils/step-analyzer.js";
|
|
15
16
|
import { safeFilename } from "./utils/strings.js";
|
|
17
|
+
import { isSameHostFamily } from './utils/url-matcher.js';
|
|
16
18
|
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from "./utils/web-sandbox.js";
|
|
17
19
|
const debugLog = createDebug('explorbot:action');
|
|
18
20
|
const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
|
|
@@ -303,7 +305,7 @@ class Action {
|
|
|
303
305
|
const url = URL.parse(request.url());
|
|
304
306
|
if (!url)
|
|
305
307
|
return;
|
|
306
|
-
if (url.
|
|
308
|
+
if (!isSameHostFamily(url.href, this.baseOrigin))
|
|
307
309
|
return;
|
|
308
310
|
const call = { method: request.method(), path: url.pathname, status };
|
|
309
311
|
if (this.networkRequests.some((r) => r.method === call.method && r.path === call.path && r.status === call.status))
|
|
@@ -523,7 +525,7 @@ export const attachStepLogger = (target, assertionsTarget) => {
|
|
|
523
525
|
const listener = (step, error) => {
|
|
524
526
|
if (!step?.toCode)
|
|
525
527
|
return;
|
|
526
|
-
if (step
|
|
528
|
+
if (isInternalStep(step))
|
|
527
529
|
return;
|
|
528
530
|
const existing = recorded.get(step);
|
|
529
531
|
if (existing) {
|
|
@@ -196,13 +196,19 @@ export function createAskApiTool(fisherman, task) {
|
|
|
196
196
|
return {
|
|
197
197
|
askApi: tool({
|
|
198
198
|
description: dedent `
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
Read the app's data over the API, changing nothing.
|
|
200
|
+
Answers questions about records: which ones are there, what they are called, whether a particular one exists.
|
|
201
|
+
|
|
202
|
+
Use it to:
|
|
203
|
+
- check whether suitable data already exists, before precondition() creates any
|
|
204
|
+
- get the exact name or id of a record a step must act on
|
|
205
|
+
- find out whether an action was stored, when the app reported success but the page does not show the result
|
|
206
|
+
- find out whether data exists at all, when a list or dropdown is empty
|
|
207
|
+
|
|
202
208
|
It never creates, edits or deletes anything — precondition() does that.
|
|
203
209
|
`,
|
|
204
210
|
inputSchema: z.object({
|
|
205
|
-
question: z.string().describe('What to find out about data
|
|
211
|
+
question: z.string().describe('What to find out about the data'),
|
|
206
212
|
}),
|
|
207
213
|
execute: async ({ question }) => {
|
|
208
214
|
tag('info').log(`Ask API: ${question}`);
|
|
@@ -26,7 +26,6 @@ declare class Navigator implements Agent {
|
|
|
26
26
|
constructor(deps: AgentDeps);
|
|
27
27
|
get verifyAttempts(): number;
|
|
28
28
|
get verifyTimeout(): number;
|
|
29
|
-
getBaseOrigin(): string | null;
|
|
30
29
|
getComparableCurrentUrl(stateManager: any, expectedUrl: string): string;
|
|
31
30
|
comparableUrl(state: {
|
|
32
31
|
url?: string;
|
package/dist/src/ai/navigator.js
CHANGED
|
@@ -12,7 +12,7 @@ import { createDebug, pluralize, tag } from '../utils/logger.js';
|
|
|
12
12
|
import { loop, pause } from '../utils/loop.js';
|
|
13
13
|
import { RulesLoader } from "../utils/rules-loader.js";
|
|
14
14
|
import { normalizeInlineText } from "../utils/strings.js";
|
|
15
|
-
import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
|
|
15
|
+
import { extractStatePath, isSameHostFamily, matchesNavigationUrl } from '../utils/url-matcher.js';
|
|
16
16
|
import { Researcher } from "./researcher.js";
|
|
17
17
|
import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
|
|
18
18
|
import { isInteractive } from './task-agent.js';
|
|
@@ -82,15 +82,6 @@ class Navigator {
|
|
|
82
82
|
get verifyTimeout() {
|
|
83
83
|
return this.config.ai?.agents?.navigator?.verifyTimeout ?? 1500;
|
|
84
84
|
}
|
|
85
|
-
getBaseOrigin() {
|
|
86
|
-
const baseUrl = this.config.playwright.url;
|
|
87
|
-
try {
|
|
88
|
-
return new URL(baseUrl).origin;
|
|
89
|
-
}
|
|
90
|
-
catch {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
85
|
getComparableCurrentUrl(stateManager, expectedUrl) {
|
|
95
86
|
const currentState = stateManager.getCurrentState();
|
|
96
87
|
if (!currentState)
|
|
@@ -109,19 +100,14 @@ class Navigator {
|
|
|
109
100
|
const currentFullUrl = currentState.fullUrl || currentState.url || '';
|
|
110
101
|
if (!currentFullUrl)
|
|
111
102
|
return false;
|
|
112
|
-
|
|
113
|
-
const currentOrigin = new URL(currentFullUrl).origin;
|
|
114
|
-
if (/^https?:\/\//i.test(expectedUrl)) {
|
|
115
|
-
return currentOrigin === new URL(expectedUrl).origin;
|
|
116
|
-
}
|
|
117
|
-
const baseOrigin = this.getBaseOrigin();
|
|
118
|
-
if (!baseOrigin)
|
|
119
|
-
return true;
|
|
120
|
-
return currentOrigin === baseOrigin;
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
103
|
+
if (!/^https?:\/\//i.test(currentFullUrl))
|
|
123
104
|
return !/^https?:\/\//i.test(expectedUrl);
|
|
124
|
-
|
|
105
|
+
if (/^https?:\/\//i.test(expectedUrl))
|
|
106
|
+
return isSameHostFamily(currentFullUrl, expectedUrl);
|
|
107
|
+
const baseUrl = this.config.playwright.url;
|
|
108
|
+
if (!baseUrl)
|
|
109
|
+
return true;
|
|
110
|
+
return isSameHostFamily(currentFullUrl, baseUrl);
|
|
125
111
|
}
|
|
126
112
|
isOnExpectedPage(expectedUrl, stateManager) {
|
|
127
113
|
if (!this.isSameExpectedOrigin(expectedUrl, stateManager)) {
|
|
@@ -289,8 +275,9 @@ class Navigator {
|
|
|
289
275
|
tag('warning').log(`Page state did not change at ${check.freshState.url}`);
|
|
290
276
|
}
|
|
291
277
|
else {
|
|
292
|
-
|
|
293
|
-
|
|
278
|
+
const reachedUrl = check.freshState.fullUrl || check.freshState.url;
|
|
279
|
+
lastFailure = `Reached ${reachedUrl}, expected ${expectedUrl}`;
|
|
280
|
+
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${reachedUrl}`);
|
|
294
281
|
}
|
|
295
282
|
batchFailures.push({
|
|
296
283
|
code: codeBlock,
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -95,7 +95,9 @@ export class Pilot {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
const schema = z.object({
|
|
98
|
-
decision: z
|
|
98
|
+
decision: z
|
|
99
|
+
.enum(['pass', 'fail', 'continue', 'skipped'])
|
|
100
|
+
.describe('pass = scenario goal accomplished, fail = the app misbehaved, continue = tester should keep going, skipped = the scenario cannot be judged against this app (its premise does not hold, it is irrelevant, or systematic execution failures prevented testing)'),
|
|
99
101
|
reason: z.string().describe('Concise user-facing reason, maximum 1 short sentence and 120 characters. Do NOT repeat the decision status; explain only the evidence. For continue: explain why rejected and suggest alternatives.'),
|
|
100
102
|
guidance: z.string().nullable().describe('Required for "continue": specific actionable instruction for the tester — what exactly to verify, retry differently, or complete next. Be concrete.'),
|
|
101
103
|
requestVerification: z
|
|
@@ -358,9 +360,13 @@ export class Pilot {
|
|
|
358
360
|
DOM assertion can't be made.
|
|
359
361
|
Do not pass when Tester achieved only a related navigation/filter/tab/status outcome instead of the
|
|
360
362
|
requested action, workflow, or entity detail goal.
|
|
361
|
-
- "fail":
|
|
362
|
-
|
|
363
|
-
|
|
363
|
+
- "fail": the app misbehaved — the scenario's action ran against the right target and the app
|
|
364
|
+
produced a wrong, broken, or missing outcome. Not reaching the goal is not by itself a fail.
|
|
365
|
+
- "skipped": the scenario cannot be judged against this app — the page shows its premise does not
|
|
366
|
+
hold (the assumed constraint, field, or behaviour is designed differently), the target entity or
|
|
367
|
+
feature is not the one here, the scenario is irrelevant, OR systematic infrastructure failures
|
|
368
|
+
(LLM errors, crashes) prevented testing. NOT for "test failed to interact" — that's "fail" or
|
|
369
|
+
"continue".
|
|
364
370
|
- "continue": goal incomplete but the control for the NEXT step is present on the current page, or a
|
|
365
371
|
concrete missing check would change your verdict. Guidance must name that step.
|
|
366
372
|
If a verify() asserted a state that was ALREADY TRUE before the test, it proves nothing — reject.
|
|
@@ -922,14 +928,14 @@ export class Pilot {
|
|
|
922
928
|
const lines = [];
|
|
923
929
|
for (const [assertion, passed] of Object.entries(currentState.verifications ?? {})) {
|
|
924
930
|
if (passed)
|
|
925
|
-
lines.push(`
|
|
931
|
+
lines.push(`verify: ${assertion}`);
|
|
926
932
|
}
|
|
927
933
|
for (const exec of testerConversation.getToolExecutions()) {
|
|
928
934
|
if (!EVIDENCE_TOOLS.includes(exec.toolName) || !exec.wasSuccessful)
|
|
929
935
|
continue;
|
|
930
936
|
const description = exec.input?.assertion || exec.input?.request || truncateJson(exec.input);
|
|
931
|
-
const
|
|
932
|
-
lines.push(
|
|
937
|
+
const analysis = exec.output?.analysis;
|
|
938
|
+
lines.push(`${exec.toolName}: ${description}${analysis ? ` -> ${analysis}` : ''}`);
|
|
933
939
|
}
|
|
934
940
|
return [...new Set(lines)].join('\n');
|
|
935
941
|
}
|
|
@@ -1053,9 +1059,14 @@ export class Pilot {
|
|
|
1053
1059
|
|
|
1054
1060
|
YOUR Pilot-only tools, both over the API:
|
|
1055
1061
|
|
|
1056
|
-
askApi(question) —
|
|
1057
|
-
|
|
1058
|
-
|
|
1062
|
+
askApi(question) — read the app's data over the API. It changes nothing. Use when:
|
|
1063
|
+
|
|
1064
|
+
- Before precondition() — check whether suitable data already exists.
|
|
1065
|
+
- A step needs the exact name or id of an existing record.
|
|
1066
|
+
- The app reported success but the page does not show the result — ask whether it was stored.
|
|
1067
|
+
- A list or dropdown is empty — ask whether the data exists at all.
|
|
1068
|
+
|
|
1069
|
+
The page is not the only witness. A record missing from the page may still exist.
|
|
1059
1070
|
|
|
1060
1071
|
precondition(description) — create FRESH disposable test data. Never request users. Use when:
|
|
1061
1072
|
|
package/dist/src/ai/rerunner.js
CHANGED
|
@@ -18,6 +18,7 @@ import { formatHeadings } from "../utils/context-formatter.js";
|
|
|
18
18
|
import { createDebug, tag } from "../utils/logger.js";
|
|
19
19
|
import { loop } from "../utils/loop.js";
|
|
20
20
|
import { RulesLoader } from "../utils/rules-loader.js";
|
|
21
|
+
import { isInternalStep } from "../utils/step-analyzer.js";
|
|
21
22
|
import { toolExecutionLabel } from "./conversation.js";
|
|
22
23
|
import { actionRule, locatorRule, sectionContextRule } from "./rules.js";
|
|
23
24
|
import { TaskAgent } from "./task-agent.js";
|
|
@@ -70,6 +71,8 @@ export class Rerunner extends TaskAgent {
|
|
|
70
71
|
const onStepStarted = (step) => {
|
|
71
72
|
if (!step.toCode)
|
|
72
73
|
return;
|
|
74
|
+
if (isInternalStep(step))
|
|
75
|
+
return;
|
|
73
76
|
const code = highlight(step.toCode(), { language: 'javascript' });
|
|
74
77
|
console.log(chalk.dim(` ${code}`));
|
|
75
78
|
};
|
|
@@ -77,12 +80,16 @@ export class Rerunner extends TaskAgent {
|
|
|
77
80
|
const task = this.getCurrentTask(testMap);
|
|
78
81
|
if (!task || !step.toCode)
|
|
79
82
|
return;
|
|
83
|
+
if (isInternalStep(step))
|
|
84
|
+
return;
|
|
80
85
|
task.addStep(step.toCode(), step.duration, 'passed');
|
|
81
86
|
};
|
|
82
87
|
const onStepFailed = (step, error) => {
|
|
83
88
|
const task = this.getCurrentTask(testMap);
|
|
84
89
|
if (!task || !step.toCode)
|
|
85
90
|
return;
|
|
91
|
+
if (isInternalStep(step))
|
|
92
|
+
return;
|
|
86
93
|
task.addStep(step.toCode(), step.duration, 'failed', error?.message);
|
|
87
94
|
console.log(chalk.red(` ${figureSet.cross} ${step.toCode()} — ${error?.message || 'failed'}`));
|
|
88
95
|
};
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -1202,7 +1202,8 @@ async function extractWebElements(error) {
|
|
|
1202
1202
|
function formatElementList(matched) {
|
|
1203
1203
|
if (!matched)
|
|
1204
1204
|
return 'Could not fetch element details. Repeat the action to get better info.';
|
|
1205
|
-
|
|
1205
|
+
const keys = matched.map((el) => `${el.text}::${el.visible}::${el.html}`);
|
|
1206
|
+
const list = matched
|
|
1206
1207
|
.map((el, i) => {
|
|
1207
1208
|
const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
|
|
1208
1209
|
if (el.visible !== undefined)
|
|
@@ -1210,10 +1211,16 @@ function formatElementList(matched) {
|
|
|
1210
1211
|
const wrapped = matched.map((_, j) => j).filter((j) => j !== i && matched[j].xpath.startsWith(`${el.xpath}/`));
|
|
1211
1212
|
if (wrapped.length)
|
|
1212
1213
|
lines.push(`Wraps: element ${wrapped.map((j) => j + 1).join(', ')}`);
|
|
1214
|
+
const same = keys.map((_, j) => j).filter((j) => j !== i && keys[j] === keys[i]);
|
|
1215
|
+
if (same.length)
|
|
1216
|
+
lines.push(`Identical to element ${same.map((j) => j + 1).join(', ')}`);
|
|
1213
1217
|
lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
|
|
1214
1218
|
return lines.join('\n');
|
|
1215
1219
|
})
|
|
1216
1220
|
.join('\n\n');
|
|
1221
|
+
if (new Set(keys).size === matched.length)
|
|
1222
|
+
return list;
|
|
1223
|
+
return `${list}\n\nIdentical matches are not told apart by their number — picking one is a guess. Click the one you mean by appearance with visualClick().`;
|
|
1217
1224
|
}
|
|
1218
1225
|
export async function formatMatchedElements(error) {
|
|
1219
1226
|
return formatElementList(await extractWebElements(error));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSameHostFamily } from '../utils/url-matcher.js';
|
|
1
2
|
import { RequestResult, generateRequestId } from "./request-result.js";
|
|
2
3
|
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
3
4
|
const JSON_CONTENT_TYPES = /application\/json|application\/.*\+json/i;
|
|
@@ -33,7 +34,7 @@ export class XhrCapture {
|
|
|
33
34
|
return;
|
|
34
35
|
const method = request.method();
|
|
35
36
|
const url = request.url();
|
|
36
|
-
if (!url
|
|
37
|
+
if (!isSameHostFamily(url, this.baseOrigin))
|
|
37
38
|
return;
|
|
38
39
|
const status = response.status();
|
|
39
40
|
if (status >= 400) {
|
|
@@ -15,11 +15,13 @@ export declare class ConfigCommand extends BaseCommand {
|
|
|
15
15
|
}
|
|
16
16
|
interface ConfigSummaryOptions {
|
|
17
17
|
configPath?: string | null;
|
|
18
|
+
siteConfigPath?: string | null;
|
|
18
19
|
root?: string;
|
|
19
20
|
json?: boolean;
|
|
20
21
|
}
|
|
21
22
|
export interface ConfigData {
|
|
22
23
|
config: string;
|
|
24
|
+
siteConfig: string;
|
|
23
25
|
url: string;
|
|
24
26
|
browser: string;
|
|
25
27
|
headless: boolean;
|
|
@@ -13,7 +13,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
13
13
|
description = 'Show models, config file and paths used by this run';
|
|
14
14
|
async execute() {
|
|
15
15
|
const parser = ConfigParser.getInstance();
|
|
16
|
-
tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), root: parser.getProjectRoot() }));
|
|
16
|
+
tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot() }));
|
|
17
17
|
}
|
|
18
18
|
static async summary(options = {}) {
|
|
19
19
|
const parser = ConfigParser.getInstance();
|
|
@@ -24,12 +24,15 @@ export class ConfigCommand extends BaseCommand {
|
|
|
24
24
|
throw error;
|
|
25
25
|
return load(site.url);
|
|
26
26
|
});
|
|
27
|
-
return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json });
|
|
27
|
+
return ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json });
|
|
28
28
|
}
|
|
29
29
|
static data(config, options = {}) {
|
|
30
30
|
let configPath = '';
|
|
31
31
|
if (options.configPath && existsSync(options.configPath))
|
|
32
32
|
configPath = options.configPath;
|
|
33
|
+
let siteConfigPath = '';
|
|
34
|
+
if (options.siteConfigPath && existsSync(options.siteConfigPath))
|
|
35
|
+
siteConfigPath = options.siteConfigPath;
|
|
33
36
|
const dirs = {};
|
|
34
37
|
if (options.root) {
|
|
35
38
|
for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
|
|
@@ -55,6 +58,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
55
58
|
}
|
|
56
59
|
return {
|
|
57
60
|
config: configPath,
|
|
61
|
+
siteConfig: siteConfigPath,
|
|
58
62
|
url: config.playwright?.url || config.web?.url || config.api?.baseEndpoint || '',
|
|
59
63
|
browser: config.playwright?.browser || '',
|
|
60
64
|
headless: !config.playwright?.show,
|
|
@@ -75,6 +79,8 @@ export class ConfigCommand extends BaseCommand {
|
|
|
75
79
|
const lines = [];
|
|
76
80
|
const section = (title, entries) => lines.push(...renderSection(title, entries));
|
|
77
81
|
const general = [['config', data.config || 'EXPLORBOT_* environment variables']];
|
|
82
|
+
if (data.siteConfig)
|
|
83
|
+
general.push(['site config', data.siteConfig]);
|
|
78
84
|
if (data.url)
|
|
79
85
|
general.push(['url', data.url]);
|
|
80
86
|
if (data.browser) {
|
|
@@ -6,10 +6,7 @@ import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "..
|
|
|
6
6
|
import { getCliName } from "../utils/cli-name.js";
|
|
7
7
|
import { log, tag } from '../utils/logger.js';
|
|
8
8
|
import { relativeToCwd } from "../utils/next-steps.js";
|
|
9
|
-
function defaultConfigTemplate(provider
|
|
10
|
-
let moduleExport = 'module.exports = config;';
|
|
11
|
-
if (esm)
|
|
12
|
-
moduleExport = 'export default config;';
|
|
9
|
+
function defaultConfigTemplate(provider) {
|
|
13
10
|
return `// 'provider/model-id' uses a bundled provider.
|
|
14
11
|
// It is also possible to import provider as a module from Vercel AI SDK.
|
|
15
12
|
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
@@ -34,7 +31,7 @@ ${modelLines(provider)}
|
|
|
34
31
|
},
|
|
35
32
|
};
|
|
36
33
|
|
|
37
|
-
|
|
34
|
+
export default config;
|
|
38
35
|
`;
|
|
39
36
|
}
|
|
40
37
|
export function envTemplate(provider) {
|
|
@@ -128,8 +125,7 @@ export function runInitCommand(options) {
|
|
|
128
125
|
log('Use --force to overwrite existing file');
|
|
129
126
|
process.exit(1);
|
|
130
127
|
}
|
|
131
|
-
|
|
132
|
-
writeFileSync(outPath, defaultConfigTemplate(provider, esm), 'utf8');
|
|
128
|
+
writeFileSync(outPath, defaultConfigTemplate(provider), 'utf8');
|
|
133
129
|
log(`Created config file: ${relativeToCwd(outPath)}`);
|
|
134
130
|
const envPath = resolve(process.cwd(), '.env');
|
|
135
131
|
if (!existsSync(envPath)) {
|
|
@@ -234,6 +230,8 @@ export function modelLines(provider, only) {
|
|
|
234
230
|
function globalConfigTemplate(provider) {
|
|
235
231
|
const { envKey } = PROVIDERS[provider];
|
|
236
232
|
return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
|
|
233
|
+
// Settings shared by every site. Each site extends them in
|
|
234
|
+
// ~/.explorbot/sites/<host>/explorbot.config.js, written on its first run.
|
|
237
235
|
// Models are written as 'provider/model-id' so they resolve without a local node_modules.
|
|
238
236
|
// The key is read from ${envKey} in ~/.explorbot/.env
|
|
239
237
|
// Model ids are snapshotted from the recommendations of this Explorbot version.
|
|
@@ -251,27 +249,9 @@ ${modelLines(provider)}
|
|
|
251
249
|
},
|
|
252
250
|
};
|
|
253
251
|
|
|
254
|
-
|
|
252
|
+
export default config;
|
|
255
253
|
`;
|
|
256
254
|
}
|
|
257
|
-
function isModuleProject(configDir) {
|
|
258
|
-
let currentDir = resolve(configDir);
|
|
259
|
-
while (true) {
|
|
260
|
-
const packagePath = join(currentDir, 'package.json');
|
|
261
|
-
if (existsSync(packagePath)) {
|
|
262
|
-
try {
|
|
263
|
-
return JSON.parse(readFileSync(packagePath, 'utf8')).type === 'module';
|
|
264
|
-
}
|
|
265
|
-
catch {
|
|
266
|
-
return false;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
const parentDir = dirname(currentDir);
|
|
270
|
-
if (parentDir === currentDir)
|
|
271
|
-
return false;
|
|
272
|
-
currentDir = parentDir;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
255
|
function writeEnvKey(key, value) {
|
|
276
256
|
const envPath = globalEnvPath();
|
|
277
257
|
let content = '# AI provider API keys';
|