explorbot 0.4.3 → 0.4.5

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 (87) hide show
  1. package/boat/api-tester/src/apibot.ts +8 -13
  2. package/boat/api-tester/src/cli.ts +7 -3
  3. package/boat/api-tester/src/config.ts +45 -9
  4. package/boat/prima/src/cli.ts +33 -99
  5. package/boat/prima/src/envelope.ts +3 -1
  6. package/boat/prima/src/help.ts +72 -0
  7. package/boat/prima/src/prima.ts +41 -46
  8. package/dist/boat/api-tester/src/apibot.js +7 -6
  9. package/dist/boat/api-tester/src/cli.js +9 -3
  10. package/dist/boat/api-tester/src/config.js +32 -6
  11. package/dist/boat/prima/src/cli.js +30 -86
  12. package/dist/boat/prima/src/envelope.js +2 -1
  13. package/dist/boat/prima/src/help.js +63 -0
  14. package/dist/boat/prima/src/prima.js +39 -44
  15. package/dist/package.json +1 -1
  16. package/dist/src/action-result.d.ts +3 -0
  17. package/dist/src/action-result.js +5 -0
  18. package/dist/src/action.js +12 -1
  19. package/dist/src/ai/fisherman/tools.js +7 -1
  20. package/dist/src/ai/fisherman.js +2 -1
  21. package/dist/src/ai/pilot.d.ts +0 -1
  22. package/dist/src/ai/pilot.js +8 -24
  23. package/dist/src/ai/planner.d.ts +4 -0
  24. package/dist/src/ai/planner.js +28 -0
  25. package/dist/src/ai/provider.js +3 -1
  26. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  27. package/dist/src/ai/researcher/deep-analysis.js +4 -1
  28. package/dist/src/ai/researcher/sections.d.ts +1 -1
  29. package/dist/src/ai/researcher/sections.js +2 -1
  30. package/dist/src/ai/researcher.js +25 -11
  31. package/dist/src/ai/rules.js +8 -7
  32. package/dist/src/ai/scout/tools.d.ts +17 -0
  33. package/dist/src/ai/scout/tools.js +130 -0
  34. package/dist/src/ai/scout.d.ts +21 -0
  35. package/dist/src/ai/scout.js +150 -0
  36. package/dist/src/ai/tester.d.ts +1 -0
  37. package/dist/src/ai/tester.js +27 -33
  38. package/dist/src/ai/tools.js +61 -30
  39. package/dist/src/application-spec.d.ts +3 -0
  40. package/dist/src/application-spec.js +21 -5
  41. package/dist/src/commands/config-command.js +6 -2
  42. package/dist/src/config.d.ts +9 -1
  43. package/dist/src/config.js +14 -0
  44. package/dist/src/explorbot.d.ts +3 -0
  45. package/dist/src/explorbot.js +33 -0
  46. package/dist/src/knowledge-tracker.d.ts +1 -0
  47. package/dist/src/knowledge-tracker.js +3 -0
  48. package/dist/src/state-manager.js +5 -1
  49. package/dist/src/utils/aria-ref.d.ts +16 -0
  50. package/dist/src/utils/aria-ref.js +47 -0
  51. package/dist/src/utils/aria.js +3 -3
  52. package/dist/src/utils/web-annotate.js +3 -15
  53. package/dist/src/utils/web-element.d.ts +0 -2
  54. package/dist/src/utils/web-element.js +0 -8
  55. package/docs/api-testing/basics.md +12 -4
  56. package/docs/reference/commands.md +1 -0
  57. package/docs/reference/configuration.md +28 -1
  58. package/docs/web-testing/agents.md +9 -1
  59. package/docs/web-testing/planner.md +5 -0
  60. package/docs/workflow/agentic-usage.md +3 -1
  61. package/docs/workflow/application-spec.md +4 -0
  62. package/package.json +1 -1
  63. package/src/action-result.ts +7 -0
  64. package/src/action.ts +14 -2
  65. package/src/ai/fisherman/tools.ts +8 -1
  66. package/src/ai/fisherman.ts +2 -1
  67. package/src/ai/pilot.ts +8 -25
  68. package/src/ai/planner.ts +33 -0
  69. package/src/ai/provider.ts +2 -1
  70. package/src/ai/researcher/deep-analysis.ts +4 -2
  71. package/src/ai/researcher/sections.ts +2 -2
  72. package/src/ai/researcher.ts +28 -11
  73. package/src/ai/rules.ts +8 -7
  74. package/src/ai/scout/tools.ts +150 -0
  75. package/src/ai/scout.ts +173 -0
  76. package/src/ai/tester.ts +25 -30
  77. package/src/ai/tools.ts +75 -36
  78. package/src/application-spec.ts +22 -4
  79. package/src/commands/config-command.ts +4 -1
  80. package/src/config.ts +23 -0
  81. package/src/explorbot.ts +36 -0
  82. package/src/knowledge-tracker.ts +4 -0
  83. package/src/state-manager.ts +6 -1
  84. package/src/utils/aria-ref.ts +61 -0
  85. package/src/utils/aria.ts +3 -3
  86. package/src/utils/web-annotate.ts +3 -15
  87. package/src/utils/web-element.ts +0 -9
@@ -20,20 +20,20 @@ import { Reporter } from '../../../src/reporter.ts';
20
20
  import type { WebPageState } from '../../../src/state-manager.ts';
21
21
  import { Stats } from '../../../src/stats.ts';
22
22
  import { Task, Test, TestResult } from '../../../src/test-plan.ts';
23
+ import { ariaRefSnapshot } from '../../../src/utils/aria-ref.ts';
23
24
  import { compactAriaSnapshot } from '../../../src/utils/aria.ts';
24
25
  import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
25
26
  import { pluralize } from '../../../src/utils/logger.ts';
26
27
  import { mdq } from '../../../src/utils/markdown-query.ts';
27
28
  import { safeFilename } from '../../../src/utils/strings.ts';
28
- import { type EnvelopeData, type InstanceInfo, STATUS_FILE, readArtifacts, writeArtifacts } from './envelope.ts';
29
+ import { type ArtifactPaths, type EnvelopeData, type InstanceInfo, STATUS_FILE, STEP_FILES, readArtifacts, writeArtifacts } from './envelope.ts';
29
30
  import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts';
30
31
  import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts';
31
32
  import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts';
32
33
 
33
- const TESTER_ONLY_TOOLS = ['learnExperience', 'askUser'];
34
+ const WITHHELD_TOOLS = ['learnExperience', 'askUser', 'research'];
34
35
  const ITERATIONS_PER_INSTRUCTION = 2;
35
36
  const MAX_INSTRUCTION_ITERATIONS = 24;
36
- const DEFAULT_RESEARCH_AFTER_VISITS = 3;
37
37
  const CONTEXT_HTML_CAP = 6000;
38
38
  const MAX_TOOL_ROUNDTRIPS = 5;
39
39
  const AI_AGENT_NAME = 'prima';
@@ -74,7 +74,6 @@ export class Prima {
74
74
  private server: { close: () => Promise<void> } | null = null;
75
75
  private attached: string | null = null;
76
76
  private session: SessionRun | null = null;
77
- private artifacts?: EnvelopeData['artifacts'];
78
77
 
79
78
  constructor(options: PrimaOptions = {}) {
80
79
  this.options = options;
@@ -108,6 +107,7 @@ export class Prima {
108
107
  const config = await this.loadConfig();
109
108
  await this.resolveBrowser(config, discovery);
110
109
  await this.bot.start();
110
+ this.bot.agentResearcher().disable();
111
111
 
112
112
  if (!this.options.url) return;
113
113
  if (this.bot.getCurrentState()) return;
@@ -325,11 +325,15 @@ export class Prima {
325
325
  return null;
326
326
  });
327
327
 
328
- if (settleError) trace.push({ label: 'settling which instructions were satisfied', ok: false, proof: browserErrorMessage(settleError) });
329
-
328
+ const stillOpen = ledger.filter((entry) => entry.status === 'open').length;
330
329
  for (const execution of invoked?.toolExecutions || []) {
331
330
  this.applyLedgerReport(execution, ledger, trace);
332
331
  }
332
+
333
+ let unsettled = '';
334
+ if (settleError) unsettled = browserErrorMessage(settleError);
335
+ if (invoked && ledger.filter((entry) => entry.status === 'open').length === stillOpen) unsettled = 'the model was asked to report every remaining instruction and reported none';
336
+ if (unsettled) trace.push({ label: 'settling which instructions were satisfied', ok: false, proof: unsettled });
333
337
  }
334
338
 
335
339
  private ledgerProgress(ledger: LedgerEntry[]): string {
@@ -422,9 +426,12 @@ export class Prima {
422
426
  const guard = await this.aiGuard(command);
423
427
  if (guard) return guard;
424
428
 
429
+ const researcher = this.bot.agentResearcher();
430
+ researcher.enable();
431
+
425
432
  const previousState = this.bot.stateManager().getCurrentState();
426
433
  const result = await this.capturedResult(previousState);
427
- const uiMap = await this.bot.agentResearcher().research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
434
+ const uiMap = await researcher.research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
428
435
  return this.reportEnvelope(command, result, previousState, { research: dropVolatileColumns(uiMap) });
429
436
  }
430
437
 
@@ -820,7 +827,7 @@ export class Prima {
820
827
  if (!researcher || !navigator) return {};
821
828
 
822
829
  const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
823
- for (const name of TESTER_ONLY_TOOLS) delete tools[name];
830
+ for (const name of WITHHELD_TOOLS) delete tools[name];
824
831
  return tools;
825
832
  }
826
833
 
@@ -878,14 +885,15 @@ export class Prima {
878
885
 
879
886
  private async pageContext(result: ActionResult): Promise<string> {
880
887
  const experience = this.bot.experienceTracker?.()?.renderExperienceTocFor?.(result) || '';
881
- const map = this.researchMap(result);
888
+ const map = getPreviousResearch(result.baseHash);
889
+ let uiMap = '';
882
890
  if (map) {
883
- return dedent`
884
- <page_ui_map url="${result.url}" title="${result.title}">
891
+ uiMap = dedent`
892
+ <page_ui_map>
893
+ A map of this page recorded by an earlier research run. It names parts the accessibility
894
+ tree does not, and can be out of date — the tree is what the page holds now.
885
895
  ${map}
886
896
  </page_ui_map>
887
-
888
- ${experience}
889
897
  `;
890
898
  }
891
899
 
@@ -894,21 +902,12 @@ export class Prima {
894
902
  ${compactAriaSnapshot(await this.refAriaSnapshot(result), true, (value) => this.offloadValue(value))}
895
903
  </page>
896
904
 
905
+ ${uiMap}
906
+
897
907
  ${experience}
898
908
  `;
899
909
  }
900
910
 
901
- private researchMap(result: ActionResult): string {
902
- if (this.bot.stateManager().getVisitCount(result.url) < this.researchAfterVisits()) return '';
903
- return getPreviousResearch(result.getStateHash());
904
- }
905
-
906
- private researchAfterVisits(): number {
907
- const configured = this.bot.getConfig?.()?.ai?.agents?.prima?.researchAfterVisits;
908
- if (typeof configured === 'number') return configured;
909
- return DEFAULT_RESEARCH_AFTER_VISITS;
910
- }
911
-
912
911
  private offloadValue(value: string): string | undefined {
913
912
  const dir = this.statusDir();
914
913
  const name = `value-${createHash('sha1').update(value).digest('hex').slice(0, 8)}.txt`;
@@ -922,7 +921,7 @@ export class Prima {
922
921
  }
923
922
 
924
923
  private async refAriaSnapshot(result: ActionResult): Promise<string | null> {
925
- const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.((page: any) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null);
924
+ const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.(ariaRefSnapshot)).catch(() => null);
926
925
  return snapshot || result.ariaSnapshot;
927
926
  }
928
927
 
@@ -982,16 +981,13 @@ export class Prima {
982
981
 
983
982
  private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise<EnvelopeData> {
984
983
  const changes = await this.pageChanges(result, previousState, used[0]);
985
- const status = await this.saveStatus(result);
986
984
  return {
987
985
  ok: true,
988
986
  command,
989
987
  used,
990
988
  page: this.pageBlock(result, previousState),
991
989
  changes,
992
- instance: await this.instanceInfo(),
993
- status,
994
- artifacts: this.artifacts,
990
+ ...(await this.envelopeTail(result)),
995
991
  };
996
992
  }
997
993
 
@@ -1000,31 +996,30 @@ export class Prima {
1000
996
  const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error) };
1001
997
  if (result.ariaSnapshot) failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
1002
998
 
1003
- const status = await this.saveStatus(result);
1004
999
  return {
1005
1000
  ok: false,
1006
1001
  command,
1007
1002
  page: this.pageBlock(result, previousState),
1008
1003
  failure,
1009
- instance: await this.instanceInfo(),
1010
- status,
1011
- artifacts: this.artifacts,
1004
+ ...(await this.envelopeTail(result)),
1012
1005
  };
1013
1006
  }
1014
1007
 
1015
1008
  private async reportEnvelope(command: string, result: ActionResult, previousState: WebPageState | null, outcome: Partial<EnvelopeData>): Promise<EnvelopeData> {
1016
- const status = await this.saveStatus(result);
1017
1009
  return {
1018
1010
  ok: true,
1019
1011
  command,
1020
1012
  page: this.pageBlock(result, previousState),
1021
1013
  ...outcome,
1022
- instance: await this.instanceInfo(),
1023
- status,
1024
- artifacts: this.artifacts,
1014
+ ...(await this.envelopeTail(result)),
1025
1015
  };
1026
1016
  }
1027
1017
 
1018
+ private async envelopeTail(result: ActionResult): Promise<Pick<EnvelopeData, 'instance' | 'status' | 'artifacts'>> {
1019
+ const { hash, artifacts } = await this.saveStatus(result);
1020
+ return { instance: await this.instanceInfo(), status: hash, artifacts };
1021
+ }
1022
+
1028
1023
  private async capturedResult(previousState: WebPageState | null, opts: { screenshot?: boolean } = {}): Promise<ActionResult> {
1029
1024
  const captured = await this.bot
1030
1025
  .getExplorer()
@@ -1086,11 +1081,11 @@ export class Prima {
1086
1081
  };
1087
1082
  }
1088
1083
 
1089
- private async saveStatus(result: ActionResult): Promise<string> {
1084
+ private async saveStatus(result: ActionResult): Promise<{ hash: string; artifacts: ArtifactPaths }> {
1090
1085
  const hash = this.statusHash();
1091
- await this.writeSnapshot(result);
1086
+ const artifacts = await this.writeSnapshot(result);
1092
1087
  writeFileSync(path.join(this.statusDir(hash), STATUS_FILE), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
1093
- return hash;
1088
+ return { hash, artifacts };
1094
1089
  }
1095
1090
 
1096
1091
  private async writeStepFiles(index: number, label: string, diff: string): Promise<void> {
@@ -1102,17 +1097,17 @@ export class Prima {
1102
1097
  const stem = path.join(dir, `${index}-${safeFilename(label.slice(0, 60))}`);
1103
1098
  const result = ActionResult.fromState(state);
1104
1099
 
1105
- writeFileSync(`${stem}.aria.yaml`, result.ariaSnapshot ?? '', 'utf-8');
1106
- writeFileSync(`${stem}.html`, await result.combinedHtml(), 'utf-8');
1107
- if (diff) writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8');
1100
+ writeFileSync(`${stem}.${STEP_FILES.aria}`, result.ariaSnapshot ?? '', 'utf-8');
1101
+ writeFileSync(`${stem}.${STEP_FILES.html}`, await result.combinedHtml(), 'utf-8');
1102
+ if (diff) writeFileSync(`${stem}.${STEP_FILES.diff}`, diff, 'utf-8');
1108
1103
  }
1109
1104
 
1110
- private async writeSnapshot(result: ActionResult): Promise<void> {
1111
- this.artifacts = writeArtifacts(this.statusDir(), {
1105
+ private async writeSnapshot(result: ActionResult): Promise<ArtifactPaths> {
1106
+ return writeArtifacts(this.statusDir(), {
1112
1107
  aria: result.ariaSnapshot,
1113
1108
  html: await result.combinedHtml(),
1114
1109
  screenshot: result.screenshot,
1115
- requests: this.bot.requestStore().getMadeRequests(),
1110
+ requests: this.bot.requestStore().getCapturedRequests(),
1116
1111
  });
1117
1112
  }
1118
1113
 
@@ -120,15 +120,14 @@ export class ApiBot {
120
120
  this.currentPlan = Plan.fromMarkdown(planPath);
121
121
  return this.currentPlan;
122
122
  }
123
- savePlan(filename) {
123
+ savePlan(suffix) {
124
124
  if (!this.currentPlan)
125
125
  return null;
126
126
  const plansDir = this.configParser.getPlansDir();
127
127
  if (!existsSync(plansDir)) {
128
128
  mkdirSync(plansDir, { recursive: true });
129
129
  }
130
- const planFilename = filename || this.generatePlanFilename();
131
- const planPath = path.join(plansDir, planFilename);
130
+ const planPath = path.join(plansDir, this.generatePlanFilename(suffix));
132
131
  this.currentPlan.saveToMarkdown(planPath);
133
132
  return planPath;
134
133
  }
@@ -162,9 +161,11 @@ export class ApiBot {
162
161
  return undefined;
163
162
  }
164
163
  }
165
- generatePlanFilename() {
164
+ generatePlanFilename(suffix) {
166
165
  const endpoint = this.currentPlan?.url || '/';
167
- const sanitized = endpoint.replace(/^\//, '').replace(/[^a-zA-Z0-9]/g, '_') || 'root';
168
- return `${sanitized.slice(0, 200)}.md`;
166
+ let name = endpoint.replace(/^\//, '').replace(/[^a-zA-Z0-9]/g, '_') || 'root';
167
+ if (suffix)
168
+ name = `${name}_${suffix}`;
169
+ return `${name.slice(0, 200)}.md`;
169
170
  }
170
171
  }
@@ -15,6 +15,7 @@ function buildOptions(options) {
15
15
  path: options.path,
16
16
  baseEndpoint: options.endpoint,
17
17
  spec: options.spec,
18
+ header: options.header,
18
19
  };
19
20
  }
20
21
  function addCommonOptions(cmd) {
@@ -24,7 +25,8 @@ function addCommonOptions(cmd) {
24
25
  .option('-c, --config <path>', 'Path to configuration file')
25
26
  .option('-p, --path <path>', 'Working directory path')
26
27
  .option('--endpoint <url>', 'Base API endpoint to test (env: EXPLORBOT_URL)')
27
- .option('--spec <path>', 'OpenAPI spec file or URL (env: EXPLORBOT_API_SPEC)');
28
+ .option('--spec <path>', 'OpenAPI spec file or URL (env: EXPLORBOT_API_SPEC)')
29
+ .option('-H, --header <header>', 'Header sent with every request, as "Name: value". Repeatable (env: EXPLORBOT_API_HEADERS)', (value, previous = []) => [...previous, value]);
28
30
  }
29
31
  function selectTests(tests, index) {
30
32
  if (!index || index === '*' || index === 'all') {
@@ -87,6 +89,8 @@ export function createApiCommands(name = 'api') {
87
89
  const [site] = listSites();
88
90
  const runOptions = buildOptions(options);
89
91
  runOptions.endpoint = endpoint || site?.url;
92
+ if (runOptions.endpoint && URL.canParse(runOptions.endpoint))
93
+ runOptions.baseEndpoint ||= runOptions.endpoint;
90
94
  try {
91
95
  const config = await parser.loadConfig(runOptions);
92
96
  console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
@@ -130,9 +134,11 @@ export function createApiCommands(name = 'api') {
130
134
  process.exit(1);
131
135
  }
132
136
  });
133
- addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan all styles, execute tests, re-plan')).action(async (endpoint, options) => {
137
+ addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan all styles, execute tests, re-plan. The endpoint may be the base endpoint itself')).action(async (endpoint, options) => {
134
138
  setPreserveConsoleLogs(true);
135
139
  try {
140
+ if (URL.canParse(endpoint))
141
+ options.endpoint ||= endpoint;
136
142
  const bot = new ApiBot({ ...buildOptions(options), endpoint });
137
143
  await bot.start();
138
144
  const styles = Object.keys(getStyles());
@@ -160,7 +166,7 @@ export function createApiCommands(name = 'api') {
160
166
  else
161
167
  totalFailed++;
162
168
  }
163
- bot.savePlan(`${endpoint.replace(/^\//, '').replace(/[^a-zA-Z0-9]/g, '_')}_${style}.md`);
169
+ bot.savePlan(style);
164
170
  }
165
171
  console.log('\n=== Final Results ===');
166
172
  console.log(`Total: ${totalTests} tests, ${totalPassed} passed, ${totalFailed} failed`);
@@ -10,8 +10,21 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
10
10
  import path, { resolve } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { parseEnv } from 'node:util';
13
- import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot } from "../../../src/config.js";
13
+ import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot, setOutputDir } from "../../../src/config.js";
14
14
  import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
15
+ function isAbsoluteEndpoint(value) {
16
+ return !!value && (value.startsWith('http://') || value.startsWith('https://'));
17
+ }
18
+ function parseHeaders(raw) {
19
+ const headers = {};
20
+ for (const line of raw.split('\n')) {
21
+ const separator = line.indexOf(':');
22
+ if (separator < 1)
23
+ continue;
24
+ headers[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
25
+ }
26
+ return headers;
27
+ }
15
28
  export class ApibotConfigParser {
16
29
  static instance;
17
30
  config = null;
@@ -65,6 +78,7 @@ export class ApibotConfigParser {
65
78
  }
66
79
  this.config = this.mergeWithDefaults(loadedConfig);
67
80
  this.applyEnvSpec(this.config.api);
81
+ this.applyEnvHeaders(this.config.api);
68
82
  if (options?.baseEndpoint)
69
83
  this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
70
84
  await resolveConfigModels(this.config.ai);
@@ -74,6 +88,7 @@ export class ApibotConfigParser {
74
88
  this.enterGlobalMode(this.config, options?.endpoint);
75
89
  }
76
90
  this.validateConfig(this.config);
91
+ setOutputDir(this.getOutputDir());
77
92
  return this.config;
78
93
  }
79
94
  finally {
@@ -103,12 +118,14 @@ export class ApibotConfigParser {
103
118
  return path.dirname(configPath);
104
119
  }
105
120
  resolveEndpointPath(endpoint) {
106
- if (!this.site)
121
+ if (!this.site && !isAbsoluteEndpoint(endpoint))
107
122
  return endpoint;
108
- const resolved = resolveSiteTarget(endpoint, this.site.url);
109
- if (resolved.baseUrl !== this.site.url)
123
+ const base = new URL(this.getConfig().api.baseEndpoint);
124
+ const origin = this.site?.url || base.origin;
125
+ const resolved = resolveSiteTarget(endpoint, origin);
126
+ if (resolved.baseUrl !== origin)
110
127
  return endpoint;
111
- const basePath = new URL(this.getConfig().api.baseEndpoint).pathname.replace(/\/$/, '');
128
+ const basePath = base.pathname.replace(/\/$/, '');
112
129
  if (!basePath)
113
130
  return resolved.path;
114
131
  if (resolved.path === basePath)
@@ -137,12 +154,19 @@ export class ApibotConfigParser {
137
154
  process.env.EXPLORBOT_URL = options.baseEndpoint;
138
155
  if (options?.spec)
139
156
  process.env.EXPLORBOT_API_SPEC = options.spec;
157
+ if (options?.header?.length)
158
+ process.env.EXPLORBOT_API_HEADERS = options.header.join('\n');
140
159
  }
141
160
  applyEnvSpec(api) {
142
161
  if (!process.env.EXPLORBOT_API_SPEC)
143
162
  return;
144
163
  api.spec = [process.env.EXPLORBOT_API_SPEC];
145
164
  }
165
+ applyEnvHeaders(api) {
166
+ if (!process.env.EXPLORBOT_API_HEADERS)
167
+ return;
168
+ api.headers = { ...api.headers, ...parseHeaders(process.env.EXPLORBOT_API_HEADERS) };
169
+ }
146
170
  enterGlobalMode(config, endpoint) {
147
171
  const site = resolveSiteTarget(endpoint);
148
172
  this.site = registerSite(site.baseUrl);
@@ -163,7 +187,7 @@ export class ApibotConfigParser {
163
187
  if (modelSpec && !provider && !modelSpec.includes('/')) {
164
188
  throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
165
189
  }
166
- const baseEndpoint = process.env.EXPLORBOT_URL;
190
+ const baseEndpoint = process.env.EXPLORBOT_URL?.replace(/\/$/, '');
167
191
  if (!baseEndpoint) {
168
192
  throw new Error('No API endpoint to test. Pass --endpoint or set EXPLORBOT_URL to the API base endpoint');
169
193
  }
@@ -171,6 +195,7 @@ export class ApibotConfigParser {
171
195
  materializeKnowledge(outputRoot);
172
196
  const api = { baseEndpoint };
173
197
  this.applyEnvSpec(api);
198
+ this.applyEnvHeaders(api);
174
199
  let model;
175
200
  if (provider && modelSpec)
176
201
  model = await createModel(provider, modelSpec);
@@ -185,6 +210,7 @@ export class ApibotConfigParser {
185
210
  };
186
211
  this.configPath = path.join(outputRoot, 'apibot.config.js');
187
212
  this.validateConfig(this.config);
213
+ setOutputDir(this.getOutputDir());
188
214
  return this.config;
189
215
  }
190
216
  findConfigFile() {
@@ -1,73 +1,12 @@
1
1
  import { Command } from 'commander';
2
- import dedent from 'dedent';
3
2
  import { keepServerRunning } from "../../../src/browser-server.js";
4
3
  import { RecommendedModelsCommand } from "../../../src/commands/recommended-models-command.js";
5
4
  import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
6
5
  import { isVerboseMode, setQuietMode } from "../../../src/utils/logger.js";
7
6
  import { clearActivityLine, trackActivityLine } from "./activity-line.js";
8
7
  import { renderEnvelope } from "./envelope.js";
8
+ import { askHelp, checkHelp, doHelp, helpContract, reportHelp, researchHelp, sessionHelp, statusHelp, verifyHelp } from "./help.js";
9
9
  import { Prima } from "./prima.js";
10
- const helpContract = dedent `
11
- Prima is a high-level AI extension to playwright-cli, driving the browser it has open.
12
-
13
- playwright-cli open <url> starts the session
14
- prima <command> ... drives it
15
- playwright-cli close ends it
16
-
17
- One call takes a whole job:
18
-
19
- prima check "a workflow can be created and appears in the list" --expected "the new workflow is listed"
20
- prima do "open the account menu" "choose the settings entry" "switch the theme to dark" "check it took effect"
21
- prima pw "({ page }) => page.click('[data-test=submit]')"
22
- `;
23
- const checkHelp = dedent `
24
- check takes an outcome rather than a click path, and works out how to reach it. It runs
25
- on the page you are already on and never reloads it, so an open dialog survives the check.
26
- --expected one outcome the run must reach, repeatable for several. Without it the
27
- scenario text is the single expected outcome. Each comes back under
28
- ### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
29
- "not verified" means the run never checked it, which is not the same
30
- as false.
31
- Outcomes are settled against a screenshot of the whole page: what a user can see is
32
- the proof, and the run log only says what was done. CONTRADICTION means the two
33
- disagree - reported with both sides rather than settled one way, so read the html,
34
- aria and screenshot named under ### Artifacts and judge it yourself. Not finding
35
- something in the picture is not enough on its own; that is "not verified".
36
- ok: follows those outcomes - false when one FAILED or CONTRADICTED, or when the run
37
- could not complete, which is reported as such rather than as an app failure.
38
- Page problems seen on the way appear under ### Answer, not as step failures.
39
- `;
40
- const doHelp = dedent `
41
- Each instruction is numbered and accounted for: ### Steps reports each as ok, FAIL or ??.
42
- ?? means the action ran but the run ended without confirming that instruction - read the
43
- steps above it. Only FAIL and an instruction the page could not carry out fail the command.
44
- Nothing runs past the last instruction given. A whole remaining sequence in one call is
45
- what makes this tier cheap.
46
- `;
47
- const verifyHelp = dedent `
48
- Reports each assertion it could express as PASSED or FAILED with its playwright form,
49
- and gives no overall verdict - read the lines and decide. "none ran" means the claim
50
- could not be expressed, which is not the same as false.
51
- `;
52
- const statusHelp = dedent `
53
- Reads the files a command recorded, so it needs no browser and outlives the session.
54
- The hash is looked up across every recorded site. ### Artifacts names every file kept
55
- under it: the aria tree, the html, the screenshot and network log when they were
56
- captured, and the per-step captures of a do run.
57
- `;
58
- const reportHelp = dedent `
59
- Commands are logged as they run, so the report needs no browser and outlives the session.
60
- The most recent session is reported unless --pw-session names another.
61
- `;
62
- const sessionHelp = dedent `
63
- --endpoint <ep> attach to a browser server endpoint directly, skipping discovery
64
- --instance <name> which prima-owned browser you talk to; parallel work needs one each
65
- --session [file] cookies and storage persisted across processes; ignored while
66
- attached, since the attached session keeps its own
67
- --framework parsed but not active yet; reported code is CodeceptJS either way
68
- DEBUG='explorbot:*' in front of a command prints the log of everything it does.
69
- When no AI model is usable pw still works; for everything else drive playwright-cli.
70
- `;
71
10
  let rootOptions = () => ({});
72
11
  function buildOptions(subcommand) {
73
12
  const options = { ...rootOptions(), ...stripEmpty(subcommand) };
@@ -104,13 +43,15 @@ function addCommonOptions(cmd) {
104
43
  .option('--session [file]', 'Persist cookies and storage to a session file')
105
44
  .option('--model <model>', 'Main model, as provider/model-id')
106
45
  .option('--vision-model <model>', 'Model for screenshot analysis, as provider/model-id')
107
- .option('--ephemeral', 'Keep no state between runs; applies to config-free runs, where output goes to a temp directory')
108
- .option('--framework <name>', 'Not active yet: framework the reported code targets, codeceptjs or playwright')
46
+ .option('--ephemeral', 'Keep no state; config-free runs use a temp directory')
47
+ .option('--framework <name>', 'Inactive: reported code targets codeceptjs or playwright')
109
48
  .option('--url <url>', 'Page to open when the session has no page yet')
110
49
  .option('--spec <path>', 'Docbot application spec directory or index.md to read as page knowledge')
111
50
  .option('--endpoint <ep>', 'Websocket endpoint of a browser server to attach to, skipping discovery')
112
- .option('--pw-session <title>', 'Title of the playwright-cli session to attach to')
113
- .addHelpText('after', `\n${sessionHelp}`);
51
+ .option('--pw-session <title>', 'Title of the playwright-cli session to attach to');
52
+ }
53
+ function addBrowserOptions(cmd) {
54
+ return addCommonOptions(cmd).addHelpText('after', `\n${sessionHelp}`);
114
55
  }
115
56
  function primaFor(options) {
116
57
  Prima.applyEnv();
@@ -124,20 +65,23 @@ function primaFor(options) {
124
65
  process.env.EXPLORBOT_SPEC = options.spec;
125
66
  return new Prima(buildOptions(options));
126
67
  }
127
- async function runPrima(options, command, run) {
68
+ async function runPrima(options, command, run, opts = {}) {
69
+ const { browser = true, record = true } = opts;
128
70
  setQuietMode(!isVerboseMode());
129
71
  trackActivityLine();
130
72
  const prima = primaFor(options);
131
73
  const startedAt = Date.now();
132
74
  let envelope;
133
75
  try {
134
- await prima.start();
76
+ if (browser)
77
+ await prima.start();
135
78
  envelope = await run(prima);
136
79
  }
137
80
  catch (error) {
138
81
  envelope = await prima.toolFailureEnvelope(command, error);
139
82
  }
140
- prima.record(envelope, Date.now() - startedAt);
83
+ if (record)
84
+ prima.record(envelope, Date.now() - startedAt);
141
85
  clearActivityLine();
142
86
  console.log(renderEnvelope(envelope));
143
87
  await prima.stop().catch(() => { });
@@ -156,37 +100,41 @@ async function runBrowser(options, run) {
156
100
  }
157
101
  export function createPrimaCommands(name = 'prima') {
158
102
  const cmd = new Command(name);
159
- cmd.description('Tests and drives a web app through described behaviour instead of locators: one command carries a whole scenario, verifies it, and reports the proof');
103
+ cmd.description('Drives a web app through described behaviour, not locators');
160
104
  cmd.option('--pw-session <title>', 'Title of the playwright-cli session to attach to');
161
105
  cmd.option('--url <url>', 'Page to open when the session has no page yet');
162
106
  cmd.addHelpText('after', `\n${helpContract}`);
163
107
  rootOptions = () => cmd.opts();
164
- addCommonOptions(cmd.command('pw <fn>').description('Run a Playwright function expression against the open page')).action(async (fn, options) => {
108
+ addBrowserOptions(cmd.command('pw <fn>').description('Run a Playwright function expression against the open page')).action(async (fn, options) => {
165
109
  await runPrima(options, `pw ${fn}`, (prima) => prima.pw(fn));
166
110
  });
167
- addCommonOptions(cmd.command('do <instructions...>').description('Run high-level instructions tester-style, one argument per instruction'))
111
+ addBrowserOptions(cmd.command('do <instructions...>').description('Run instructions, one argument each'))
168
112
  .addHelpText('after', `\n${doHelp}`)
169
113
  .action(async (instructions, options) => {
170
114
  await runPrima(options, `do ${instructions.join(' ')}`, (prima) => prima.do(instructions));
171
115
  });
172
- addCommonOptions(cmd.command('check <scenario>').description('Run a scenario end to end as a test, with its own verification, and report the steps it took'))
116
+ addBrowserOptions(cmd.command('check <scenario>').description('Run a scenario as a test, with verification'))
173
117
  .option('--expected <outcome>', 'An outcome the run must reach; repeat the flag for several', (value, all) => [...all, value], [])
174
118
  .addHelpText('after', `\n${checkHelp}`)
175
119
  .action(async (scenario, options) => {
176
120
  await runPrima(options, `check ${scenario}`, (prima) => prima.check(scenario, options.expected));
177
121
  });
178
- addCommonOptions(cmd.command('ask <question>').description('Answer a question about the current page').option('--no-vision', 'Answer from page structure only, without a screenshot')).action(async (question, options) => {
122
+ addBrowserOptions(cmd.command('ask <question>').description('Answer a question about the current page').option('--no-vision', 'Answer from page structure only, without a screenshot'))
123
+ .addHelpText('after', `\n${askHelp}`)
124
+ .action(async (question, options) => {
179
125
  await runPrima(options, `ask ${question}`, (prima) => prima.ask(question));
180
126
  });
181
- addCommonOptions(cmd.command('verify <assertion>').alias('assert').description('Assert a statement about the current page'))
127
+ addBrowserOptions(cmd.command('verify <assertion>').alias('assert').description('Assert a statement about the current page'))
182
128
  .addHelpText('after', `\n${verifyHelp}`)
183
129
  .action(async (assertion, options) => {
184
130
  await runPrima(options, `verify ${assertion}`, (prima) => prima.verify(assertion));
185
131
  });
186
- addCommonOptions(cmd.command('research').description('Map the current page and return verified locators').option('--data', 'Include data extraction in the map').option('--deep', 'Expand hidden elements for a deeper map').option('--fresh', 'Ignore the cached map and research the page again')).action(async (options) => {
132
+ addBrowserOptions(cmd.command('research').description('Map the current page and return verified locators').option('--data', 'Include data extraction in the map').option('--deep', 'Expand hidden elements for a deeper map').option('--fresh', 'Ignore the cached map and research the page again'))
133
+ .addHelpText('after', `\n${researchHelp}`)
134
+ .action(async (options) => {
187
135
  await runPrima(options, 'research', (prima) => prima.research({ data: options.data, deep: options.deep, fresh: options.fresh }));
188
136
  });
189
- addCommonOptions(cmd.command('go <target>').description('Navigate to a url, a path, or a page described in plain words')).action(async (target, options) => {
137
+ addBrowserOptions(cmd.command('go <target>').description('Navigate to a url, a path, or a page described in plain words')).action(async (target, options) => {
190
138
  if (URL.canParse(target))
191
139
  options.baseUrl = target;
192
140
  await runPrima(options, `go ${target}`, (prima) => prima.go(target));
@@ -204,11 +152,7 @@ export function createPrimaCommands(name = 'prima') {
204
152
  addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
205
153
  .addHelpText('after', `\n${statusHelp}`)
206
154
  .action(async (hash, options) => {
207
- setQuietMode(!isVerboseMode());
208
- const prima = primaFor(options);
209
- const envelope = await prima.status(hash).catch((error) => prima.toolFailureEnvelope(`status ${hash}`, error));
210
- console.log(renderEnvelope(envelope));
211
- process.exit(envelope.ok ? 0 : 1);
155
+ await runPrima(options, `status ${hash}`, (prima) => prima.status(hash), { browser: false, record: false });
212
156
  });
213
157
  addCommonOptions(cmd.command('report').description('Turn every command of a session into one html and markdown report'))
214
158
  .addHelpText('after', `\n${reportHelp}`)
@@ -220,7 +164,7 @@ export function createPrimaCommands(name = 'prima') {
220
164
  process.exit(0);
221
165
  });
222
166
  const browser = cmd.command('browser').description('Manage the browsers prima drives');
223
- addCommonOptions(browser.command('start').description('Start a prima-owned browser and hold it open until Ctrl+C'))
167
+ addBrowserOptions(browser.command('start').description('Start a prima-owned browser and hold it open until Ctrl+C'))
224
168
  .option('-s, --show', 'Launch the browser in a visible window')
225
169
  .option('--headless', 'Launch the browser without a window')
226
170
  .action(async (options) => {
@@ -230,7 +174,7 @@ export function createPrimaCommands(name = 'prima') {
230
174
  return keepServerRunning(() => prima.browserStop());
231
175
  });
232
176
  });
233
- addCommonOptions(browser.command('stop').description('Stop the browser of this instance'))
177
+ addBrowserOptions(browser.command('stop').description('Stop the browser of this instance'))
234
178
  .option('--all', 'Stop every running instance')
235
179
  .action(async (options) => {
236
180
  await runBrowser(options, async (prima) => {
@@ -239,13 +183,13 @@ export function createPrimaCommands(name = 'prima') {
239
183
  return stopped;
240
184
  });
241
185
  });
242
- addCommonOptions(browser.command('status').description('Report the browser of this instance')).action(async (options) => {
186
+ addBrowserOptions(browser.command('status').description('Report the browser of this instance')).action(async (options) => {
243
187
  await runBrowser(options, async (prima) => {
244
188
  console.log(await prima.browserStatus());
245
189
  return true;
246
190
  });
247
191
  });
248
- addCommonOptions(browser.command('list').description('List every browser instance that is running')).action(async (options) => {
192
+ addBrowserOptions(browser.command('list').description('List every browser instance that is running')).action(async (options) => {
249
193
  await runBrowser(options, async (prima) => {
250
194
  console.log(await prima.browserList());
251
195
  return true;
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  export const STATUS_FILE = 'status.json';
4
4
  const ARTIFACT_FILES = { aria: 'aria.yml', html: 'page.html', screenshot: 'page.png', network: 'network.jsonl' };
5
+ export const STEP_FILES = { aria: 'aria.yaml', html: 'html', diff: 'diff.yaml' };
5
6
  const EXPECTATION_LABELS = {
6
7
  passed: 'PASSED ',
7
8
  failed: 'FAILED ',
@@ -86,7 +87,7 @@ function renderSteps(data) {
86
87
  lines.push(` ${line}`);
87
88
  });
88
89
  if (data.stepFiles)
89
- lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{aria.yaml,html,diff.yaml}`);
90
+ lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{${Object.values(STEP_FILES).join(',')}}`);
90
91
  return section('Steps', lines.join('\n'));
91
92
  }
92
93
  function renderExpectations(data) {