explorbot 0.5.8 → 0.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +25 -4
  3. package/boat/api-tester/src/commands/init-command.ts +2 -0
  4. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -1
  5. package/boat/prima/README.md +3 -0
  6. package/boat/prima/bin/prima-cli.ts +2 -1
  7. package/boat/prima/src/cli.ts +7 -1
  8. package/boat/prima/src/envelope.ts +18 -4
  9. package/boat/prima/src/help.ts +15 -4
  10. package/boat/prima/src/prima.ts +181 -25
  11. package/dist/bin/explorbot-cli.js +31 -4
  12. package/dist/boat/api-tester/bin/apibot-cli.js +2 -1
  13. package/dist/boat/api-tester/src/commands/init-command.js +2 -0
  14. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -1
  15. package/dist/boat/prima/bin/prima-cli.js +2 -1
  16. package/dist/boat/prima/src/cli.js +6 -1
  17. package/dist/boat/prima/src/envelope.js +16 -3
  18. package/dist/boat/prima/src/help.js +14 -4
  19. package/dist/boat/prima/src/prima.js +191 -27
  20. package/dist/package.json +1 -1
  21. package/dist/rules/navigator/verification-actions.md +5 -3
  22. package/dist/rules/planner/ignored-pages.md +6 -0
  23. package/dist/rules/quartermaster/ux/accessibility.md +8 -0
  24. package/dist/rules/quartermaster/ux/charts.md +6 -0
  25. package/dist/rules/quartermaster/ux/copy.md +9 -0
  26. package/dist/rules/quartermaster/ux/forms.md +7 -0
  27. package/dist/rules/quartermaster/ux/interaction.md +7 -0
  28. package/dist/rules/quartermaster/ux/motion.md +5 -0
  29. package/dist/rules/quartermaster/ux/navigation.md +7 -0
  30. package/dist/rules/quartermaster/ux/readability.md +6 -0
  31. package/dist/rules/quartermaster/ux/responsive.md +7 -0
  32. package/dist/src/ai/judge.d.ts +3 -1
  33. package/dist/src/ai/judge.js +13 -3
  34. package/dist/src/ai/navigator.js +3 -2
  35. package/dist/src/ai/pilot.js +10 -4
  36. package/dist/src/ai/planner.js +9 -1
  37. package/dist/src/ai/provider.d.ts +4 -0
  38. package/dist/src/ai/provider.js +27 -5
  39. package/dist/src/ai/quartermaster.d.ts +31 -3
  40. package/dist/src/ai/quartermaster.js +262 -17
  41. package/dist/src/ai/tester.js +6 -3
  42. package/dist/src/commands/init-command.js +4 -0
  43. package/dist/src/commands/options/auth-option.d.ts +6 -0
  44. package/dist/src/commands/options/auth-option.js +14 -0
  45. package/dist/src/commands/options/index.d.ts +2 -0
  46. package/dist/src/commands/options/index.js +2 -0
  47. package/dist/src/config.js +1 -0
  48. package/dist/src/explorbot.d.ts +1 -1
  49. package/dist/src/explorbot.js +8 -13
  50. package/dist/src/explorer.js +1 -0
  51. package/dist/src/global-config.js +6 -2
  52. package/dist/src/utils/ux-probe.d.ts +25 -0
  53. package/dist/src/utils/ux-probe.js +155 -0
  54. package/docs/reference/commands.md +12 -0
  55. package/docs/workflow/agentic-usage.md +7 -0
  56. package/docs/workflow/knowledge.md +6 -0
  57. package/package.json +1 -1
  58. package/rules/navigator/verification-actions.md +5 -3
  59. package/rules/planner/ignored-pages.md +6 -0
  60. package/rules/quartermaster/ux/accessibility.md +8 -0
  61. package/rules/quartermaster/ux/charts.md +6 -0
  62. package/rules/quartermaster/ux/copy.md +9 -0
  63. package/rules/quartermaster/ux/forms.md +7 -0
  64. package/rules/quartermaster/ux/interaction.md +7 -0
  65. package/rules/quartermaster/ux/motion.md +5 -0
  66. package/rules/quartermaster/ux/navigation.md +7 -0
  67. package/rules/quartermaster/ux/readability.md +6 -0
  68. package/rules/quartermaster/ux/responsive.md +7 -0
  69. package/src/ai/judge.ts +11 -3
  70. package/src/ai/navigator.ts +2 -2
  71. package/src/ai/pilot.ts +10 -4
  72. package/src/ai/planner.ts +10 -1
  73. package/src/ai/provider.ts +29 -6
  74. package/src/ai/quartermaster.ts +300 -18
  75. package/src/ai/tester.ts +5 -3
  76. package/src/commands/init-command.ts +5 -0
  77. package/src/commands/options/auth-option.ts +15 -0
  78. package/src/commands/options/index.ts +2 -0
  79. package/src/config.ts +1 -0
  80. package/src/explorbot.ts +9 -13
  81. package/src/explorer.ts +1 -0
  82. package/src/global-config.ts +5 -2
  83. package/src/utils/ux-probe.ts +163 -0
@@ -0,0 +1,155 @@
1
+ import { HTML_SELECTORS, HTML_VISIBILITY_LIMITS } from "./html.js";
2
+ export const UX_FOCUS_ATTR = 'data-explorbot-ux';
3
+ const UX_LIMITS = {
4
+ touchTarget: 44,
5
+ pointerTarget: 24,
6
+ touchWidth: 1024,
7
+ targetGap: 8,
8
+ minFontSize: 12,
9
+ minLineHeight: 1.3,
10
+ maxEntries: 8,
11
+ minOpacity: HTML_VISIBILITY_LIMITS.minOpacity,
12
+ };
13
+ export function uxProbeArgs(scope) {
14
+ return { scope, controlsSelector: HTML_SELECTORS.interactiveControl, limits: UX_LIMITS };
15
+ }
16
+ export function renderUxMeasurements(measurements) {
17
+ const lines = [`viewport: ${measurements.width}px wide`];
18
+ for (const [check, entries] of Object.entries(measurements.checks)) {
19
+ if (!entries.length)
20
+ continue;
21
+ lines.push(`${check}:`, ...entries.map((entry) => ` - ${entry}`));
22
+ }
23
+ if (lines.length === 1)
24
+ lines.push('no measurable problems');
25
+ return lines.join('\n');
26
+ }
27
+ // Passed to page.evaluate as a function — must stay self-contained with no outer-scope references.
28
+ export function measureUx({ scope, controlsSelector, limits }) {
29
+ const root = document.querySelector(scope) || document.body;
30
+ const width = window.innerWidth;
31
+ const touch = width <= limits.touchWidth;
32
+ const visibleWidth = document.documentElement.clientWidth;
33
+ const checks = {
34
+ 'horizontal overflow': [],
35
+ 'zoom and viewport meta': [],
36
+ 'small targets': [],
37
+ 'crowded targets': [],
38
+ 'placeholder-only labels': [],
39
+ 'small text': [],
40
+ 'cramped line-height': [],
41
+ 'truncated text without full text': [],
42
+ 'layout-property transitions': [],
43
+ };
44
+ const add = (check, entry) => {
45
+ if (checks[check].length >= limits.maxEntries)
46
+ return;
47
+ if (checks[check].includes(entry))
48
+ return;
49
+ checks[check].push(entry);
50
+ };
51
+ const describe = (el) => {
52
+ const role = el.getAttribute('role') || el.tagName.toLowerCase();
53
+ const name = (el.getAttribute('aria-label') || el.innerText || el.getAttribute('placeholder') || el.getAttribute('title') || '').trim().replace(/\s+/g, ' ');
54
+ if (!name)
55
+ return `${role} (unnamed)`;
56
+ return `${role} "${name.slice(0, 40)}"`;
57
+ };
58
+ const visible = (el) => {
59
+ const rect = el.getBoundingClientRect();
60
+ if (!rect.width || !rect.height)
61
+ return false;
62
+ const style = window.getComputedStyle(el);
63
+ return style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > limits.minOpacity;
64
+ };
65
+ if (root === document.body || root.matches('main, [role="main"]')) {
66
+ const docWidth = document.documentElement.scrollWidth;
67
+ if (docWidth > visibleWidth + 1)
68
+ add('horizontal overflow', `page is ${docWidth}px wide in a ${visibleWidth}px visible area`);
69
+ const meta = document.querySelector('meta[name="viewport"]')?.getAttribute('content');
70
+ if (!meta)
71
+ add('zoom and viewport meta', 'no viewport meta tag');
72
+ const metaRules = (meta || '').toLowerCase().replace(/\s/g, '');
73
+ if (metaRules.includes('user-scalable=no') || metaRules.includes('user-scalable=0'))
74
+ add('zoom and viewport meta', 'zoom disabled by user-scalable');
75
+ const maxScale = metaRules.split(',').find((part) => part.startsWith('maximum-scale='));
76
+ if (maxScale && Number(maxScale.split('=')[1]) <= 1)
77
+ add('zoom and viewport meta', `zoom capped by ${maxScale}`);
78
+ }
79
+ const minTarget = touch ? limits.touchTarget : limits.pointerTarget;
80
+ const controls = Array.from(root.querySelectorAll(controlsSelector)).filter(visible);
81
+ const boxes = controls.map((el) => ({ el, rect: el.getBoundingClientRect() }));
82
+ for (const { el, rect } of boxes) {
83
+ if (el.tagName === 'A' && window.getComputedStyle(el).display === 'inline')
84
+ continue;
85
+ if (rect.width >= minTarget && rect.height >= minTarget)
86
+ continue;
87
+ add('small targets', `${describe(el)} is ${Math.round(rect.width)}×${Math.round(rect.height)}px, below ${minTarget}px`);
88
+ }
89
+ if (touch) {
90
+ for (let i = 0; i < boxes.length; i++) {
91
+ for (let j = i + 1; j < boxes.length; j++) {
92
+ const a = boxes[i];
93
+ const b = boxes[j];
94
+ if (a.el.contains(b.el) || b.el.contains(a.el))
95
+ continue;
96
+ const gapX = Math.max(b.rect.left - a.rect.right, a.rect.left - b.rect.right);
97
+ const gapY = Math.max(b.rect.top - a.rect.bottom, a.rect.top - b.rect.bottom);
98
+ const gap = Math.max(gapX, gapY);
99
+ if (gap >= limits.targetGap)
100
+ continue;
101
+ if (a.rect.height >= limits.touchTarget && b.rect.height >= limits.touchTarget && a.rect.width >= limits.touchTarget && b.rect.width >= limits.touchTarget)
102
+ continue;
103
+ add('crowded targets', `${describe(a.el)} and ${describe(b.el)} are ${Math.max(0, Math.round(gap))}px apart`);
104
+ }
105
+ }
106
+ }
107
+ for (const el of Array.from(root.querySelectorAll('input[placeholder], textarea[placeholder]'))) {
108
+ if (!visible(el))
109
+ continue;
110
+ if (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title'))
111
+ continue;
112
+ if (el.labels?.length)
113
+ continue;
114
+ add('placeholder-only labels', describe(el));
115
+ }
116
+ for (const el of Array.from(root.querySelectorAll('*'))) {
117
+ if (!visible(el))
118
+ continue;
119
+ const rect = el.getBoundingClientRect();
120
+ const style = window.getComputedStyle(el);
121
+ const overflows = rect.right > visibleWidth + 1 && rect.left < visibleWidth;
122
+ if (overflows && !(el.parentElement && el.parentElement.getBoundingClientRect().right > visibleWidth + 1))
123
+ add('horizontal overflow', `${describe(el)} ends at ${Math.round(rect.right)}px`);
124
+ const animated = style.transitionProperty.split(',').map((property) => property.trim());
125
+ const layout = animated.filter((property) => ['width', 'height', 'top', 'left', 'right', 'bottom', 'margin', 'padding'].includes(property));
126
+ const transitions = style.transitionDuration.split(',').some((duration) => Number.parseFloat(duration) > 0);
127
+ if (transitions && layout.length)
128
+ add('layout-property transitions', `${describe(el)} transitions ${layout.join(', ')}`);
129
+ const ownText = Array.from(el.childNodes)
130
+ .filter((node) => node.nodeType === Node.TEXT_NODE)
131
+ .map((node) => node.textContent || '')
132
+ .join('')
133
+ .trim();
134
+ if (!ownText)
135
+ continue;
136
+ const fontSize = Number.parseFloat(style.fontSize);
137
+ if (fontSize < limits.minFontSize)
138
+ add('small text', `"${ownText.slice(0, 40)}" at ${fontSize}px`);
139
+ const lineHeight = Number.parseFloat(style.lineHeight);
140
+ const multiline = rect.height > fontSize * 2.5;
141
+ if (multiline && lineHeight && lineHeight / fontSize < limits.minLineHeight)
142
+ add('cramped line-height', `"${ownText.slice(0, 40)}" at ${(lineHeight / fontSize).toFixed(2)}`);
143
+ const htmlEl = el;
144
+ if (style.textOverflow === 'ellipsis' && htmlEl.scrollWidth > htmlEl.clientWidth && !el.getAttribute('title'))
145
+ add('truncated text without full text', `"${ownText.slice(0, 40)}"`);
146
+ }
147
+ return { width, checks };
148
+ }
149
+ export function countRunningAnimations(scope) {
150
+ const root = document.querySelector(scope) || document.body;
151
+ return document.getAnimations().filter((animation) => {
152
+ const target = animation.effect?.target;
153
+ return animation.playState === 'running' && target && root.contains(target);
154
+ }).length;
155
+ }
@@ -93,6 +93,17 @@ npx explorbot explore /pay --knowledge 'Test card 4111 1111 1111 1111, any futur
93
93
 
94
94
  Like `--ws`, it is a program-level option rather than a per-command one: it works on every command — including `api`, `docs` and `prima` — and can go anywhere on the line. It is listed under `npx explorbot --help` rather than in each command's own help.
95
95
 
96
+ ### `--auth`
97
+
98
+ Passes login details to the run in free form — credentials, a token, whatever the app asks for. It joins the run's knowledge on every page and API endpoint, stays in memory, and is never written to disk. `EXPLORBOT_AUTH` does the same from the environment; the flag wins when both are set.
99
+
100
+ ```bash
101
+ npx explorbot explore /dashboard --auth 'login: admin@example.com password: secret123'
102
+ npx explorbot api explore /orders --auth 'Bearer: eyJhbGciOi...'
103
+ ```
104
+
105
+ Like `--knowledge`, it works on every command of `explorbot`, `api`, `docs` and `prima`, and can go anywhere on the line.
106
+
96
107
  ### `--session`
97
108
 
98
109
  Saves browser state (cookies, localStorage, sessionStorage) to a JSON file. The next run restores the session, so you skip login and setup steps.
@@ -129,6 +140,7 @@ EXPLORBOT_AI_PROVIDER=openrouter \
129
140
  | `EXPLORBOT_EPHEMERAL` | Keep no state between runs — output goes to a fresh temp directory instead of the site dir |
130
141
  | `EXPLORBOT_KNOWLEDGE` | Inline knowledge text, applied to every page |
131
142
  | `EXPLORBOT_KNOWLEDGE_FILE` | Path to a knowledge markdown file |
143
+ | `EXPLORBOT_AUTH` | Auth details in free form (credentials, a token), kept in memory for the run and never written to disk; same as --auth |
132
144
  | `EXPLORBOT_SPEC` | Docbot application spec directory or index.md, used as page knowledge |
133
145
  | `EXPLORBOT_API_SPEC` | OpenAPI spec path for the API boat |
134
146
  | `EXPLORBOT_API_HEADERS` | Headers sent with every API request, one "Name: value" per line |
@@ -57,6 +57,7 @@ No `init`, no config file, no project directory, no model IDs to look up. These
57
57
  | `EXPLORBOT_EPHEMERAL` | no | Keep no state between runs — output goes to a fresh temp directory instead of the site dir |
58
58
  | `EXPLORBOT_KNOWLEDGE` | no | Inline knowledge text, applied to every page |
59
59
  | `EXPLORBOT_KNOWLEDGE_FILE` | no | Path to a knowledge markdown file |
60
+ | `EXPLORBOT_AUTH` | no | Auth details in free form (credentials, a token), kept in memory for the run and never written to disk; same as --auth |
60
61
  | `EXPLORBOT_SPEC` | no | Docbot application spec directory or index.md, used as page knowledge |
61
62
  | `EXPLORBOT_API_SPEC` | no | OpenAPI spec path for the API boat |
62
63
  | `EXPLORBOT_API_HEADERS` | no | Headers sent with every API request, one "Name: value" per line |
@@ -133,6 +134,12 @@ EXPLORBOT_AI_PROVIDER=openrouter \
133
134
  npx explorbot explore /admin/users
134
135
  ```
135
136
 
137
+ `EXPLORBOT_AUTH` takes the same free-form credentials but keeps them in memory only — nothing lands in the knowledge directory. `--auth` is its flag twin:
138
+
139
+ ```bash
140
+ EXPLORBOT_AUTH="login: admin@example.com password: secret123" npx explorbot explore /admin/users
141
+ ```
142
+
136
143
  `EXPLORBOT_KNOWLEDGE_FILE` points at a markdown file the agent wrote. Its frontmatter is preserved, so it can target specific URLs — see [Knowledge](./knowledge.md) for the format:
137
144
 
138
145
  ```bash
@@ -88,6 +88,12 @@ Send X-Api-Key: ${env.API_KEY} on every request'
88
88
 
89
89
  For runs driven from the environment — config-free, or on the global configuration — `EXPLORBOT_KNOWLEDGE` does the same job for the length of one run. See [Agentic usage](./agentic-usage.md).
90
90
 
91
+ For credentials alone, `--auth` (or `EXPLORBOT_AUTH`) takes free-form text, applies it everywhere, and never writes it to disk:
92
+
93
+ ```bash
94
+ npx explorbot explore /dashboard --auth 'login: admin@example.com password: secret123'
95
+ ```
96
+
91
97
  ## URL Patterns
92
98
 
93
99
  | Pattern | Matches |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -55,9 +55,11 @@ Checks that page title contains expected text.
55
55
  I.seeInSource(<text>)
56
56
 
57
57
  Checks that page source contains expected text (including hidden elements).
58
+ Pass plain text only — never markup, tags, attributes or class names. The HTML you are shown is rewritten
59
+ and will not match the live source character for character. Use it only for text that no visible element carries.
58
60
 
59
61
  <example>
60
- I.seeInSource('<div class="hidden">');
62
+ I.seeInSource('Draft saved');
61
63
  </example>
62
64
 
63
65
  ### I.dontSee
@@ -99,10 +101,10 @@ Checks that an input field does NOT contain the specified value.
99
101
 
100
102
  I.dontSeeInSource(<text>)
101
103
 
102
- Checks that page source does NOT contain expected text.
104
+ Checks that page source does NOT contain expected text. Plain text only, same as I.seeInSource.
103
105
 
104
106
  <example>
105
- I.dontSeeInSource('error-class');
107
+ I.dontSeeInSource('Session expired');
106
108
  </example>
107
109
 
108
110
  ### Asserting the state of a control
@@ -0,0 +1,6 @@
1
+ <ignored_pages>
2
+ Do not plan tests for these pages, return an empty scenarios list:
3
+ - sign in
4
+ - sign up
5
+ - password recovery
6
+ </ignored_pages>
@@ -0,0 +1,8 @@
1
+ <accessibility priority="CRITICAL">
2
+ - Every control has an accessible name; icon-only controls included. Confirm against the ARIA tree, not the picture.
3
+ - Interactive elements are reachable by keyboard in visual order, with a visible focus indicator.
4
+ - Headings form a sequential outline without skipped levels.
5
+ - Meaning never rests on color alone: state, error and status also carry text or an icon.
6
+ - Dialogs and multi-step flows offer a visible way out.
7
+ - Contrast is reported only from axe results, never judged by eye.
8
+ </accessibility>
@@ -0,0 +1,6 @@
1
+ <charts priority="LOW">
2
+ Apply only when a chart or data visualisation is inside the focus area:
3
+ - Series are identified by a legend or direct labels, not by color alone.
4
+ - Exact values are reachable without hover.
5
+ - An empty or failed dataset shows a message, not a blank frame.
6
+ </charts>
@@ -0,0 +1,9 @@
1
+ <copy priority="HIGH">
2
+ Judge what a text does for the user, not how it is phrased:
3
+ - Intent: a label or heading must tell what happens or what the area is for; a text that names no outcome fails.
4
+ - Duplication: two texts in the focus area that say the same thing, or a text that repeats the visible label beside it.
5
+ - Relevance: a text that explains nothing the interface does not already show, or concerns something outside the user's task here.
6
+ - Filler: generic, promotional or self-congratulatory text that could sit unchanged on any product.
7
+ - Overload: more text than the task needs, competing with the controls for attention.
8
+ Quote the text and state which check it fails. Never propose replacement wording.
9
+ </copy>
@@ -0,0 +1,7 @@
1
+ <forms priority="MEDIUM">
2
+ - Each input has a visible label that stays when the user types; a placeholder alone is not a label.
3
+ - Errors appear next to the input they concern and state cause and recovery.
4
+ - Required inputs are marked before submission, not discovered after it.
5
+ - Long or complex forms reveal options progressively instead of all at once.
6
+ - A completed submit confirms its result.
7
+ </forms>
@@ -0,0 +1,7 @@
1
+ <interaction priority="CRITICAL">
2
+ - Targets meet the size and spacing limits in measurements: touch limits at touch widths, pointer limits above.
3
+ - Every action is reachable by click or tap; hover may reveal, never be the only way in.
4
+ - An action that waits on the server shows it is working and blocks a second submit.
5
+ - Destructive or irreversible actions ask for confirmation or offer undo.
6
+ - Disabled controls look disabled and say, or make evident, why.
7
+ </interaction>
@@ -0,0 +1,5 @@
1
+ <motion priority="MEDIUM">
2
+ Judge only what measurements report:
3
+ - Transitions on layout properties, which make content jump while they run.
4
+ - Animations that keep running when the user asks for reduced motion.
5
+ </motion>
@@ -0,0 +1,7 @@
1
+ <navigation priority="HIGH">
2
+ - The current location is marked in the navigation that leads to it.
3
+ - Back behaves predictably; a state worth sharing or returning to has its own URL.
4
+ - Navigation offers few top-level choices; grouping beats a long flat list.
5
+ - Destructive or session-ending items are set apart from ordinary destinations.
6
+ - Modals do not carry primary navigation flows.
7
+ </navigation>
@@ -0,0 +1,6 @@
1
+ <readability priority="MEDIUM">
2
+ Judge only what measurements report:
3
+ - Body text below the minimum size.
4
+ - Multi-line text with cramped line-height.
5
+ - Truncated text that hides meaning and offers no way to read it in full.
6
+ </readability>
@@ -0,0 +1,7 @@
1
+ <responsive priority="HIGH">
2
+ - Compare breakpoints: the focus area must stay usable at every width it was measured at.
3
+ - No horizontal scroll; content fits the viewport width. Zoom must never be disabled.
4
+ - When navigation does not fit, it collapses behind one named, reachable control; nothing primary disappears without a way back to it.
5
+ - On narrow screens core content comes first; secondary panels fold, they do not push the task below the fold.
6
+ - Fixed or sticky bars never cover the content or controls beneath them.
7
+ </responsive>
package/src/ai/judge.ts CHANGED
@@ -15,7 +15,8 @@ export const JUDGE_PAGE_CAP = 12000;
15
15
  export class Decision {
16
16
  constructor(
17
17
  readonly value: string | null,
18
- readonly confidence: number
18
+ readonly confidence: number,
19
+ readonly answered = true
19
20
  ) {}
20
21
 
21
22
  get approved(): boolean {
@@ -46,10 +47,17 @@ export class Judge {
46
47
  }
47
48
 
48
49
  async decide(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision> {
49
- if (!this.enabled.direct) return new Decision(null, 0);
50
+ if (!this.enabled.direct) return new Decision(null, 0, false);
50
51
  return this.consult(question, options, state);
51
52
  }
52
53
 
54
+ async assert(statement: string, state: unknown): Promise<boolean | null> {
55
+ const decision = await this.decide(statement, null, state);
56
+ if (decision.approved) return true;
57
+ if (decision.answered && decision.confidence < 1 - this.threshold) return false;
58
+ return null;
59
+ }
60
+
53
61
  async pick(question: string, options: string[], state: unknown): Promise<number | null> {
54
62
  if (new Set(options).size < options.length) return null;
55
63
  const decision = await this.decide(question, [...options, UNDECIDED], state);
@@ -76,7 +84,7 @@ export class Judge {
76
84
  if (list?.includes(UNDECIDED)) instructions = `${question} ${UNDECIDED_HINT}`;
77
85
 
78
86
  const answer = await this.provider.decide(state, instructions, list).catch((error: unknown) => this.recordFailure(error));
79
- if (!answer) return new Decision(null, 0);
87
+ if (!answer) return new Decision(null, 0, false);
80
88
  Observability.getSpan()?.setAttribute('langfuse.observation.output', JSON.stringify(answer));
81
89
  if (answer.probability <= this.threshold) return new Decision(null, answer.probability);
82
90
  if (answer.value === UNDECIDED) return new Decision(null, answer.probability);
@@ -857,8 +857,8 @@ class Navigator implements Agent {
857
857
  }
858
858
 
859
859
  const totalAttempted = Math.min(codeBlocks.length, this.verifyAttempts);
860
- const majorityNeeded = Math.floor(totalAttempted / 2) + 1;
861
- let verified = successfulCodes.length >= majorityNeeded;
860
+ let verified = successfulCodes.length > 0 && !failures;
861
+ if (successfulCodes.length && failures) verified = await this.provider.oracle(message, { assertions: results.map(({ code, passed, error }) => ({ code, passed, error })) }, { agentName: 'navigator', telemetry: { functionId: 'navigator.verifyState' } });
862
862
  if (alreadyVerified) verified = true;
863
863
 
864
864
  const inexpressible = !alreadyVerified && totalAttempted === 0;
package/src/ai/pilot.ts CHANGED
@@ -631,8 +631,10 @@ export class Pilot implements Agent {
631
631
  outcomes: z.array(
632
632
  z.object({
633
633
  expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
634
- status: z.enum(['passed', 'failed', 'unverified', 'contradiction']).describe('passed = the evidence shows it happened to the thing it names, failed = the evidence shows it did not, unverified = the run never established either way, contradiction = the picture and the run disagree'),
635
- evidence: z.string().nullable().describe('What settled it. For a contradiction, what each side shows. Null when there is nothing to add'),
634
+ status: z
635
+ .enum(['passed', 'failed', 'unverified', 'contradiction'])
636
+ .describe('passed = the evidence shows it happened to the thing it names, failed = the evidence shows the application did not do it, unverified = the run never established either way, contradiction = the picture and the run disagree'),
637
+ evidence: z.string().nullable().describe('What settled it. For unverified, what stopped the run from checking it. For a contradiction, what each side shows. Null when there is nothing to add'),
636
638
  })
637
639
  ),
638
640
  });
@@ -674,10 +676,14 @@ export class Pilot implements Agent {
674
676
  The log is written in the tester's own words, so an outcome can be satisfied by a step that describes it
675
677
  differently. Judge by what the steps show happened, not by whether the wording matches.
676
678
  Wording may differ, the subject may not. An outcome refers to what the scenario names or chooses; it holds
677
- only when that same thing appears. A different route or a different thing reaching a similar state is "failed",
678
- and so is an outcome about something the run never managed to choose or create.
679
+ only when that same thing appears. A different route or a different thing reaching a similar state is "failed".
680
+ An outcome about something the run never managed to choose or create never passes: it is "failed" when the
681
+ application refused it, "unverified" when the run's own trouble got in the way.
679
682
  Choose "unverified" only when the evidence neither shows the outcome happening nor shows it failing —
680
683
  that is a statement about the run, not about the application.
684
+ "failed" is a statement about the application. When the run's own trouble — an earlier step it could not
685
+ get past, a control it could not reach or operate — left the outcome untested, it is "unverified", and
686
+ the evidence names that blocker.
681
687
  `;
682
688
 
683
689
  const settle = (content: any, model: any) =>
package/src/ai/planner.ts CHANGED
@@ -24,6 +24,7 @@ import type { Provider } from './provider.js';
24
24
  import { POSSIBLE_SECTIONS, type Researcher } from './researcher.ts';
25
25
  import { findSimilarStateHash } from './researcher/cache.ts';
26
26
  import { hasFocusedSection } from './researcher/focus.ts';
27
+ import { RulesLoader } from '../utils/rules-loader.ts';
27
28
  import { capabilityGroundingRule, dataProtectionRules, fileUploadRule } from './rules.ts';
28
29
  import type { Scout } from './scout.ts';
29
30
 
@@ -44,6 +45,7 @@ const TasksSchema = z.object({
44
45
  })
45
46
  )
46
47
  .describe('List of testing scenarios'),
48
+ notPlannableReason: z.string().nullish().describe('When scenarios is empty, one sentence saying why this page has nothing to test; otherwise null.'),
47
49
  });
48
50
 
49
51
  const PlannerBase = WithSessionDedup(WithSubPages(Object as unknown as new (...args: any[]) => object));
@@ -210,6 +212,10 @@ export class Planner extends PlannerBase implements Agent {
210
212
  throw new Error('No tasks were created successfully');
211
213
  }
212
214
 
215
+ if (!this.currentPlan && aiResult.object.scenarios.length === 0 && aiResult.object.notPlannableReason) {
216
+ tag('warning').log(`Page is not plannable: ${aiResult.object.notPlannableReason}`);
217
+ }
218
+
213
219
  const defaultStartUrl = this.getDefaultStartUrl(state);
214
220
  const fromPlanning = aiResult.object.scenarios.map((s: any) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || defaultStartUrl, s.steps || []));
215
221
 
@@ -345,11 +351,14 @@ export class Planner extends PlannerBase implements Agent {
345
351
 
346
352
  conversation.addUserText(this.getSystemMessage(feature));
347
353
 
354
+ let ignoredPagesRule = '';
355
+ if (!feature) ignoredPagesRule = `\n${RulesLoader.loadRules('planner', ['ignored-pages'], state.url)}`;
356
+
348
357
  const planningPrompt = dedent`
349
358
  <task>
350
359
  Based on the page research, create ${this.MIN_TASKS}-${this.MAX_TASKS} exploratory testing scenarios.
351
360
  For each scenario provide specific steps and expected outcomes.
352
- Exception: if the page reports the requested resource is missing, shows a failure state, or holds no content and no controls, return an empty scenarios list. Never invent tests for a page with nothing to exercise.
361
+ Exception: if the page reports the requested resource is missing, shows a failure state, or holds no content and no controls, return an empty scenarios list. Never invent tests for a page with nothing to exercise.${ignoredPagesRule}
353
362
  </task>
354
363
 
355
364
  <rules>
@@ -2,12 +2,12 @@ import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { OpenTelemetry } from '@ai-sdk/otel';
3
3
  import { LangfuseSpanProcessor } from '@langfuse/otel';
4
4
  import { NodeSDK } from '@opentelemetry/sdk-node';
5
- import { APICallError, NoObjectGeneratedError, asSchema, extractJsonMiddleware, generateObject, generateText, isStepCount, parsePartialJson, registerTelemetry, tool, wrapLanguageModel } from 'ai';
5
+ import { APICallError, NoObjectGeneratedError, NoOutputGeneratedError, Output, asSchema, extractJsonMiddleware, generateText, isStepCount, parsePartialJson, registerTelemetry, tool, wrapLanguageModel } from 'ai';
6
6
  import type { ModelMessage } from 'ai';
7
7
  import dedent from 'dedent';
8
8
  import { z } from 'zod';
9
9
  import { clearActivity, setActivity } from '../activity.ts';
10
- import { type AIConfig, configuredModels, modelName as getModelName } from '../config.js';
10
+ import { type AIConfig, PROVIDERS, configuredModels, modelName as getModelName, modelProvider } from '../config.js';
11
11
  import { executionController } from '../execution-controller.ts';
12
12
  import { Observability } from '../observability.ts';
13
13
  import { Stats } from '../stats.ts';
@@ -16,6 +16,7 @@ import { createDebug, tag } from '../utils/logger.js';
16
16
  import { type RetryOptions, withRetry } from '../utils/retry.js';
17
17
  import { RulesLoader } from '../utils/rules-loader.ts';
18
18
  import { Conversation, NARRATION_TOOL, toToolExecution } from './conversation.js';
19
+ import type { Judge } from './judge.ts';
19
20
 
20
21
  const debugLog = createDebug('explorbot:provider');
21
22
  const promptLog = createDebug('explorbot:provider:out');
@@ -84,6 +85,7 @@ function combinedAbortSignal(controller: AbortController): AbortSignal {
84
85
 
85
86
  export class Provider {
86
87
  private config: AIConfig;
88
+ private judge: Judge | null = null;
87
89
  private telemetryEnabled = false;
88
90
  private otelSdk: NodeSDK | null = null;
89
91
  private defaultRetryOptions: RetryOptions = {
@@ -99,6 +101,7 @@ export class Provider {
99
101
  error.message.includes('AI request timeout') ||
100
102
  error.message.includes('schema') ||
101
103
  error.message.includes('No object generated') ||
104
+ error.message.includes('No output generated') ||
102
105
  error.message.includes('No response text') ||
103
106
  error.message.includes('validate JSON')) &&
104
107
  !error.message.includes('output truncated at maxTokens')
@@ -125,6 +128,12 @@ export class Provider {
125
128
  prompt: 'hi',
126
129
  });
127
130
  } catch (error: any) {
131
+ const envKey = PROVIDERS[modelProvider(this.config.model)]?.envKey;
132
+ const apiKey = envKey && process.env[envKey];
133
+ if (envKey && !apiKey) throw new AiError(`AI connection failed: ${envKey} is not set. ${error.message}`);
134
+ if (apiKey && [401, 403].includes(error.statusCode)) {
135
+ throw new AiError(`AI connection failed: ${error.message}. ${envKey}=${apiKey.slice(0, 5)}**${apiKey.slice(-3)} was rejected, check the key`);
136
+ }
128
137
  throw new AiError(`AI connection failed: ${error.message}`);
129
138
  }
130
139
  }
@@ -142,6 +151,20 @@ export class Provider {
142
151
  return agentConfig?.model || this.config.model;
143
152
  }
144
153
 
154
+ setJudge(judge: Judge | null): void {
155
+ this.judge = judge;
156
+ }
157
+
158
+ async oracle(statement: string, state: unknown, options: any = {}): Promise<boolean> {
159
+ const judged = await this.judge?.assert(statement, state);
160
+ if (typeof judged === 'boolean') return judged;
161
+
162
+ const schema = z.object({ holds: z.boolean().describe('whether the statement is true') });
163
+ const content = `${statement}\n\n${JSON.stringify(state, null, 2)}`;
164
+ const response = await this.generateObject([{ role: 'user' as const, content }], schema, this.getAgenticModel(options.agentName), options).catch(() => null);
165
+ return response?.object?.holds === true;
166
+ }
167
+
145
168
  getAgenticModel(agentName?: string): any {
146
169
  if (agentName) {
147
170
  const agentConfig = this.config.agents?.[agentName as keyof typeof this.config.agents];
@@ -530,13 +553,13 @@ export class Provider {
530
553
  setActivity(`🤖 Asking ${modelName} for structured output`, 'ai');
531
554
  promptLog(`Using model: ${modelName}`);
532
555
 
533
- const config = this.buildGenerateConfig({ schema }, { model: modelToUse }, options);
534
-
535
556
  try {
557
+ const { timeout, ...config } = this.buildGenerateConfig({ output: Output.object({ schema }) }, { model: modelToUse }, options);
536
558
  promptLog(messages[messages.length - 1].content);
537
559
  const response = await this.withModelRequestSlot(() =>
538
560
  withRetry(async () => {
539
- return (await this.raceWithIdleTimeout((signal) => generateObject({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
561
+ const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), timeout || 30000)) as any;
562
+ return { object: result.output, usage: result.usage };
540
563
  }, this.getRetryOptions(options))
541
564
  );
542
565
 
@@ -553,7 +576,7 @@ export class Provider {
553
576
  if (Provider.isContextLengthError(error)) {
554
577
  return this.recoverFromContextLength(error, messages, options, (m, o) => this.generateObject(m, schema, model, o));
555
578
  }
556
- if (NoObjectGeneratedError.isInstance(error)) return this.recoverWithPlainJson(messages, schema, modelToUse, options);
579
+ if (NoObjectGeneratedError.isInstance(error) || NoOutputGeneratedError.isInstance(error)) return this.recoverWithPlainJson(messages, schema, modelToUse, options);
557
580
  throw new AiError(error.message || error.toString());
558
581
  }
559
582
  }