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
@@ -56,6 +56,7 @@ interface CLIOptions {
56
56
  debug?: boolean;
57
57
  config?: string;
58
58
  path?: string;
59
+ baseUrl?: string;
59
60
  show?: boolean;
60
61
  headless?: boolean;
61
62
  incognito?: boolean;
@@ -66,6 +67,7 @@ interface CLIOptions {
66
67
  function buildExplorBotOptions(from: string | undefined, options: CLIOptions): ExplorBotOptions {
67
68
  return {
68
69
  from,
70
+ baseUrl: options.baseUrl,
69
71
  verbose: options.verbose || options.debug,
70
72
  config: options.config,
71
73
  path: options.path,
@@ -83,6 +85,7 @@ function addCommonOptions(cmd: Command): Command {
83
85
  .option('--debug', 'Enable debug logging (same as --verbose)')
84
86
  .option('-c, --config <path>', 'Path to configuration file')
85
87
  .option('-p, --path <path>', 'Working directory path')
88
+ .option('--base-url <url>', 'Run against this URL: its path scopes relative paths (plans, commands, saved tests on rerun), its query params ride along with every page load')
86
89
  .option('-s, --show', 'Show browser window')
87
90
  .option('--headless', 'Run browser in headless mode')
88
91
  .option('--incognito', 'Run without recording experiences')
@@ -171,6 +171,7 @@ interface DocbotConfig {
171
171
  minCanActions?: number;
172
172
  minInteractiveElements?: number;
173
173
  interactive?: boolean;
174
+ research?: 'light' | 'full';
174
175
  };
175
176
  }
176
177
 
@@ -118,9 +118,11 @@ class DocBot {
118
118
  }
119
119
  }
120
120
 
121
+ const light = this.useLightResearch();
121
122
  const research = await this.explorBot.agentResearcher().research(state, {
122
- screenshot: this.shouldUseScreenshots(),
123
+ screenshot: !light && this.shouldUseScreenshots(),
123
124
  force: true,
125
+ light,
124
126
  });
125
127
  const pagePath = this.getPageFilePath(state.url);
126
128
  const captureState = this.shouldUseScreenshots()
@@ -221,6 +223,13 @@ class DocBot {
221
223
  return true;
222
224
  }
223
225
 
226
+ private useLightResearch(): boolean {
227
+ const research = this.config.docs?.research;
228
+ if (research === 'light') return true;
229
+ if (research === 'full') return false;
230
+ return this.config.docs?.interactive !== true;
231
+ }
232
+
224
233
  private shouldCollapseTemplates(override?: boolean): boolean {
225
234
  if (override === false) {
226
235
  return false;
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import type { SettledExpectation } from '../../../src/test-plan.ts';
3
4
 
4
5
  export const STATUS_FILE = 'status.json';
5
6
 
@@ -26,16 +27,16 @@ export interface EnvelopeData {
26
27
  ok: boolean;
27
28
  command: string;
28
29
  used?: string[];
29
- page: { url: string; previousUrl?: string; title: string; state: string; visits: number };
30
+ page: { url: string; previousUrl?: string; redirectedFrom?: string; title: string; state: string; visits: number };
30
31
  changes?: string | null;
31
32
  steps?: Array<{ label: string; ok: boolean; unconfirmed?: boolean; proof: string }>;
32
- expectations?: Array<{ text: string; status: 'passed' | 'failed' | 'unverified' | 'contradiction'; evidence?: string }>;
33
+ expectations?: SettledExpectation[];
33
34
  warning?: string;
34
35
  stepFiles?: string;
35
36
  value?: string;
36
37
  answer?: string;
37
38
  research?: string;
38
- assertions?: Array<{ code: string; passed: boolean; proof: string[] }>;
39
+ assertions?: Array<{ code: string; passed: boolean; proof: string[]; error?: string }>;
39
40
  failure?: { error: string; compactAria?: string };
40
41
  instance: InstanceInfo;
41
42
  status?: string;
@@ -88,12 +89,13 @@ function renderResult(data: EnvelopeData): string {
88
89
  }
89
90
 
90
91
  function renderPage(data: EnvelopeData): string {
91
- const { url, previousUrl, title, state, visits } = data.page;
92
+ const { url, previousUrl, redirectedFrom, title, state, visits } = data.page;
92
93
  const urlLabel = `url: ${url}`;
93
94
  const stateLabel = `state: ${state}`;
94
95
  const width = Math.max(urlLabel.length, stateLabel.length) + 3;
95
96
  let changedMarker = '';
96
97
  if (previousUrl && previousUrl !== url) changedMarker = `(changed: ${previousUrl} → ${url})`;
98
+ if (redirectedFrom) changedMarker = `(redirected: ${redirectedFrom} → ${url})`;
97
99
  const lines = [align(urlLabel, changedMarker, width), `title: ${title}`, align(stateLabel, `(visit #${visits})`, width)];
98
100
  return section('Page', lines.join('\n'));
99
101
  }
@@ -129,7 +131,8 @@ function renderExpectations(data: EnvelopeData): string | null {
129
131
  const lines: string[] = [];
130
132
  data.expectations.forEach((expectation, index) => {
131
133
  lines.push(`${index + 1}. ${EXPECTATION_LABELS[expectation.status]} ${expectation.text}`);
132
- if (expectation.status !== 'contradiction' && expectation.status !== 'failed') return;
134
+ if (expectation.resolution) lines.push(` resolved: ${expectation.resolution}`);
135
+ if (!expectation.contradicted && !expectation.failed && !expectation.resolution) return;
133
136
  for (const line of (expectation.evidence || '').split('\n').filter(Boolean)) lines.push(` ${line}`);
134
137
  });
135
138
  return section('Expected outcomes', lines.join('\n'));
@@ -153,7 +156,10 @@ function renderOutcome(data: EnvelopeData): string | null {
153
156
  .map((line) => line.trim())
154
157
  .filter((line) => line && !line.startsWith('//'))
155
158
  .join(' ');
156
- return `${code} => ${assertion.passed ? 'PASSED' : 'FAILED'}`;
159
+ if (assertion.passed) return `${code} => PASSED`;
160
+ const reason = assertion.error?.split('\n').find(Boolean);
161
+ if (!reason) return `${code} => FAILED`;
162
+ return `${code} => FAILED\n ${reason}`;
157
163
  });
158
164
 
159
165
  const proof = data.assertions.flatMap((assertion) => assertion.proof);
@@ -27,8 +27,10 @@ export const checkHelp = dedent`
27
27
  ### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
28
28
  "not verified" means never checked, not false.
29
29
  Proof is a full-page screenshot: what a user sees counts, the log only shows actions.
30
- CONTRADICTION means screenshot and log disagree; judge the html, aria and
31
- screenshot under ### Artifacts yourself.
30
+ When screenshot and log disagree, the page structure breaks the tie: the outcome
31
+ becomes PASSED or FAILED with a "resolved:" line naming the side that was wrong.
32
+ CONTRADICTION remains when it cannot be broken, or when the page holds something
33
+ it does not display; judge the html, aria and screenshot under ### Artifacts yourself.
32
34
  ok is false when any outcome FAILED or CONTRADICTED, or the run could not finish,
33
35
  reported as such rather than as an app failure.
34
36
  Side issues found on the way go under ### Answer, not as step failures.
@@ -239,6 +239,10 @@ export class Prima {
239
239
 
240
240
  if (aiError) return this.failureEnvelope(command, aiError, previousState);
241
241
 
242
+ if (trace.length && ledger.some((entry) => entry.status === 'open')) {
243
+ await this.judgeUnreportedInstructions(ledger, trace);
244
+ }
245
+
242
246
  if (trace.length && ledger.some((entry) => entry.status === 'open')) {
243
247
  await this.settleLedger(conversation, provider, ledger, trace);
244
248
  }
@@ -310,6 +314,32 @@ export class Prima {
310
314
  return true;
311
315
  }
312
316
 
317
+ private async judgeUnreportedInstructions(ledger: LedgerEntry[], trace: Array<{ label: string; ok: boolean; proof: string }>): Promise<void> {
318
+ const judge = this.bot.judge?.();
319
+ if (!judge) return;
320
+
321
+ const current = this.bot.stateManager().getCurrentState();
322
+ let page = '';
323
+ if (current) page = ((await this.refAriaSnapshot(ActionResult.fromState(current))) || '').slice(0, JUDGE_PAGE_CAP);
324
+ const actions = trace
325
+ .map((step) => {
326
+ if (step.ok) return `ok ${step.label}`;
327
+ return `FAIL ${step.label}`;
328
+ })
329
+ .join('\n');
330
+ const state = { actions, finalUrl: current?.url || '', finalPage: page };
331
+
332
+ const open = ledger.filter((entry) => entry.status === 'open');
333
+ const decisions = await Promise.all(open.map((entry) => judge.decide(`The actions that ran and the final page show this instruction was carried out: ${entry.text}`, null, state)));
334
+
335
+ open.forEach((entry, index) => {
336
+ if (decisions[index].rejected) return;
337
+ entry.status = 'done';
338
+ entry.proof = 'judged from the actions that ran and the final page';
339
+ trace.push({ label: `done: ${entry.text}`, ok: true, proof: entry.proof });
340
+ });
341
+ }
342
+
313
343
  private async settleLedger(conversation: any, provider: any, ledger: LedgerEntry[], trace: Array<{ label: string; ok: boolean; proof: string }>): Promise<void> {
314
344
  conversation.addUserText(dedent`
315
345
  The run is over and these instructions were never reported:
@@ -376,8 +406,8 @@ export class Prima {
376
406
  envelope.warning = 'These outcomes were settled from the run log alone — no screenshot backed them. Set ai.visionModel, or check anything visual with prima ask.';
377
407
  }
378
408
 
379
- const unreached = envelope.expectations.filter((expectation) => expectation.status === 'failed');
380
- const contradicted = envelope.expectations.filter((expectation) => expectation.status === 'contradiction');
409
+ const unreached = envelope.expectations.filter((expectation) => expectation.failed);
410
+ const contradicted = envelope.expectations.filter((expectation) => expectation.contradicted);
381
411
  envelope.ok = !unreached.length && !contradicted.length;
382
412
 
383
413
  const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
@@ -455,9 +485,10 @@ export class Prima {
455
485
  }
456
486
 
457
487
  let navigationError: unknown = null;
488
+ let arrived = true;
458
489
 
459
490
  try {
460
- await this.bot.agentNavigator().visit(target);
491
+ arrived = await this.bot.agentNavigator().visit(target, { recover: !isUrl });
461
492
  } catch (error) {
462
493
  navigationError = error;
463
494
  }
@@ -467,7 +498,9 @@ export class Prima {
467
498
  const used: string[] = [];
468
499
  if (isUrl) used.push(code);
469
500
  const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
470
- return this.successEnvelope(command, used, result, previousState);
501
+ const envelope = await this.successEnvelope(command, used, result, previousState);
502
+ if (!arrived) envelope.page.redirectedFrom = target;
503
+ return envelope;
471
504
  }
472
505
 
473
506
  async browserStart(): Promise<void> {
@@ -4,7 +4,7 @@ import { outputPath } from '../../../src/config.ts';
4
4
  import { safeFilename } from '../../../src/utils/strings.ts';
5
5
  import { type EnvelopeData, renderEnvelope } from './envelope.ts';
6
6
 
7
- const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped' };
7
+ const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped', contradiction: 'failed' };
8
8
 
9
9
  export function sessionsDir(): string {
10
10
  return outputPath('prima', 'sessions');
@@ -74,7 +74,7 @@ function commandSteps(envelope: EnvelopeData): any[] {
74
74
 
75
75
  for (const expectation of envelope.expectations || []) {
76
76
  const entry: any = { category: 'user', title: `expected: ${expectation.text}`, status: EXPECTATION_STATUS[expectation.status], duration: 0 };
77
- if (expectation.status === 'failed') entry.error = 'the run did not reach this outcome';
77
+ if (expectation.failed) entry.error = 'the run did not reach this outcome';
78
78
  steps.push(entry);
79
79
  }
80
80
 
@@ -82,7 +82,7 @@ function commandSteps(envelope: EnvelopeData): any[] {
82
82
  const entry: any = { category: 'user', title: assertion.code, status: 'passed', duration: 0, log: assertion.proof.join('\n') };
83
83
  if (!assertion.passed) {
84
84
  entry.status = 'failed';
85
- entry.error = 'the assertion did not hold on the page';
85
+ entry.error = assertion.error || 'the assertion did not hold on the page';
86
86
  }
87
87
  steps.push(entry);
88
88
  }
@@ -46,6 +46,7 @@ if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima') && !prin
46
46
  function buildExplorBotOptions(from, options) {
47
47
  return {
48
48
  from,
49
+ baseUrl: options.baseUrl,
49
50
  verbose: options.verbose || options.debug,
50
51
  config: options.config,
51
52
  path: options.path,
@@ -62,6 +63,7 @@ function addCommonOptions(cmd) {
62
63
  .option('--debug', 'Enable debug logging (same as --verbose)')
63
64
  .option('-c, --config <path>', 'Path to configuration file')
64
65
  .option('-p, --path <path>', 'Working directory path')
66
+ .option('--base-url <url>', 'Run against this URL: its path scopes relative paths (plans, commands, saved tests on rerun), its query params ride along with every page load')
65
67
  .option('-s, --show', 'Show browser window')
66
68
  .option('--headless', 'Run browser in headless mode')
67
69
  .option('--incognito', 'Run without recording experiences')
@@ -103,9 +103,11 @@ class DocBot {
103
103
  continue;
104
104
  }
105
105
  }
106
+ const light = this.useLightResearch();
106
107
  const research = await this.explorBot.agentResearcher().research(state, {
107
- screenshot: this.shouldUseScreenshots(),
108
+ screenshot: !light && this.shouldUseScreenshots(),
108
109
  force: true,
110
+ light,
109
111
  });
110
112
  const pagePath = this.getPageFilePath(state.url);
111
113
  const captureState = this.shouldUseScreenshots()
@@ -197,6 +199,14 @@ class DocBot {
197
199
  }
198
200
  return true;
199
201
  }
202
+ useLightResearch() {
203
+ const research = this.config.docs?.research;
204
+ if (research === 'light')
205
+ return true;
206
+ if (research === 'full')
207
+ return false;
208
+ return this.config.docs?.interactive !== true;
209
+ }
200
210
  shouldCollapseTemplates(override) {
201
211
  if (override === false) {
202
212
  return false;
@@ -52,13 +52,15 @@ function renderResult(data) {
52
52
  return section('Result', lines.join('\n'));
53
53
  }
54
54
  function renderPage(data) {
55
- const { url, previousUrl, title, state, visits } = data.page;
55
+ const { url, previousUrl, redirectedFrom, title, state, visits } = data.page;
56
56
  const urlLabel = `url: ${url}`;
57
57
  const stateLabel = `state: ${state}`;
58
58
  const width = Math.max(urlLabel.length, stateLabel.length) + 3;
59
59
  let changedMarker = '';
60
60
  if (previousUrl && previousUrl !== url)
61
61
  changedMarker = `(changed: ${previousUrl} → ${url})`;
62
+ if (redirectedFrom)
63
+ changedMarker = `(redirected: ${redirectedFrom} → ${url})`;
62
64
  const lines = [align(urlLabel, changedMarker, width), `title: ${title}`, align(stateLabel, `(visit #${visits})`, width)];
63
65
  return section('Page', lines.join('\n'));
64
66
  }
@@ -96,7 +98,9 @@ function renderExpectations(data) {
96
98
  const lines = [];
97
99
  data.expectations.forEach((expectation, index) => {
98
100
  lines.push(`${index + 1}. ${EXPECTATION_LABELS[expectation.status]} ${expectation.text}`);
99
- if (expectation.status !== 'contradiction' && expectation.status !== 'failed')
101
+ if (expectation.resolution)
102
+ lines.push(` resolved: ${expectation.resolution}`);
103
+ if (!expectation.contradicted && !expectation.failed && !expectation.resolution)
100
104
  return;
101
105
  for (const line of (expectation.evidence || '').split('\n').filter(Boolean))
102
106
  lines.push(` ${line}`);
@@ -123,7 +127,12 @@ function renderOutcome(data) {
123
127
  .map((line) => line.trim())
124
128
  .filter((line) => line && !line.startsWith('//'))
125
129
  .join(' ');
126
- return `${code} => ${assertion.passed ? 'PASSED' : 'FAILED'}`;
130
+ if (assertion.passed)
131
+ return `${code} => PASSED`;
132
+ const reason = assertion.error?.split('\n').find(Boolean);
133
+ if (!reason)
134
+ return `${code} => FAILED`;
135
+ return `${code} => FAILED\n ${reason}`;
127
136
  });
128
137
  const proof = data.assertions.flatMap((assertion) => assertion.proof);
129
138
  if (proof.length)
@@ -25,8 +25,10 @@ export const checkHelp = dedent `
25
25
  ### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
26
26
  "not verified" means never checked, not false.
27
27
  Proof is a full-page screenshot: what a user sees counts, the log only shows actions.
28
- CONTRADICTION means screenshot and log disagree; judge the html, aria and
29
- screenshot under ### Artifacts yourself.
28
+ When screenshot and log disagree, the page structure breaks the tie: the outcome
29
+ becomes PASSED or FAILED with a "resolved:" line naming the side that was wrong.
30
+ CONTRADICTION remains when it cannot be broken, or when the page holds something
31
+ it does not display; judge the html, aria and screenshot under ### Artifacts yourself.
30
32
  ok is false when any outcome FAILED or CONTRADICTED, or the run could not finish,
31
33
  reported as such rather than as an app failure.
32
34
  Side issues found on the way go under ### Answer, not as step failures.
@@ -220,6 +220,9 @@ export class Prima {
220
220
  }
221
221
  if (aiError)
222
222
  return this.failureEnvelope(command, aiError, previousState);
223
+ if (trace.length && ledger.some((entry) => entry.status === 'open')) {
224
+ await this.judgeUnreportedInstructions(ledger, trace);
225
+ }
223
226
  if (trace.length && ledger.some((entry) => entry.status === 'open')) {
224
227
  await this.settleLedger(conversation, provider, ledger, trace);
225
228
  }
@@ -283,6 +286,32 @@ export class Prima {
283
286
  }
284
287
  return true;
285
288
  }
289
+ async judgeUnreportedInstructions(ledger, trace) {
290
+ const judge = this.bot.judge?.();
291
+ if (!judge)
292
+ return;
293
+ const current = this.bot.stateManager().getCurrentState();
294
+ let page = '';
295
+ if (current)
296
+ page = ((await this.refAriaSnapshot(ActionResult.fromState(current))) || '').slice(0, JUDGE_PAGE_CAP);
297
+ const actions = trace
298
+ .map((step) => {
299
+ if (step.ok)
300
+ return `ok ${step.label}`;
301
+ return `FAIL ${step.label}`;
302
+ })
303
+ .join('\n');
304
+ const state = { actions, finalUrl: current?.url || '', finalPage: page };
305
+ const open = ledger.filter((entry) => entry.status === 'open');
306
+ const decisions = await Promise.all(open.map((entry) => judge.decide(`The actions that ran and the final page show this instruction was carried out: ${entry.text}`, null, state)));
307
+ open.forEach((entry, index) => {
308
+ if (decisions[index].rejected)
309
+ return;
310
+ entry.status = 'done';
311
+ entry.proof = 'judged from the actions that ran and the final page';
312
+ trace.push({ label: `done: ${entry.text}`, ok: true, proof: entry.proof });
313
+ });
314
+ }
286
315
  async settleLedger(conversation, provider, ledger, trace) {
287
316
  conversation.addUserText(dedent `
288
317
  The run is over and these instructions were never reported:
@@ -343,8 +372,8 @@ export class Prima {
343
372
  if (!result.screenshot || !this.visionEnabled()) {
344
373
  envelope.warning = 'These outcomes were settled from the run log alone — no screenshot backed them. Set ai.visionModel, or check anything visual with prima ask.';
345
374
  }
346
- const unreached = envelope.expectations.filter((expectation) => expectation.status === 'failed');
347
- const contradicted = envelope.expectations.filter((expectation) => expectation.status === 'contradiction');
375
+ const unreached = envelope.expectations.filter((expectation) => expectation.failed);
376
+ const contradicted = envelope.expectations.filter((expectation) => expectation.contradicted);
348
377
  envelope.ok = !unreached.length && !contradicted.length;
349
378
  const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
350
379
  if (problems.length)
@@ -413,8 +442,9 @@ export class Prima {
413
442
  return judged;
414
443
  }
415
444
  let navigationError = null;
445
+ let arrived = true;
416
446
  try {
417
- await this.bot.agentNavigator().visit(target);
447
+ arrived = await this.bot.agentNavigator().visit(target, { recover: !isUrl });
418
448
  }
419
449
  catch (error) {
420
450
  navigationError = error;
@@ -425,7 +455,10 @@ export class Prima {
425
455
  if (isUrl)
426
456
  used.push(code);
427
457
  const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
428
- return this.successEnvelope(command, used, result, previousState);
458
+ const envelope = await this.successEnvelope(command, used, result, previousState);
459
+ if (!arrived)
460
+ envelope.page.redirectedFrom = target;
461
+ return envelope;
429
462
  }
430
463
  async browserStart() {
431
464
  const config = await this.loadConfig();
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { outputPath } from "../../../src/config.js";
4
4
  import { safeFilename } from "../../../src/utils/strings.js";
5
5
  import { renderEnvelope } from "./envelope.js";
6
- const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped' };
6
+ const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped', contradiction: 'failed' };
7
7
  export function sessionsDir() {
8
8
  return outputPath('prima', 'sessions');
9
9
  }
@@ -64,7 +64,7 @@ function commandSteps(envelope) {
64
64
  }
65
65
  for (const expectation of envelope.expectations || []) {
66
66
  const entry = { category: 'user', title: `expected: ${expectation.text}`, status: EXPECTATION_STATUS[expectation.status], duration: 0 };
67
- if (expectation.status === 'failed')
67
+ if (expectation.failed)
68
68
  entry.error = 'the run did not reach this outcome';
69
69
  steps.push(entry);
70
70
  }
@@ -72,7 +72,7 @@ function commandSteps(envelope) {
72
72
  const entry = { category: 'user', title: assertion.code, status: 'passed', duration: 0, log: assertion.proof.join('\n') };
73
73
  if (!assertion.passed) {
74
74
  entry.status = 'failed';
75
- entry.error = 'the assertion did not hold on the page';
75
+ entry.error = assertion.error || 'the assertion did not hold on the page';
76
76
  }
77
77
  steps.push(entry);
78
78
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -1,11 +1,11 @@
1
1
  <rules>
2
- Do not invent locators, focus only on locators from HTML PAGE.
2
+ Do not invent locators, focus only on locators from the <page> context.
3
3
  Provide up to {{maxAttempts}} various code suggestions to achieve the result.
4
4
  If there was already successful solution in <experience> use it as a first solution.
5
5
 
6
6
  If no successful solution was found in <experience> propose codeblocks for each area that can help to achieve the result.
7
7
  Do not stick only to the first found element as it might be hidden or not availble on the page.
8
- If you think HTML contains several areas that can help to achieve the result, propose codeblocks for each such area.
8
+ If you think the page contains several areas that can help to achieve the result, propose codeblocks for each such area.
9
9
  Use exact locators that can pick the elements from each areas.
10
10
  Detect such duplicated areas by looking for duplicate IDs, data-ids, forms, etc.
11
11
 
@@ -24,7 +24,7 @@ Your response must start explanation of what you are going to do to achive the r
24
24
  It is important to explain intention before proposing code.
25
25
  Response must also valid CodeceptJS code in code blocks.
26
26
  Propose codeblock from successful solutions in <experience> first if they exist.
27
- Use only locators from HTML PAGE that was passed in <page> context.
27
+ Use only locators from the <page> context.
28
28
  </output>
29
29
 
30
30
 
@@ -105,6 +105,7 @@ class Action {
105
105
  if (includeScreenshot) {
106
106
  const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
107
107
  const screenshotPath = join(statesDir, filename);
108
+ await page?.screencast?.hideOverlays().catch(() => { });
108
109
  screenshotFile = await page
109
110
  ?.screenshot({ path: screenshotPath, fullPage: true })
110
111
  .then(() => {
@@ -115,6 +116,7 @@ class Action {
115
116
  debugLog('Screenshot failed, continuing without it:', err);
116
117
  return undefined;
117
118
  });
119
+ await page?.screencast?.showOverlays().catch(() => { });
118
120
  }
119
121
  // Save HTML to file
120
122
  const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
@@ -546,6 +548,10 @@ export const attachStepLogger = (target, assertionsTarget) => {
546
548
  if (batchFailed)
547
549
  return;
548
550
  const executed = { command: step.toCode(), success: !error };
551
+ if (Object.keys(step.opts || {}).length) {
552
+ const args = [step.humanizeArgs(), `step.opts(${JSON.stringify(step.opts)})`].filter(Boolean).join(', ');
553
+ executed.command = `${step.prefix}${step.actor}.${step.title}(${args})${step.suffix}`;
554
+ }
549
555
  if (error) {
550
556
  executed.error = errorToString(error);
551
557
  batchFailed = true;
@@ -6,6 +6,7 @@ import { setActivity } from "../activity.js";
6
6
  import { Observability } from "../observability.js";
7
7
  import { Plan, Test, TestResult } from "../test-plan.js";
8
8
  import { collectInteractiveNodes } from "../utils/aria.js";
9
+ import { splitTopLevel } from "../utils/code-extractor.js";
9
10
  import { EXPLORBOT_ATTRS, HTML_COMPOSITE_AREA_HINTS, HTML_COMPOSITE_TARGET_ROLES, HTML_EXTRACTION_LIMITS, HTML_FORM_CONTROL_ROLES, HTML_FORM_CONTROL_TAGS, HTML_INTERACTIVE_ROLES, HTML_SELECTORS, getComponentScopeHtmlExtractorSource, inferHtmlRole } from "../utils/html.js";
10
11
  import { createDebug, tag } from "../utils/logger.js";
11
12
  import { loop, pause } from "../utils/loop.js";
@@ -933,34 +934,7 @@ function countTopLevelArgCount(line) {
933
934
  const end = line.lastIndexOf(')');
934
935
  if (start === -1 || end === -1 || end <= start + 1)
935
936
  return 0;
936
- const body = line.slice(start + 1, end);
937
- let count = 1;
938
- let depth = 0;
939
- let quote = '';
940
- for (let i = 0; i < body.length; i++) {
941
- const char = body[i];
942
- const escaped = body[i - 1] === '\\';
943
- if (quote) {
944
- if (char === quote && !escaped)
945
- quote = '';
946
- continue;
947
- }
948
- if (char === '"' || char === "'" || char === '`') {
949
- quote = char;
950
- continue;
951
- }
952
- if (char === '(' || char === '[' || char === '{') {
953
- depth++;
954
- continue;
955
- }
956
- if (char === ')' || char === ']' || char === '}') {
957
- depth = Math.max(0, depth - 1);
958
- continue;
959
- }
960
- if (char === ',' && depth === 0)
961
- count++;
962
- }
963
- return count;
937
+ return splitTopLevel(line.slice(start + 1, end), ',').length;
964
938
  }
965
939
  function buildClassSelector(tag, classes) {
966
940
  const safeClasses = classes.filter((cls) => /^[a-z0-9_-]+$/i.test(cls)).slice(0, 5);
@@ -6,6 +6,7 @@ import { tag } from "../../utils/logger.js";
6
6
  import { relativeToCwd } from "../../utils/next-steps.js";
7
7
  import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from "../../utils/step-analyzer.js";
8
8
  import { safeFilename } from "../../utils/strings.js";
9
+ import { ensureCodeceptResolvable } from "../../utils/test-files.js";
9
10
  import { ASSERTION_TOOLS } from "../tools.js";
10
11
  import { escapeString, getExecutionLabel } from "./utils.js";
11
12
  export function WithCodeceptJS(Base) {
@@ -79,6 +80,7 @@ export function WithCodeceptJS(Base) {
79
80
  }
80
81
  const testsDir = ConfigParser.getInstance().getTestsDir();
81
82
  mkdirSync(testsDir, { recursive: true });
83
+ ensureCodeceptResolvable(testsDir);
82
84
  const filePath = join(testsDir, safeFilename(plan.title, '.js'));
83
85
  writeFileSync(filePath, lines.join('\n'));
84
86
  this.savedFiles.add(filePath);
@@ -9,7 +9,11 @@ export function createJudgeTool(deps, buildState) {
9
9
  return {
10
10
  judge: tool({
11
11
  description: dedent `
12
- Settle one judgement about the current page instead of guessing. Phrase it literally and concretely.
12
+ Fast yes/no or pick-one decision about the current page, answered from <page_aria> and your recent steps.
13
+ No screenshot, no HTML, produces no test code.
14
+ Use for in-flow judgements you would otherwise guess: did the last action already take effect, which of several similar elements the scenario means, is this the expected page or dialog.
15
+ Not for final assertions (use verify/finish) and not for anything page_aria does not show, such as styling or visual state (use see).
16
+ Phrase the question literally and concretely.
13
17
  `,
14
18
  inputSchema: z.object({
15
19
  question: z.string().describe('The statement to confirm, or the question the options answer'),
@@ -35,8 +35,10 @@ declare class Navigator implements Agent {
35
35
  }, expectedUrl: string): string;
36
36
  isSameExpectedOrigin(expectedUrl: string, stateManager: any): boolean;
37
37
  isOnExpectedPage(expectedUrl: string, stateManager: any): boolean;
38
- visit(url: string): Promise<void>;
39
- visitOnce(url: string): Promise<void>;
38
+ visit(url: string, opts?: {
39
+ recover?: boolean;
40
+ }): Promise<boolean>;
41
+ visitOnce(url: string, recover: boolean): Promise<boolean>;
40
42
  navigationError(url: string, fallback: string): Error;
41
43
  failureReason(stopReason: string | null, knowledge: string, url: string): string | null;
42
44
  resolveState(message: string, actionResult: ActionResult, opts?: {
@@ -98,5 +100,6 @@ export type AssertionResult = {
98
100
  code: string;
99
101
  passed: boolean;
100
102
  proof: string[];
103
+ error?: string;
101
104
  };
102
105
  export { Navigator };