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.
Files changed (55) hide show
  1. package/boat/api-tester/src/cli.ts +1 -1
  2. package/boat/api-tester/src/config.ts +34 -26
  3. package/dist/boat/api-tester/src/cli.js +1 -1
  4. package/dist/boat/api-tester/src/config.js +32 -26
  5. package/dist/package.json +1 -1
  6. package/dist/src/action.js +4 -2
  7. package/dist/src/ai/fisherman/tools.js +10 -4
  8. package/dist/src/ai/navigator.d.ts +0 -1
  9. package/dist/src/ai/navigator.js +11 -24
  10. package/dist/src/ai/pilot.js +21 -10
  11. package/dist/src/ai/rerunner.js +7 -0
  12. package/dist/src/ai/tools.js +8 -1
  13. package/dist/src/api/xhr-capture.js +2 -1
  14. package/dist/src/commands/config-command.d.ts +2 -0
  15. package/dist/src/commands/config-command.js +8 -2
  16. package/dist/src/commands/init-command.js +6 -26
  17. package/dist/src/commands/sites-command.js +6 -1
  18. package/dist/src/config.d.ts +5 -4
  19. package/dist/src/config.js +25 -23
  20. package/dist/src/explorer.js +2 -3
  21. package/dist/src/global-config.d.ts +6 -0
  22. package/dist/src/global-config.js +82 -7
  23. package/dist/src/reporter.js +8 -4
  24. package/dist/src/utils/html.js +6 -0
  25. package/dist/src/utils/logger.js +1 -1
  26. package/dist/src/utils/merge.d.ts +1 -0
  27. package/dist/src/utils/merge.js +11 -0
  28. package/dist/src/utils/step-analyzer.d.ts +3 -0
  29. package/dist/src/utils/step-analyzer.js +7 -0
  30. package/dist/src/utils/url-matcher.d.ts +1 -0
  31. package/dist/src/utils/url-matcher.js +7 -0
  32. package/docs/reference/commands.md +10 -2
  33. package/docs/reference/configuration.md +37 -4
  34. package/docs/superpowers/plans/2026-09-15-mdq-package.md +2029 -0
  35. package/docs/superpowers/specs/2026-09-14-mdq-package-design.md +397 -0
  36. package/package.json +1 -1
  37. package/src/action.ts +4 -2
  38. package/src/ai/fisherman/tools.ts +10 -4
  39. package/src/ai/navigator.ts +9 -23
  40. package/src/ai/pilot.ts +21 -10
  41. package/src/ai/rerunner.ts +4 -0
  42. package/src/ai/tools.ts +7 -1
  43. package/src/api/xhr-capture.ts +2 -1
  44. package/src/commands/config-command.ts +9 -2
  45. package/src/commands/init-command.ts +6 -27
  46. package/src/commands/sites-command.ts +6 -1
  47. package/src/config.ts +28 -25
  48. package/src/explorer.ts +2 -2
  49. package/src/global-config.ts +81 -6
  50. package/src/reporter.ts +8 -4
  51. package/src/utils/html.ts +6 -0
  52. package/src/utils/logger.ts +1 -1
  53. package/src/utils/merge.ts +13 -0
  54. package/src/utils/step-analyzer.ts +8 -0
  55. 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
- this.config = this.mergeWithDefaults(loadedConfig);
115
- this.applyEnvSpec(this.config.api);
116
- this.applyEnvHeaders(this.config.api);
117
- if (options?.baseEndpoint) this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
118
- await resolveConfigModels(this.config.ai);
119
- resolveLangfuse(this.config.ai);
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(this.config, options?.endpoint);
126
+ config = await this.enterGlobalMode(config, options?.endpoint);
125
127
  }
126
128
 
127
- this.validateConfig(this.config);
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): void {
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
- config.dirs = { output: 'output', knowledge: 'knowledge' };
219
- config.api = { ...config.api, baseEndpoint };
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 this.deepMerge({ dirs: { output: 'output' }, api: {} }, config);
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
- this.config = this.mergeWithDefaults(loadedConfig);
80
- this.applyEnvSpec(this.config.api);
81
- this.applyEnvHeaders(this.config.api);
81
+ let config = this.mergeWithDefaults(loadedConfig);
82
+ this.applyEnvSpec(config.api);
83
+ this.applyEnvHeaders(config.api);
82
84
  if (options?.baseEndpoint)
83
- this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
84
- await resolveConfigModels(this.config.ai);
85
- resolveLangfuse(this.config.ai);
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(this.config, options?.endpoint);
91
+ config = await this.enterGlobalMode(config, options?.endpoint);
90
92
  }
91
- this.validateConfig(this.config);
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
- config.dirs = { output: 'output', knowledge: 'knowledge' };
179
- config.api = { ...config.api, baseEndpoint };
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 this.deepMerge({ dirs: { output: 'output' }, api: {} }, config);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -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.origin !== this.baseOrigin)
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.name?.startsWith('grab'))
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
- Ask what data already exists, changing nothing.
200
- Ask a question about existing records: which ones are there, what they are called, whether a particular one exists.
201
- Use it before precondition() to see whether suitable data is already available, and whenever a step needs the exact name or id of a record that is already there.
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 that already exists'),
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;
@@ -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
- try {
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
- lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`;
293
- tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
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,
@@ -95,7 +95,9 @@ export class Pilot {
95
95
  }
96
96
  }
97
97
  const schema = z.object({
98
- decision: z.enum(['pass', 'fail', 'continue', 'skipped']).describe('pass = test succeeded, fail = test failed, continue = tester should keep going, skipped = scenario is irrelevant OR systematic execution failures prevented testing'),
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": goal not achieved and no further step toward it is available on the current page.
362
- - "skipped": scenario is irrelevant to the app, OR systematic infrastructure failures (LLM errors,
363
- crashes) prevented testing. NOT for "test failed to interact"that's "fail" or "continue".
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(`state verification (passed): ${assertion}`);
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 result = exec.output?.message || exec.output?.analysis || exec.output?.result;
932
- lines.push(`CHECK ${exec.toolName} (executed successfully): ${description}${result ? ` -> ${result}` : ''}`);
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) — ask what data already exists. It changes nothing. Use it to check whether
1057
- suitable data is already there before creating any, and to get the exact name or id of an existing
1058
- record a step must act on.
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
 
@@ -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
  };
@@ -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
- return matched
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.startsWith(this.baseOrigin))
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, esm) {
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
- ${moduleExport}
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
- const esm = extname(outPath) !== '.js' || isModuleProject(dirname(outPath));
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
- module.exports = config;
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';