explorbot 0.5.2 → 0.5.4

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 (70) hide show
  1. package/bin/explorbot-cli.ts +3 -0
  2. package/boat/doc-collector/src/config.ts +1 -0
  3. package/boat/doc-collector/src/docbot.ts +10 -1
  4. package/boat/prima/src/envelope.ts +12 -6
  5. package/boat/prima/src/help.ts +4 -2
  6. package/boat/prima/src/prima.ts +37 -4
  7. package/boat/prima/src/session-log.ts +3 -3
  8. package/dist/bin/explorbot-cli.js +2 -0
  9. package/dist/boat/doc-collector/src/docbot.js +11 -1
  10. package/dist/boat/prima/src/envelope.js +12 -3
  11. package/dist/boat/prima/src/help.js +4 -2
  12. package/dist/boat/prima/src/prima.js +37 -4
  13. package/dist/boat/prima/src/session-log.js +3 -3
  14. package/dist/package.json +1 -1
  15. package/dist/rules/navigator/output.md +3 -3
  16. package/dist/src/action.js +6 -0
  17. package/dist/src/ai/driller.js +2 -28
  18. package/dist/src/ai/historian/codeceptjs.js +2 -0
  19. package/dist/src/ai/judge-tool.js +5 -1
  20. package/dist/src/ai/navigator.d.ts +5 -2
  21. package/dist/src/ai/navigator.js +15 -10
  22. package/dist/src/ai/pilot.d.ts +2 -7
  23. package/dist/src/ai/pilot.js +37 -7
  24. package/dist/src/ai/rerunner.js +3 -1
  25. package/dist/src/ai/researcher.js +10 -8
  26. package/dist/src/ai/rules.js +1 -1
  27. package/dist/src/ai/tester.js +11 -0
  28. package/dist/src/ai/tools.js +23 -7
  29. package/dist/src/config.d.ts +10 -3
  30. package/dist/src/config.js +42 -6
  31. package/dist/src/explorbot.js +5 -1
  32. package/dist/src/explorer.d.ts +2 -0
  33. package/dist/src/explorer.js +33 -1
  34. package/dist/src/global-config.js +1 -1
  35. package/dist/src/test-plan.d.ts +11 -0
  36. package/dist/src/test-plan.js +21 -0
  37. package/dist/src/utils/code-extractor.d.ts +1 -0
  38. package/dist/src/utils/code-extractor.js +40 -0
  39. package/dist/src/utils/html.d.ts +2 -0
  40. package/dist/src/utils/html.js +36 -1
  41. package/dist/src/utils/mdq/cli.js +3 -1
  42. package/dist/src/utils/test-files.d.ts +5 -0
  43. package/dist/src/utils/test-files.js +28 -1
  44. package/dist/src/utils/web-sandbox.js +4 -4
  45. package/docs/doc-collection/basics.md +13 -0
  46. package/docs/reference/commands.md +12 -0
  47. package/docs/web-testing/researcher.md +8 -0
  48. package/package.json +1 -1
  49. package/rules/navigator/output.md +3 -3
  50. package/src/action.ts +6 -0
  51. package/src/ai/driller.ts +2 -33
  52. package/src/ai/historian/codeceptjs.ts +2 -0
  53. package/src/ai/judge-tool.ts +5 -1
  54. package/src/ai/navigator.ts +15 -11
  55. package/src/ai/pilot.ts +38 -15
  56. package/src/ai/rerunner.ts +3 -1
  57. package/src/ai/researcher.ts +10 -9
  58. package/src/ai/rules.ts +1 -1
  59. package/src/ai/tester.ts +12 -0
  60. package/src/ai/tools.ts +21 -12
  61. package/src/config.ts +48 -6
  62. package/src/explorbot.ts +3 -1
  63. package/src/explorer.ts +37 -2
  64. package/src/global-config.ts +1 -1
  65. package/src/test-plan.ts +23 -0
  66. package/src/utils/code-extractor.ts +41 -0
  67. package/src/utils/html.ts +33 -1
  68. package/src/utils/mdq/cli.ts +3 -2
  69. package/src/utils/test-files.ts +27 -1
  70. package/src/utils/web-sandbox.ts +4 -4
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { relative, resolve } from 'node:path';
2
+ import { dirname, relative, resolve } from 'node:path';
3
3
  import { tool } from 'ai';
4
4
  import { createBashTool } from 'bash-tool';
5
5
  import chalk from 'chalk';
@@ -19,6 +19,7 @@ import { createDebug, tag } from '../utils/logger.ts';
19
19
  import { loop } from '../utils/loop.ts';
20
20
  import { RulesLoader } from '../utils/rules-loader.ts';
21
21
  import { isInternalStep } from '../utils/step-analyzer.ts';
22
+ import { ensureCodeceptResolvable } from '../utils/test-files.ts';
22
23
  import type { Agent, AgentDeps } from './agent.ts';
23
24
  import { toolExecutionLabel } from './conversation.ts';
24
25
  import type { Navigator } from './navigator.ts';
@@ -135,6 +136,7 @@ export class Rerunner extends TaskAgent implements Agent {
135
136
  codeceptjs.container.createMocha();
136
137
  const mocha = codeceptjs.container.mocha();
137
138
  mocha.reporter(class {});
139
+ ensureCodeceptResolvable(dirname(absPath));
138
140
  mocha.files = [absPath];
139
141
  mocha.loadFiles();
140
142
 
@@ -103,8 +103,8 @@ export class Researcher extends ResearcherBase implements Agent {
103
103
  `;
104
104
  }
105
105
 
106
- async research(state: WebPageState, opts: { screenshot?: boolean; force?: boolean; deep?: boolean; data?: boolean; fix?: boolean; _retriesLeft?: number } = {}): Promise<string> {
107
- const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
106
+ async research(state: WebPageState, opts: { screenshot?: boolean; force?: boolean; deep?: boolean; data?: boolean; fix?: boolean; light?: boolean; _retriesLeft?: number } = {}): Promise<string> {
107
+ const { screenshot = false, force = false, deep = false, data = false, fix = true, light = false } = opts;
108
108
  const maxRetries = this.settings.retries ?? 2;
109
109
  let retriesLeft = opts._retriesLeft ?? maxRetries;
110
110
  this.actionResult = ActionResult.fromState(state);
@@ -133,6 +133,7 @@ export class Researcher extends ResearcherBase implements Agent {
133
133
  return Observability.run(sessionName, { tags: ['researcher'], sessionId: stateHash }, async () => {
134
134
  const displayUrl = state.fullUrl || state.url;
135
135
  tag('info').log(`Researching ${displayUrl} to understand the context...`);
136
+ if (light) tag('info').log('Light research: single pass, locator verification skipped');
136
137
  setActivity(`${this.emoji} Researching...`, 'action');
137
138
 
138
139
  await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
@@ -171,7 +172,7 @@ export class Researcher extends ResearcherBase implements Agent {
171
172
  }
172
173
 
173
174
  const isOnCurrentState = this.actionResult!.getStateHash() === this.stateManager.getCurrentState()?.hash;
174
- this.hasScreenshotToAnalyze = screenshot && this.provider.hasVision() && isOnCurrentState;
175
+ this.hasScreenshotToAnalyze = !light && screenshot && this.provider.hasVision() && isOnCurrentState;
175
176
 
176
177
  const conversation = this.provider.startConversation(this.getSystemMessage(), 'researcher');
177
178
 
@@ -205,13 +206,13 @@ export class Researcher extends ResearcherBase implements Agent {
205
206
  result.parseLocators();
206
207
  debugLog(`Extracted ${result.locators.length} locators from research`);
207
208
 
208
- if (!interrupted() && result.locators.length === 0 && retriesLeft > 0) {
209
+ if (!light && !interrupted() && result.locators.length === 0 && retriesLeft > 0) {
209
210
  tag('warning').log(`No locators parsed, retrying research (${maxRetries - retriesLeft + 1}/${maxRetries})...`);
210
211
  await new Promise((r) => setTimeout(r, 1000));
211
212
  return this.research(state, { ...opts, force: true, _retriesLeft: retriesLeft - 1 } as any);
212
213
  }
213
214
 
214
- if (!interrupted()) {
215
+ if (!light && !interrupted()) {
215
216
  const brokenContainers = await this.resolveContainers(result);
216
217
  const containerCount = result.containers.length;
217
218
  if (containerCount > 0 && brokenContainers.length === containerCount && retriesLeft > 0) {
@@ -232,7 +233,7 @@ export class Researcher extends ResearcherBase implements Agent {
232
233
  }
233
234
 
234
235
  // Stage 3: Fix broken sections via AI conversation continuation
235
- if (!interrupted() && fix && result.locators.some((l) => l.valid === false)) {
236
+ if (!light && !interrupted() && fix && result.locators.some((l) => l.valid === false)) {
236
237
  await this.fixBrokenSections(result, activeConversation);
237
238
  }
238
239
 
@@ -273,12 +274,12 @@ export class Researcher extends ResearcherBase implements Agent {
273
274
  }
274
275
 
275
276
  // Stage 5: Backfill broken elements
276
- if (!interrupted()) {
277
+ if (!light && !interrupted()) {
277
278
  await this.backfillCoordinates(result);
278
279
  await this.backfillBrokenLocators(result);
279
280
  }
280
281
 
281
- if (!interrupted()) {
282
+ if (!light && !interrupted()) {
282
283
  await this.detectPagination(result);
283
284
  }
284
285
 
@@ -309,7 +310,7 @@ export class Researcher extends ResearcherBase implements Agent {
309
310
  result.cleanup();
310
311
 
311
312
  let researchFile: string | null = null;
312
- if (stateHash) {
313
+ if (stateHash && !light) {
313
314
  researchFile = saveResearch(researchState, result.text, combinedHtml);
314
315
  }
315
316
 
package/src/ai/rules.ts CHANGED
@@ -237,7 +237,7 @@ export const unexpectedPopupRule = dedent`
237
237
  If a modal/popup appeared that you didn't expect, dismiss it first before continuing with original task.
238
238
  If elements become hidden or unclickable (timeout errors on visible elements), a dialog or overlay may have appeared on top.
239
239
  If a click error mentions "intercepts pointer events", another element is covering the target — dismiss it first.
240
- If buttons are disabled unexpectedly, check if a popup is blocking interaction or if required form fields are empty.
240
+ If buttons are disabled unexpectedly, trust any explanation the app gives (tooltip, hint text) over your own guess; otherwise check for a blocking popup or empty required fields.
241
241
 
242
242
  Dismiss strategy (try in order):
243
243
  1. I.pressKey('Escape') — press Escape to dismiss
package/src/ai/tester.ts CHANGED
@@ -752,6 +752,16 @@ export class Tester extends TaskAgent implements Agent {
752
752
  }
753
753
 
754
754
  getSystemMessage(): string {
755
+ let judgeRule = '';
756
+ if (this.agentTools?.judge) {
757
+ judgeRule = dedent`
758
+ <judge_rule>
759
+ For a quick judgement about the current page (did the last action already take effect, which element the scenario means), ask judge() before see().
760
+ Keep see() for what only a screenshot shows.
761
+ </judge_rule>
762
+ `;
763
+ }
764
+
755
765
  return dedent`
756
766
  <role>
757
767
  You are a senior test automation engineer with expertise in CodeceptJS and exploratory testing.
@@ -851,6 +861,8 @@ export class Tester extends TaskAgent implements Agent {
851
861
 
852
862
  ${capabilityGroundingRule}
853
863
 
864
+ ${judgeRule}
865
+
854
866
  ${dataProtectionRules}
855
867
 
856
868
  ${this.provider.getSystemPromptForAgent('tester', this.stateManager.getCurrentState()?.url) || ''}
package/src/ai/tools.ts CHANGED
@@ -112,7 +112,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
112
112
  let ambiguityError: Error | null = null;
113
113
 
114
114
  for (let i = 0; i < commands.length; i++) {
115
- const command = transformContainsCommand(commands[i]);
115
+ let command = transformContainsCommand(commands[i]);
116
116
  let success = await action.attempt(command, explanation);
117
117
 
118
118
  const attempt: { command: string; success: boolean; error?: string } = { command, success };
@@ -125,10 +125,14 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
125
125
  const labels = (await extractWebElements(ambiguityError))?.map((el) => el.label) || [];
126
126
  const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
127
127
  if (index) success = await action.attemptExactElementIndex(command, index, explanation);
128
+ if (success) command = action.executedSteps.map((step) => step.command).join('\n') || command;
128
129
  }
129
130
  }
130
131
 
131
132
  if (success) {
133
+ attempt.command = command;
134
+ attempt.success = true;
135
+ attempt.error = undefined;
132
136
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
133
137
 
134
138
  if (!hasObservablePageChange(toolResult)) {
@@ -156,17 +160,22 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
156
160
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
157
161
 
158
162
  const suggestion = clickFailureSuggestion(attempts);
163
+ const data: Record<string, any> = { ...toolResult, attempts, suggestion };
164
+ const clickError = ambiguityError || action.lastError;
165
+
166
+ for (const disabled of attempts.filter((a) => a.error?.includes('not enabled'))) {
167
+ const hoverCommand = disabled.command.replace('I.click(', 'I.moveCursorTo(');
168
+ const beforeHover = ActionResult.fromState(stateManager.getCurrentState()!);
169
+ if (!(await action.attempt(hoverCommand, 'Hover the disabled element to reveal why it is disabled'))) continue;
170
+ const hoverResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(beforeHover, hoverCommand);
171
+ const shown = hoverResult.pageDiff?.messages;
172
+ if (!shown?.length) break;
173
+ data.disabledReason = shown;
174
+ data.suggestion = `Element is DISABLED. Hovering it showed: "${shown.join(' | ')}". That is why — act on it or report it, do not guess another cause.`;
175
+ break;
176
+ }
159
177
 
160
- return failedToolResult(
161
- 'click',
162
- 'All click commands failed',
163
- {
164
- ...toolResult,
165
- attempts,
166
- suggestion,
167
- },
168
- ambiguityError || action.lastError
169
- );
178
+ return failedToolResult('click', 'All click commands failed', data, clickError);
170
179
  },
171
180
  }),
172
181
 
@@ -1356,7 +1365,7 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
1356
1365
  const errors = attempts.map((a) => a.error || '');
1357
1366
 
1358
1367
  if (errors.some((e) => e.includes('not enabled'))) {
1359
- return 'Element exists but is DISABLED — clicking it again cannot work. A precondition is unmet: a required field is empty, nothing is selected, or a dialog is blocking. Satisfy it, then retry.';
1368
+ return 'Element exists but is DISABLED — clicking it again cannot work. Find the precondition the page shows as unmet, then retry.';
1360
1369
  }
1361
1370
 
1362
1371
  if (errors.some((e) => e.includes('intercepts pointer events'))) {
package/src/config.ts CHANGED
@@ -327,6 +327,7 @@ export class ConfigParser {
327
327
  private site: SiteRecord | null = null;
328
328
  private siteStartPath = '/';
329
329
  private siteConfigPath: string | null = null;
330
+ private runBase: RunBase | null = null;
330
331
 
331
332
  private constructor() {}
332
333
 
@@ -368,6 +369,7 @@ export class ConfigParser {
368
369
  if (this.config && !options?.config && !options?.path && this.runtimeTarget === target) {
369
370
  return this.config;
370
371
  }
372
+ this.runBase = parseRunBase(options?.baseUrl);
371
373
 
372
374
  // Store the initial working directory for reference
373
375
  if (!process.env.INITIAL_CWD) {
@@ -404,14 +406,21 @@ export class ConfigParser {
404
406
  let envUrl = options?.baseUrl;
405
407
  if (!envUrl && target?.startsWith('http')) envUrl = target;
406
408
 
407
- const outputRoot = resolveOutputRoot(process.env.EXPLORBOT_URL || envUrl);
409
+ if (options?.baseUrl) {
410
+ const envOrigin = URL.parse(process.env.EXPLORBOT_URL || '')?.origin;
411
+ const baseOrigin = URL.parse(options.baseUrl)?.origin;
412
+ if (envOrigin && envOrigin !== baseOrigin) {
413
+ tag('warning').log(`--base-url ${baseOrigin} selects the site for this run — EXPLORBOT_URL ${envOrigin} is ignored`);
414
+ }
415
+ }
416
+ const outputRoot = resolveOutputRoot(options?.baseUrl || process.env.EXPLORBOT_URL || envUrl);
408
417
  loadedConfig = await this.buildEnvConfig(envUrl, outputRoot);
409
418
  sourcePath = join(outputRoot, 'explorbot.config.js');
410
419
 
411
420
  log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
412
421
  }
413
422
 
414
- let config = this.resolveConfig(loadedConfig as ExplorbotConfig, options);
423
+ let config = this.resolveConfig(loadedConfig as ExplorbotConfig);
415
424
  await resolveConfigModels(config.ai);
416
425
  this.site = null;
417
426
  this.siteConfigPath = null;
@@ -484,6 +493,22 @@ export class ConfigParser {
484
493
  return this.siteConfigPath;
485
494
  }
486
495
 
496
+ public applyBasePath(target: string): string {
497
+ const base = this.runBase?.path;
498
+ if (!base) return target;
499
+ if (!target.startsWith('/')) return target;
500
+ if (target === base || target.startsWith(`${base}/`) || target.startsWith(`${base}?`)) return target;
501
+ return `${base}${target}`;
502
+ }
503
+
504
+ public getBaseQuery(): string {
505
+ return this.runBase?.query || '';
506
+ }
507
+
508
+ public getBasePath(): string {
509
+ return this.runBase?.path || '';
510
+ }
511
+
487
512
  public resolveTargetPath(target?: string): string {
488
513
  if (!this.site) {
489
514
  const configured = this.config?.playwright?.url || this.config?.web?.url;
@@ -492,12 +517,13 @@ export class ConfigParser {
492
517
  if (targetOrigin && baseOrigin && targetOrigin !== baseOrigin) {
493
518
  tag('warning').log(`Exploring ${targetOrigin} but base URL is ${baseOrigin}. Relative navigation resolves against the base URL — set web.url to ${targetOrigin} to avoid it.`);
494
519
  }
495
- return target || '/';
520
+ return this.applyBasePath(target || '/');
496
521
  }
497
522
  if (!target) return this.siteStartPath;
498
523
 
499
524
  const resolved = resolveSiteTarget(target, this.site.url);
500
525
  if (resolved.baseUrl !== this.site.url) return target;
526
+ if (target.startsWith('/')) return this.applyBasePath(resolved.path);
501
527
  return resolved.path;
502
528
  }
503
529
 
@@ -524,6 +550,7 @@ export class ConfigParser {
524
550
  ConfigParser.instance.site = null;
525
551
  ConfigParser.instance.siteStartPath = '/';
526
552
  ConfigParser.instance.siteConfigPath = null;
553
+ ConfigParser.instance.runBase = null;
527
554
  }
528
555
  }
529
556
 
@@ -694,15 +721,15 @@ export class ConfigParser {
694
721
  }
695
722
  }
696
723
 
697
- private resolveConfig(config: ExplorbotConfig, options?: { baseUrl?: string }): ExplorbotConfig {
724
+ private resolveConfig(config: ExplorbotConfig): ExplorbotConfig {
698
725
  if (config.web?.url && !config.playwright?.url) {
699
726
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
700
727
  config.playwright.url = config.web.url;
701
728
  }
702
729
 
703
- if (options?.baseUrl) {
730
+ if (this.runBase) {
704
731
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
705
- config.playwright.url = options.baseUrl;
732
+ config.playwright.url = this.runBase.origin;
706
733
  }
707
734
 
708
735
  resolveLangfuse(config.ai);
@@ -936,8 +963,23 @@ export async function createModel(provider: string, modelId: string): Promise<an
936
963
  return (await info.load())(modelId);
937
964
  }
938
965
 
966
+ function parseRunBase(baseUrl?: string): RunBase | null {
967
+ if (!baseUrl) return null;
968
+
969
+ const url = URL.parse(baseUrl);
970
+ if (!url) throw new Error(`Base URL must be a full URL like https://app.example.com/team/acme, got "${baseUrl}"`);
971
+
972
+ return { origin: url.origin, path: url.pathname.replace(/\/+$/, ''), query: url.search };
973
+ }
974
+
939
975
  type ModelRole = 'model' | 'visionModel' | 'agenticModel';
940
976
 
977
+ interface RunBase {
978
+ origin: string;
979
+ path: string;
980
+ query: string;
981
+ }
982
+
941
983
  interface ConfiguredModel {
942
984
  name: string;
943
985
  provider: string;
package/src/explorbot.ts CHANGED
@@ -155,7 +155,7 @@ export class ExplorBot {
155
155
  }
156
156
 
157
157
  async visit(url: string): Promise<void> {
158
- return this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
158
+ await this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
159
159
  }
160
160
 
161
161
  async openTab(): Promise<void> {
@@ -517,6 +517,8 @@ export class ExplorBot {
517
517
  setCurrentPlan(plan?: Plan): void {
518
518
  this.currentPlan = plan;
519
519
  if (!plan) return;
520
+ if (plan.url) plan.url = this.configParser.applyBasePath(plan.url);
521
+ for (const test of plan.tests) test.startUrl = this.configParser.applyBasePath(test.startUrl);
520
522
  if (!this.sessionPlans.includes(plan)) {
521
523
  this.sessionPlans.push(plan);
522
524
  }
package/src/explorer.ts CHANGED
@@ -6,7 +6,7 @@ import stepsListener from 'codeceptjs/lib/listener/steps';
6
6
  import storeListener from 'codeceptjs/lib/listener/store';
7
7
  import { createTest } from 'codeceptjs/lib/mocha/test';
8
8
  import dedent from 'dedent';
9
- import type { Browser, BrowserContextOptions, Page } from 'playwright';
9
+ import type { Browser, BrowserContextOptions, Page, Route } from 'playwright';
10
10
  import { ActionResult } from './action-result.ts';
11
11
  import Action from './action.js';
12
12
  import type { RequestStore } from './api/request-store.ts';
@@ -103,6 +103,7 @@ class Explorer {
103
103
  if (!this.playwrightHelper) {
104
104
  throw new Error('Playwright helper not available');
105
105
  }
106
+ this.applyBasePathToHelper();
106
107
  await this.connectOrLaunchBrowser();
107
108
  const hasSession = !this.options?.attachedBrowser && this.options?.session && existsSync(this.options.session);
108
109
  await this.openContextPage();
@@ -163,7 +164,7 @@ class Explorer {
163
164
 
164
165
  async visit(url: string, opts: CaptureOpts = {}): Promise<ActionResult> {
165
166
  return this.runWithRecovery('visit', async () => {
166
- const action = await this.visitOnce(url);
167
+ const action = await this.visitOnce(ConfigParser.getInstance().applyBasePath(url));
167
168
  if (opts.screenshot) return action.capturePageState({ includeScreenshot: true });
168
169
  return action.getActionResult() ?? action.capturePageState();
169
170
  });
@@ -347,6 +348,7 @@ class Explorer {
347
348
  const attached = this.options?.attachedBrowser;
348
349
  if (!attached) {
349
350
  await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
351
+ await this.pinBaseQuery();
350
352
  return;
351
353
  }
352
354
 
@@ -356,6 +358,39 @@ class Explorer {
356
358
  this.playwrightHelper.browserContext = context;
357
359
  await this.playwrightHelper._setPage(page);
358
360
  debugLog(`Adopted attached browser page: ${page.url()}`);
361
+ await this.pinBaseQuery();
362
+ }
363
+
364
+ private applyBasePathToHelper(): void {
365
+ if (!ConfigParser.getInstance().getBasePath()) return;
366
+
367
+ const helper = this.playwrightHelper;
368
+ const original = helper.amOnPage.bind(helper);
369
+ helper.amOnPage = (url: string) => original(ConfigParser.getInstance().applyBasePath(url));
370
+ }
371
+
372
+ private async pinBaseQuery(): Promise<void> {
373
+ const query = ConfigParser.getInstance().getBaseQuery();
374
+ if (!query) return;
375
+
376
+ const pinned = new URLSearchParams(query);
377
+ const origin = URL.parse(this.config.playwright.url)?.origin;
378
+
379
+ await this.playwrightHelper.browserContext.route('**/*', async (route: Route) => {
380
+ const request = route.request();
381
+ if (request.resourceType() !== 'document') return route.continue();
382
+
383
+ const url = URL.parse(request.url());
384
+ if (!url || url.origin !== origin) return route.continue();
385
+
386
+ const missing = [...pinned].filter(([key]) => !url.searchParams.has(key));
387
+ if (!missing.length) return route.continue();
388
+
389
+ for (const [key, value] of missing) url.searchParams.set(key, value);
390
+ return route.continue({ url: url.toString() });
391
+ });
392
+
393
+ tag('info').log(`Every page load of ${origin} carries ${query}`);
359
394
  }
360
395
 
361
396
  private createBrowserContextOptions(): BrowserContextOptions {
@@ -131,7 +131,7 @@ export function resolveSiteTarget(target?: string, defaultBaseUrl?: string): Sit
131
131
  }
132
132
 
133
133
  if (raw.startsWith('/')) {
134
- const base = defaultBaseUrl || process.env.EXPLORBOT_URL;
134
+ const base = process.env.EXPLORBOT_URL || defaultBaseUrl;
135
135
  if (!base) {
136
136
  throw new Error(withSites(`Cannot resolve path "${raw}" without a site.`));
137
137
  }
package/src/test-plan.ts CHANGED
@@ -66,6 +66,29 @@ export class ActiveNote {
66
66
  }
67
67
  }
68
68
 
69
+ export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
70
+
71
+ export class SettledExpectation {
72
+ constructor(
73
+ readonly text: string,
74
+ readonly status: SettledStatus,
75
+ readonly evidence?: string,
76
+ readonly resolution?: string
77
+ ) {}
78
+
79
+ get failed(): boolean {
80
+ return this.status === 'failed';
81
+ }
82
+
83
+ get contradicted(): boolean {
84
+ return this.status === 'contradiction';
85
+ }
86
+
87
+ resolve(status: SettledStatus, resolution: string): SettledExpectation {
88
+ return new SettledExpectation(this.text, status, this.evidence, resolution);
89
+ }
90
+ }
91
+
69
92
  export interface StepData {
70
93
  text: string;
71
94
  duration?: number;
@@ -3,6 +3,9 @@ import { createDebug } from './logger.js';
3
3
  const debugLog = createDebug('explorbot:code-extractor');
4
4
 
5
5
  const JS_LANGUAGES = new Set(['', 'js', 'javascript']);
6
+ const QUOTES = ["'", '"', '`'];
7
+ const OPENING_BRACKETS = ['(', '[', '{'];
8
+ const CLOSING_BRACKETS = [')', ']', '}'];
6
9
 
7
10
  export function extractCodeBlocks(aiResponse: string): string[] {
8
11
  const codeBlockRegex = /```([^\n`]*)\n([\s\S]*?)\n```/g;
@@ -24,3 +27,41 @@ export function extractCodeBlocks(aiResponse: string): string[] {
24
27
 
25
28
  return codeBlocks;
26
29
  }
30
+
31
+ export function splitTopLevel(code: string, separator: string): string[] {
32
+ const parts: string[] = [];
33
+ let current = '';
34
+ let depth = 0;
35
+ let quote = '';
36
+
37
+ for (let i = 0; i < code.length; i++) {
38
+ const char = code[i];
39
+
40
+ if (quote) {
41
+ current += char;
42
+ if (char === '\\') current += code[++i] || '';
43
+ if (char === quote) quote = '';
44
+ continue;
45
+ }
46
+
47
+ if (char === '/' && code[i + 1] === '/') {
48
+ while (i + 1 < code.length && code[i + 1] !== '\n') i++;
49
+ continue;
50
+ }
51
+
52
+ if (char === separator && depth <= 0) {
53
+ parts.push(current);
54
+ current = '';
55
+ depth = 0;
56
+ continue;
57
+ }
58
+
59
+ current += char;
60
+ if (QUOTES.includes(char)) quote = char;
61
+ if (OPENING_BRACKETS.includes(char)) depth++;
62
+ if (CLOSING_BRACKETS.includes(char)) depth--;
63
+ }
64
+
65
+ parts.push(current);
66
+ return parts;
67
+ }
package/src/utils/html.ts CHANGED
@@ -88,6 +88,7 @@ export const EXPLORBOT_ATTRS = {
88
88
  coveredBy: 'data-explorbot-covered-by',
89
89
  context: 'data-explorbot-context',
90
90
  eidx: 'data-explorbot-eidx',
91
+ hidden: 'data-explorbot-hidden',
91
92
  hit: 'data-explorbot-hit',
92
93
  variant: 'data-explorbot-variant',
93
94
  } as const;
@@ -189,6 +190,17 @@ export function captureHtmlForSnapshot(): string {
189
190
  }
190
191
  }
191
192
 
193
+ const liveElements = Array.from(document.body?.querySelectorAll('*') || []);
194
+ const clonedElements = Array.from(clone.querySelector('body')?.querySelectorAll('*') || []);
195
+ for (let i = 0; i < liveElements.length; i++) {
196
+ const source = liveElements[i];
197
+ if (source.matches('input, textarea, select, option, optgroup')) continue;
198
+ if (source.checkVisibility()) continue;
199
+ if (!source.parentElement?.checkVisibility()) continue;
200
+ if (getComputedStyle(source).display === 'contents') continue;
201
+ clonedElements[i]?.setAttribute('data-explorbot-hidden', 'true');
202
+ }
203
+
192
204
  return clone.outerHTML;
193
205
  }
194
206
 
@@ -1493,7 +1505,27 @@ function getAttribute(element: parse5TreeAdapter.Element, name: string): string
1493
1505
  return attr?.value;
1494
1506
  }
1495
1507
 
1508
+ function getImgAlt(element: parse5TreeAdapter.Element): string {
1509
+ let alt = '';
1510
+
1511
+ function processNode(node: parse5TreeAdapter.Node) {
1512
+ if (alt) return;
1513
+ if ('tagName' in node) {
1514
+ const el = node as parse5TreeAdapter.Element;
1515
+ if (el.tagName.toLowerCase() === 'img') {
1516
+ alt = getAttribute(el, 'alt') || '';
1517
+ return;
1518
+ }
1519
+ }
1520
+ if ('childNodes' in node) node.childNodes.forEach(processNode);
1521
+ }
1522
+
1523
+ processNode(element);
1524
+ return alt;
1525
+ }
1526
+
1496
1527
  function hasHiddenClass(element: parse5TreeAdapter.Element): boolean {
1528
+ if (getAttribute(element, EXPLORBOT_ATTRS.hidden)) return true;
1497
1529
  const classAttr = element.attrs.find((attr) => attr.name === 'class');
1498
1530
  if (!classAttr) return false;
1499
1531
 
@@ -1553,7 +1585,7 @@ export function extractLinks(html: string): ExtractedLink[] {
1553
1585
  if (href) {
1554
1586
  const shouldSkip = skipPrefixes.some((prefix) => href.startsWith(prefix));
1555
1587
  if (!shouldSkip) {
1556
- const rawTitle = getAttribute(element, 'aria-label') || getTextContent(element);
1588
+ const rawTitle = getAttribute(element, 'aria-label') || getTextContent(element) || getImgAlt(element);
1557
1589
  const title = sanitizeLinkTitle(rawTitle);
1558
1590
  if (title && title.length <= 100) {
1559
1591
  const key = `${href}|${title}`;
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
- import { Command } from 'commander';
2
+ import { Command, type CommanderError } from 'commander';
3
3
  import { type MarkdownQuery, MdqError, mdq } from './query.ts';
4
4
 
5
5
  const EDIT_FLAGS = ['remove', 'replace', 'insertBefore', 'insertAfter', 'prepend', 'append', 'addRow', 'addItem', 'set'] as const;
@@ -7,7 +7,7 @@ const EDIT_FLAGS = ['remove', 'replace', 'insertBefore', 'insertAfter', 'prepend
7
7
  export async function runMdq(argv: string[], readStdin: StdinReader): Promise<CliResult> {
8
8
  const program = new Command();
9
9
  program
10
- .name('mdq')
10
+ .name('mdq-cli')
11
11
  .description('query and edit markdown')
12
12
  .argument('[selector]', 'markdown selector')
13
13
  .argument('[file]', 'file to read; stdin when omitted')
@@ -31,6 +31,7 @@ export async function runMdq(argv: string[], readStdin: StdinReader): Promise<Cl
31
31
  try {
32
32
  program.parse(argv, { from: 'user' });
33
33
  } catch (error) {
34
+ if ((error as CommanderError).code === 'commander.helpDisplayed') return { output: program.helpInformation(), code: 0 };
34
35
  return { output: String((error as Error).message), code: 2 };
35
36
  }
36
37
 
@@ -1,4 +1,5 @@
1
- import { existsSync, readdirSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, symlinkSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import path from 'node:path';
3
4
  import chalk from 'chalk';
4
5
  import { highlight } from 'cli-highlight';
@@ -9,6 +10,8 @@ import store from 'codeceptjs/lib/store';
9
10
  import figureSet from 'figures';
10
11
  import { ConfigParser } from '../config.ts';
11
12
 
13
+ const nodeRequire = createRequire(import.meta.url);
14
+
12
15
  export function listTestFiles(testsDir: string): string[] {
13
16
  if (!existsSync(testsDir)) return [];
14
17
 
@@ -17,10 +20,32 @@ export function listTestFiles(testsDir: string): string[] {
17
20
  .map((f) => path.resolve(testsDir, f));
18
21
  }
19
22
 
23
+ /**
24
+ * Generated tests import `codeceptjs/steps`, but in global mode they lie in ~/.explorbot,
25
+ * far from any node_modules. Link the bot's own codeceptjs next to them so Node resolves it.
26
+ */
27
+ export function ensureCodeceptResolvable(dir: string): void {
28
+ const link = path.join(dir, 'node_modules', 'codeceptjs');
29
+ if (existsSync(link)) return;
30
+ let target: string;
31
+ try {
32
+ target = path.dirname(nodeRequire.resolve('codeceptjs/package.json'));
33
+ } catch {
34
+ return;
35
+ }
36
+ try {
37
+ mkdirSync(path.dirname(link), { recursive: true });
38
+ symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
39
+ } catch {
40
+ // Best effort: a project install already resolves codeceptjs without the link.
41
+ }
42
+ }
43
+
20
44
  export function loadTestSuites(testsDir: string): any[] {
21
45
  const jsFiles = listTestFiles(testsDir);
22
46
  if (jsFiles.length === 0) return [];
23
47
 
48
+ ensureCodeceptResolvable(testsDir);
24
49
  codeceptjs.container.createMocha();
25
50
  const mocha = codeceptjs.container.mocha();
26
51
  mocha.files = jsFiles;
@@ -93,6 +118,7 @@ export async function dryRunTestFile(filePath: string): Promise<void> {
93
118
  codeceptjs.container.createMocha();
94
119
  const mocha = codeceptjs.container.mocha();
95
120
  mocha.reporter(class {});
121
+ ensureCodeceptResolvable(path.dirname(absPath));
96
122
  mocha.files = [absPath];
97
123
  mocha.loadFiles();
98
124