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.
- package/README.md +1 -1
- package/bin/explorbot-cli.ts +25 -4
- package/boat/api-tester/src/commands/init-command.ts +2 -0
- package/boat/doc-collector/bin/doc-collector-cli.ts +2 -1
- package/boat/prima/README.md +3 -0
- package/boat/prima/bin/prima-cli.ts +2 -1
- package/boat/prima/src/cli.ts +7 -1
- package/boat/prima/src/envelope.ts +18 -4
- package/boat/prima/src/help.ts +15 -4
- package/boat/prima/src/prima.ts +181 -25
- package/dist/bin/explorbot-cli.js +31 -4
- package/dist/boat/api-tester/bin/apibot-cli.js +2 -1
- package/dist/boat/api-tester/src/commands/init-command.js +2 -0
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -1
- package/dist/boat/prima/bin/prima-cli.js +2 -1
- package/dist/boat/prima/src/cli.js +6 -1
- package/dist/boat/prima/src/envelope.js +16 -3
- package/dist/boat/prima/src/help.js +14 -4
- package/dist/boat/prima/src/prima.js +191 -27
- package/dist/package.json +1 -1
- package/dist/rules/navigator/verification-actions.md +5 -3
- package/dist/rules/planner/ignored-pages.md +6 -0
- package/dist/rules/quartermaster/ux/accessibility.md +8 -0
- package/dist/rules/quartermaster/ux/charts.md +6 -0
- package/dist/rules/quartermaster/ux/copy.md +9 -0
- package/dist/rules/quartermaster/ux/forms.md +7 -0
- package/dist/rules/quartermaster/ux/interaction.md +7 -0
- package/dist/rules/quartermaster/ux/motion.md +5 -0
- package/dist/rules/quartermaster/ux/navigation.md +7 -0
- package/dist/rules/quartermaster/ux/readability.md +6 -0
- package/dist/rules/quartermaster/ux/responsive.md +7 -0
- package/dist/src/ai/judge.d.ts +3 -1
- package/dist/src/ai/judge.js +13 -3
- package/dist/src/ai/navigator.js +3 -2
- package/dist/src/ai/pilot.js +10 -4
- package/dist/src/ai/planner.js +9 -1
- package/dist/src/ai/provider.d.ts +4 -0
- package/dist/src/ai/provider.js +27 -5
- package/dist/src/ai/quartermaster.d.ts +31 -3
- package/dist/src/ai/quartermaster.js +262 -17
- package/dist/src/ai/tester.js +6 -3
- package/dist/src/commands/init-command.js +4 -0
- package/dist/src/commands/options/auth-option.d.ts +6 -0
- package/dist/src/commands/options/auth-option.js +14 -0
- package/dist/src/commands/options/index.d.ts +2 -0
- package/dist/src/commands/options/index.js +2 -0
- package/dist/src/config.js +1 -0
- package/dist/src/explorbot.d.ts +1 -1
- package/dist/src/explorbot.js +8 -13
- package/dist/src/explorer.js +1 -0
- package/dist/src/global-config.js +6 -2
- package/dist/src/utils/ux-probe.d.ts +25 -0
- package/dist/src/utils/ux-probe.js +155 -0
- package/docs/reference/commands.md +12 -0
- package/docs/workflow/agentic-usage.md +7 -0
- package/docs/workflow/knowledge.md +6 -0
- package/package.json +1 -1
- package/rules/navigator/verification-actions.md +5 -3
- package/rules/planner/ignored-pages.md +6 -0
- package/rules/quartermaster/ux/accessibility.md +8 -0
- package/rules/quartermaster/ux/charts.md +6 -0
- package/rules/quartermaster/ux/copy.md +9 -0
- package/rules/quartermaster/ux/forms.md +7 -0
- package/rules/quartermaster/ux/interaction.md +7 -0
- package/rules/quartermaster/ux/motion.md +5 -0
- package/rules/quartermaster/ux/navigation.md +7 -0
- package/rules/quartermaster/ux/readability.md +6 -0
- package/rules/quartermaster/ux/responsive.md +7 -0
- package/src/ai/judge.ts +11 -3
- package/src/ai/navigator.ts +2 -2
- package/src/ai/pilot.ts +10 -4
- package/src/ai/planner.ts +10 -1
- package/src/ai/provider.ts +29 -6
- package/src/ai/quartermaster.ts +300 -18
- package/src/ai/tester.ts +5 -3
- package/src/commands/init-command.ts +5 -0
- package/src/commands/options/auth-option.ts +15 -0
- package/src/commands/options/index.ts +2 -0
- package/src/config.ts +1 -0
- package/src/explorbot.ts +9 -13
- package/src/explorer.ts +1 -0
- package/src/global-config.ts +5 -2
- 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
|
@@ -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('
|
|
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('
|
|
107
|
+
I.dontSeeInSource('Session expired');
|
|
106
108
|
</example>
|
|
107
109
|
|
|
108
110
|
### Asserting the state of a control
|
|
@@ -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,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,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);
|
package/src/ai/navigator.ts
CHANGED
|
@@ -857,8 +857,8 @@ class Navigator implements Agent {
|
|
|
857
857
|
}
|
|
858
858
|
|
|
859
859
|
const totalAttempted = Math.min(codeBlocks.length, this.verifyAttempts);
|
|
860
|
-
|
|
861
|
-
|
|
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
|
|
635
|
-
|
|
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
|
-
|
|
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>
|
package/src/ai/provider.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
}
|