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
@@ -15,7 +15,7 @@ export class ConfigCommand extends BaseCommand {
15
15
 
16
16
  async execute(): Promise<void> {
17
17
  const parser = ConfigParser.getInstance();
18
- tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), root: parser.getProjectRoot() }));
18
+ tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot() }));
19
19
  }
20
20
 
21
21
  static async summary(options: { config?: string; path?: string; url?: string; json?: boolean } = {}): Promise<string> {
@@ -28,13 +28,16 @@ export class ConfigCommand extends BaseCommand {
28
28
  return load(site.url);
29
29
  });
30
30
 
31
- return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json });
31
+ return ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json });
32
32
  }
33
33
 
34
34
  static data(config: SummarizedConfig, options: ConfigSummaryOptions = {}): ConfigData {
35
35
  let configPath = '';
36
36
  if (options.configPath && existsSync(options.configPath)) configPath = options.configPath;
37
37
 
38
+ let siteConfigPath = '';
39
+ if (options.siteConfigPath && existsSync(options.siteConfigPath)) siteConfigPath = options.siteConfigPath;
40
+
38
41
  const dirs: Record<string, string> = {};
39
42
  if (options.root) {
40
43
  for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
@@ -60,6 +63,7 @@ export class ConfigCommand extends BaseCommand {
60
63
 
61
64
  return {
62
65
  config: configPath,
66
+ siteConfig: siteConfigPath,
63
67
  url: config.playwright?.url || config.web?.url || config.api?.baseEndpoint || '',
64
68
  browser: config.playwright?.browser || '',
65
69
  headless: !config.playwright?.show,
@@ -82,6 +86,7 @@ export class ConfigCommand extends BaseCommand {
82
86
  const section = (title: string, entries: [string, string][]) => lines.push(...renderSection(title, entries));
83
87
 
84
88
  const general: [string, string][] = [['config', data.config || 'EXPLORBOT_* environment variables']];
89
+ if (data.siteConfig) general.push(['site config', data.siteConfig]);
85
90
  if (data.url) general.push(['url', data.url]);
86
91
  if (data.browser) {
87
92
  let window = 'visible';
@@ -118,12 +123,14 @@ export class ConfigCommand extends BaseCommand {
118
123
 
119
124
  interface ConfigSummaryOptions {
120
125
  configPath?: string | null;
126
+ siteConfigPath?: string | null;
121
127
  root?: string;
122
128
  json?: boolean;
123
129
  }
124
130
 
125
131
  export interface ConfigData {
126
132
  config: string;
133
+ siteConfig: string;
127
134
  url: string;
128
135
  browser: string;
129
136
  headless: boolean;
@@ -7,10 +7,7 @@ import { getCliName } from '../utils/cli-name.ts';
7
7
  import { log, tag } from '../utils/logger.js';
8
8
  import { relativeToCwd } from '../utils/next-steps.ts';
9
9
 
10
- function defaultConfigTemplate(provider: string, esm: boolean): string {
11
- let moduleExport = 'module.exports = config;';
12
- if (esm) moduleExport = 'export default config;';
13
-
10
+ function defaultConfigTemplate(provider: string): string {
14
11
  return `// 'provider/model-id' uses a bundled provider.
15
12
  // It is also possible to import provider as a module from Vercel AI SDK.
16
13
  // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
@@ -35,7 +32,7 @@ ${modelLines(provider)}
35
32
  },
36
33
  };
37
34
 
38
- ${moduleExport}
35
+ export default config;
39
36
  `;
40
37
  }
41
38
 
@@ -144,8 +141,7 @@ export function runInitCommand(options: InitCommandOptions): void {
144
141
  process.exit(1);
145
142
  }
146
143
 
147
- const esm = extname(outPath) !== '.js' || isModuleProject(dirname(outPath));
148
- writeFileSync(outPath, defaultConfigTemplate(provider, esm), 'utf8');
144
+ writeFileSync(outPath, defaultConfigTemplate(provider), 'utf8');
149
145
  log(`Created config file: ${relativeToCwd(outPath)}`);
150
146
 
151
147
  const envPath = resolve(process.cwd(), '.env');
@@ -271,6 +267,8 @@ function globalConfigTemplate(provider: string): string {
271
267
  const { envKey } = PROVIDERS[provider];
272
268
 
273
269
  return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
270
+ // Settings shared by every site. Each site extends them in
271
+ // ~/.explorbot/sites/<host>/explorbot.config.js, written on its first run.
274
272
  // Models are written as 'provider/model-id' so they resolve without a local node_modules.
275
273
  // The key is read from ${envKey} in ~/.explorbot/.env
276
274
  // Model ids are snapshotted from the recommendations of this Explorbot version.
@@ -288,29 +286,10 @@ ${modelLines(provider)}
288
286
  },
289
287
  };
290
288
 
291
- module.exports = config;
289
+ export default config;
292
290
  `;
293
291
  }
294
292
 
295
- function isModuleProject(configDir: string): boolean {
296
- let currentDir = resolve(configDir);
297
-
298
- while (true) {
299
- const packagePath = join(currentDir, 'package.json');
300
- if (existsSync(packagePath)) {
301
- try {
302
- return JSON.parse(readFileSync(packagePath, 'utf8')).type === 'module';
303
- } catch {
304
- return false;
305
- }
306
- }
307
-
308
- const parentDir = dirname(currentDir);
309
- if (parentDir === currentDir) return false;
310
- currentDir = parentDir;
311
- }
312
- }
313
-
314
293
  function writeEnvKey(key: string, value: string): void {
315
294
  const envPath = globalEnvPath();
316
295
  let content = '# AI provider API keys';
@@ -1,4 +1,4 @@
1
- import { listSites, sitesDir } from '../global-config.js';
1
+ import { findSiteConfig, listSites, sitesDir } from '../global-config.js';
2
2
  import { getCliName } from '../utils/cli-name.js';
3
3
  import { tag } from '../utils/logger.js';
4
4
  import { BaseCommand } from './base-command.js';
@@ -20,6 +20,11 @@ export class SitesCommand extends BaseCommand {
20
20
  tag('info').log(`Registered sites (${sites.length}):`);
21
21
  for (const site of sites) {
22
22
  tag('info').log(` ${site.folder.padEnd(width)} ${site.url} last run ${site.lastRunAt.slice(0, 16).replace('T', ' ')}`);
23
+
24
+ let config = 'inherits global config';
25
+ const sitePath = findSiteConfig(site.dir);
26
+ if (sitePath) config = sitePath;
27
+ tag('info').log(` ${' '.repeat(width)} ${config}`);
23
28
  }
24
29
  tag('info').log('');
25
30
  tag('info').log(`Stored in ${sitesDir()}`);
package/src/config.ts CHANGED
@@ -5,8 +5,9 @@ import { pathToFileURL } from 'node:url';
5
5
  import { parseEnv } from 'node:util';
6
6
  import dedent from 'dedent';
7
7
  import matter from 'gray-matter';
8
- import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from './global-config.js';
8
+ import { EXPLORBOT_CONFIG_PATHS, type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, loadSiteConfig, registerSite, resolveSiteTarget } from './global-config.js';
9
9
  import { getCliName } from './utils/cli-name.js';
10
+ import { deepMerge } from './utils/merge.js';
10
11
  import { log, tag } from './utils/logger.js';
11
12
 
12
13
  export const PROVIDERS: Record<string, ProviderInfo> = {
@@ -268,7 +269,7 @@ const config: ExplorbotConfig = {
268
269
 
269
270
  type RuleEntry = string | Record<string, string>;
270
271
 
271
- export const EXPLORBOT_CONFIG_PATHS = ['explorbot.config.js', 'explorbot.config.mjs', 'explorbot.config.ts'];
272
+ export { EXPLORBOT_CONFIG_PATHS };
272
273
 
273
274
  export const EXPLORBOT_ENV_VARS: EnvVar[] = [
274
275
  { name: 'EXPLORBOT_AI_PROVIDER', required: true, description: 'Provider name; fills every model role from its recommended models. Turns on config-free mode' },
@@ -323,6 +324,7 @@ export class ConfigParser {
323
324
  private runtimeTarget: string | null = null;
324
325
  private site: SiteRecord | null = null;
325
326
  private siteStartPath = '/';
327
+ private siteConfigPath: string | null = null;
326
328
 
327
329
  private constructor() {}
328
330
 
@@ -407,16 +409,18 @@ export class ConfigParser {
407
409
  log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
408
410
  }
409
411
 
410
- this.config = this.resolveConfig(loadedConfig as ExplorbotConfig, options);
411
- await resolveConfigModels(this.config.ai);
412
- this.runtimeTarget = target;
413
- this.configPath = sourcePath;
412
+ let config = this.resolveConfig(loadedConfig as ExplorbotConfig, options);
413
+ await resolveConfigModels(config.ai);
414
414
  this.site = null;
415
+ this.siteConfigPath = null;
415
416
 
416
417
  if (resolvedPath && isGlobalConfigPath(resolvedPath)) {
417
- this.enterGlobalMode(this.config, target);
418
+ config = await this.enterGlobalMode(config, target);
418
419
  }
419
420
 
421
+ this.config = config;
422
+ this.runtimeTarget = target;
423
+ this.configPath = sourcePath;
420
424
  this.applyEnvSpec(this.config);
421
425
 
422
426
  // Restore original directory after successful config load
@@ -473,6 +477,10 @@ export class ConfigParser {
473
477
  return this.site;
474
478
  }
475
479
 
480
+ public getSiteConfigPath(): string | null {
481
+ return this.siteConfigPath;
482
+ }
483
+
476
484
  public resolveTargetPath(target?: string): string {
477
485
  if (!this.site) {
478
486
  const configured = this.config?.playwright?.url || this.config?.web?.url;
@@ -512,6 +520,7 @@ export class ConfigParser {
512
520
  ConfigParser.instance.runtimeTarget = null;
513
521
  ConfigParser.instance.site = null;
514
522
  ConfigParser.instance.siteStartPath = '/';
523
+ ConfigParser.instance.siteConfigPath = null;
515
524
  }
516
525
  }
517
526
 
@@ -563,16 +572,24 @@ export class ConfigParser {
563
572
  }
564
573
  }
565
574
 
566
- private enterGlobalMode(config: ExplorbotConfig, target: string | null): void {
575
+ private async enterGlobalMode(config: ExplorbotConfig, target: string | null): Promise<ExplorbotConfig> {
567
576
  const site = resolveSiteTarget(target || undefined, config.web?.url || config.playwright?.url);
568
577
  this.site = registerSite(site.baseUrl);
569
578
  this.siteStartPath = site.path;
570
579
 
571
- config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
572
- config.playwright = { ...config.playwright, browser: config.playwright?.browser || 'chromium', url: site.baseUrl };
580
+ const { path: sitePath, config: siteConfig } = await loadSiteConfig(this.site.dir, site.baseUrl);
581
+ this.siteConfigPath = sitePath;
582
+
583
+ const merged = deepMerge(config, siteConfig);
584
+ await resolveConfigModels(merged.ai);
585
+ resolveLangfuse(merged.ai);
586
+
587
+ merged.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
588
+ merged.playwright = { ...merged.playwright, browser: merged.playwright?.browser || 'chromium', url: site.baseUrl };
573
589
  materializeKnowledge(this.site.dir);
574
590
 
575
591
  log(`Global mode: ${site.baseUrl} stored in ${this.site.dir}`);
592
+ return merged;
576
593
  }
577
594
 
578
595
  private applyEnvSpec(config: ExplorbotConfig): void {
@@ -720,21 +737,7 @@ export class ConfigParser {
720
737
  },
721
738
  };
722
739
 
723
- return this.deepMerge(defaults, config);
724
- }
725
-
726
- private deepMerge(target: any, source: any): any {
727
- const result = { ...target };
728
-
729
- for (const key in source) {
730
- if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
731
- result[key] = this.deepMerge(result[key] || {}, source[key]);
732
- } else {
733
- result[key] = source[key];
734
- }
735
- }
736
-
737
- return result;
740
+ return deepMerge(defaults, config);
738
741
  }
739
742
 
740
743
  public ensureDirectory(path: string): void {
package/src/explorer.ts CHANGED
@@ -21,6 +21,7 @@ import { Test, TestResult } 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';
24
+ import { isInternalStep } from './utils/step-analyzer.ts';
24
25
 
25
26
  declare global {
26
27
  namespace NodeJS {
@@ -206,8 +207,7 @@ class Explorer {
206
207
 
207
208
  const stepHandler = (step: any, status?: string, error?: string, log?: string) => {
208
209
  if (!step.toCode) return;
209
- if (step?.name?.startsWith('grab')) return;
210
- if (step?.name?.startsWith('save')) return;
210
+ if (isInternalStep(step)) return;
211
211
 
212
212
  test.addStep(step.toCode(), step.duration, status, error, log);
213
213
 
@@ -1,10 +1,14 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import os from 'node:os';
3
- import { join } from 'node:path';
3
+ import { join, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import dedent from 'dedent';
4
6
 
5
7
  const GLOBAL_CONFIG_NAMES = ['config.js', 'config.mjs', 'config.ts'];
6
8
  const SITE_DIRS = ['knowledge', 'experience', 'output'];
7
9
 
10
+ export const EXPLORBOT_CONFIG_PATHS = ['explorbot.config.js', 'explorbot.config.mjs', 'explorbot.config.ts'];
11
+
8
12
  export function globalDir(): string {
9
13
  return join(os.homedir(), '.explorbot');
10
14
  }
@@ -18,11 +22,7 @@ export function globalConfigPath(): string {
18
22
  }
19
23
 
20
24
  export function findGlobalConfig(): string | null {
21
- for (const name of GLOBAL_CONFIG_NAMES) {
22
- const fullPath = join(globalDir(), name);
23
- if (existsSync(fullPath)) return fullPath;
24
- }
25
- return null;
25
+ return firstExisting(globalDir(), GLOBAL_CONFIG_NAMES);
26
26
  }
27
27
 
28
28
  export function isGlobalConfigPath(configPath: string): boolean {
@@ -47,6 +47,52 @@ export function listSites(): SiteRecord[] {
47
47
  .sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt));
48
48
  }
49
49
 
50
+ export function findSiteConfig(dir: string): string | null {
51
+ return firstExisting(dir, EXPLORBOT_CONFIG_PATHS);
52
+ }
53
+
54
+ function ensureSiteConfig(dir: string, baseUrl: string): string {
55
+ const existing = findSiteConfig(dir);
56
+ if (existing) return existing;
57
+
58
+ const path = join(dir, EXPLORBOT_CONFIG_PATHS[0]);
59
+ writeFileSync(path, siteConfigTemplate(baseUrl), 'utf8');
60
+ return path;
61
+ }
62
+
63
+ export async function loadSiteConfig(dir: string, baseUrl: string): Promise<{ path: string; config: any }> {
64
+ const path = ensureSiteConfig(dir, baseUrl);
65
+ const module = await import(pathToFileURL(resolve(path)).href);
66
+ const config = module.default || module;
67
+ validateSiteConfig(config, path, baseUrl);
68
+ return { path, config };
69
+ }
70
+
71
+ function validateSiteConfig(config: any, configPath: string, baseUrl: string): void {
72
+ const url = config?.web?.url;
73
+ if (!url) {
74
+ throw new Error(dedent`
75
+ Site config is missing web.url.
76
+ ${configPath}
77
+
78
+ Add it so the config states which site it configures:
79
+ web: { url: '${baseUrl}' },
80
+ `);
81
+ }
82
+
83
+ const declared = URL.parse(url)?.origin;
84
+ if (declared === baseUrl) return;
85
+
86
+ throw new Error(dedent`
87
+ Site config declares a different site.
88
+ ${configPath}
89
+ web.url: ${url}
90
+ site: ${baseUrl}
91
+
92
+ Fix web.url, or explore ${url} to register it as its own site.
93
+ `);
94
+ }
95
+
50
96
  export function findSiteWith(subpath: string): SiteRecord | undefined {
51
97
  return listSites().find((site) => existsSync(join(site.dir, subpath)));
52
98
  }
@@ -107,6 +153,35 @@ export function resolveSiteTarget(target?: string, defaultBaseUrl?: string): Sit
107
153
  return { baseUrl: site.url, path };
108
154
  }
109
155
 
156
+ function firstExisting(dir: string, names: string[]): string | null {
157
+ for (const name of names) {
158
+ const fullPath = join(dir, name);
159
+ if (existsSync(fullPath)) return fullPath;
160
+ }
161
+ return null;
162
+ }
163
+
164
+ function siteConfigTemplate(baseUrl: string): string {
165
+ return `// Config for ${baseUrl}
166
+ // Extends ~/.explorbot/config.js — set only what differs.
167
+ const config = {
168
+ web: {
169
+ url: '${baseUrl}',
170
+ },
171
+
172
+ // ai: {
173
+ // model: 'openrouter/openai/gpt-oss-120b',
174
+ // },
175
+
176
+ // playwright: {
177
+ // show: true,
178
+ // },
179
+ };
180
+
181
+ export default config;
182
+ `;
183
+ }
184
+
110
185
  function readSite(folder: string): SiteRecord | null {
111
186
  const dir = join(sitesDir(), folder);
112
187
  const metaPath = join(dir, 'site.json');
package/src/reporter.ts CHANGED
@@ -138,6 +138,7 @@ export class Reporter {
138
138
 
139
139
  protected combineStepsAndNotes(test: Test, lastScreenshotFile?: string): Step[] {
140
140
  const noteEntries = Object.entries(test.notes)
141
+ .filter(([, note]) => !note.observation)
141
142
  .map(([timestampKey, note]) => ({
142
143
  startTime: note.startTime,
143
144
  endTime: note.endTime,
@@ -272,9 +273,12 @@ export class Reporter {
272
273
  description: test.description,
273
274
  code: test.generatedCode || '',
274
275
  steps,
275
- logs: Object.values(test.steps)
276
- .map((stepData) => stepData.text)
277
- .join('\n'),
276
+ logs: [
277
+ ...Object.values(test.steps).map((stepData) => stepData.text),
278
+ ...Object.values(test.notes)
279
+ .filter((note) => note.observation)
280
+ .map((note) => note.message),
281
+ ].join('\n'),
278
282
  files: Object.values(test.artifacts) || [],
279
283
  message: test.summary || this.extractLastNoteMessage(test) || '',
280
284
  meta,
@@ -341,7 +345,7 @@ export class Reporter {
341
345
  }
342
346
 
343
347
  private extractLastNoteMessage(test: Test): string {
344
- const notes = Object.values(test.notes);
348
+ const notes = Object.values(test.notes).filter((note) => !note.observation);
345
349
  if (notes.length === 0) return '';
346
350
  return notes[notes.length - 1].message;
347
351
  }
package/src/utils/html.ts CHANGED
@@ -1420,6 +1420,12 @@ function cleanElement(element: parse5TreeAdapter.Element): void {
1420
1420
  'aria-labelledby',
1421
1421
  'aria-describedby',
1422
1422
  'aria-owns',
1423
+ 'aria-checked',
1424
+ 'aria-expanded',
1425
+ 'aria-selected',
1426
+ 'aria-pressed',
1427
+ 'aria-current',
1428
+ 'aria-disabled',
1423
1429
  'role',
1424
1430
  'title',
1425
1431
  'href',
@@ -215,7 +215,7 @@ class SpanDestination implements LogDestination {
215
215
  if (!step?.toCode) {
216
216
  return;
217
217
  }
218
- const stepName = step?.name ? `I.${step.name}` : 'I.step';
218
+ const stepName = step?.title ? `I.${step.title}` : 'I.step';
219
219
  const stepInput = typeof step?.toCode === 'function' ? step.toCode() : entry.content;
220
220
  const errorFromStep = step?.error;
221
221
  const errorMessage =
@@ -0,0 +1,13 @@
1
+ export function deepMerge(target: any, source: any): any {
2
+ const result = { ...target };
3
+
4
+ for (const key in source) {
5
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
6
+ result[key] = deepMerge(result[key] || {}, source[key]);
7
+ continue;
8
+ }
9
+ result[key] = source[key];
10
+ }
11
+
12
+ return result;
13
+ }
@@ -5,6 +5,8 @@ import { isDynamicId } from './xpath.ts';
5
5
  export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'] as const;
6
6
  export type CodeceptToolName = (typeof CODECEPT_TOOLS)[number];
7
7
 
8
+ const INTERNAL_STEP_PREFIXES = ['grab', 'save'];
9
+
8
10
  const CODECEPT_FORM_COMMANDS: readonly string[] = ['I.fillField', 'I.type', 'I.selectOption', 'I.attachFile', 'I.checkOption', 'I.uncheckOption'];
9
11
 
10
12
  export function isCodeceptToolName(toolName: string): toolName is CodeceptToolName {
@@ -71,3 +73,9 @@ export function mergeUniqueStepsByCode(primary: SessionStep[], secondary: Sessio
71
73
  }
72
74
  return merged;
73
75
  }
76
+
77
+ export function isInternalStep(step: { title?: string }): boolean {
78
+ const title = step?.title;
79
+ if (!title) return false;
80
+ return INTERNAL_STEP_PREFIXES.some((prefix) => title.startsWith(prefix));
81
+ }
@@ -132,3 +132,10 @@ export function matchesNavigationUrl(expected: string, current: string): boolean
132
132
  .filter(Boolean);
133
133
  return recordSegments.length > 0 && recordSegments.every(isDynamicSegment);
134
134
  }
135
+
136
+ export function isSameHostFamily(urlA: string, urlB: string): boolean {
137
+ const hostA = URL.parse(urlA)?.host.toLowerCase();
138
+ const hostB = URL.parse(urlB)?.host.toLowerCase();
139
+ if (!hostA || !hostB) return false;
140
+ return hostA === hostB || hostA.endsWith(`.${hostB}`) || hostB.endsWith(`.${hostA}`);
141
+ }