explorbot 0.5.7 → 0.5.9
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/bin/explorbot-cli.ts +25 -3
- package/boat/prima/README.md +3 -0
- 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 -3
- 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/explore-command.js +7 -0
- package/dist/src/commands/init-command.d.ts +1 -0
- package/dist/src/commands/init-command.js +87 -36
- package/dist/src/components/InitWizard.d.ts +5 -1
- package/dist/src/components/InitWizard.js +29 -11
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +29 -7
- 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/basics/getting-started.md +23 -10
- package/docs/basics/providers.md +2 -0
- package/docs/reference/commands.md +5 -2
- package/docs/reference/configuration.md +1 -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/explore-command.ts +8 -0
- package/src/commands/init-command.ts +105 -38
- package/src/components/InitWizard.tsx +43 -11
- package/src/config.ts +32 -7
- 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
|
+
}
|
|
@@ -15,21 +15,28 @@ You need Node.js 24+ (or Bun), an AI provider key, and a modern terminal — iTe
|
|
|
15
15
|
|
|
16
16
|
## 2. Configure
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Run the setup wizard:
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
npx explorbot init
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
It asks for your AI provider, its API key, and your app's URL, then writes everything for you: `explorbot.config.js`, an `.env` file with the key stored, and an `output/` folder. No manual file editing.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
The generated config keeps models simple — one line picks the recommended set for your provider:
|
|
27
27
|
|
|
28
|
-
```
|
|
29
|
-
|
|
28
|
+
```javascript
|
|
29
|
+
export default {
|
|
30
|
+
web: {
|
|
31
|
+
url: 'http://localhost:3000',
|
|
32
|
+
},
|
|
33
|
+
ai: {
|
|
34
|
+
recommendedModels: 'openrouter',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
30
37
|
```
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
`ai.recommendedModels` fills every model role from the provider's bundled recommendations. To pin a single role instead, write it as `'provider/model-id'` — it overrides the recommendation:
|
|
33
40
|
|
|
34
41
|
```javascript
|
|
35
42
|
export default {
|
|
@@ -37,10 +44,8 @@ export default {
|
|
|
37
44
|
url: 'http://localhost:3000',
|
|
38
45
|
},
|
|
39
46
|
ai: {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
agenticModel: 'openrouter/anthropic/claude-haiku-5.5',
|
|
43
|
-
decisionModel: { provider: 'openrouter', model: 'typesafe/jev-latest' },
|
|
47
|
+
recommendedModels: 'openrouter',
|
|
48
|
+
agenticModel: 'groq/openai/gpt-oss-120b',
|
|
44
49
|
},
|
|
45
50
|
};
|
|
46
51
|
```
|
|
@@ -84,6 +89,14 @@ Explorbot uses three models. Pick each one for speed and cost:
|
|
|
84
89
|
|
|
85
90
|
Captain and Pilot barely use tokens, so a smarter `agenticModel` improves results for almost no extra cost. OpenRouter is the simplest start — one key, many models. To use OpenAI, Anthropic, Groq, or others, see [Providers](./providers.md). For every config option, see [Configuration](../reference/configuration.md).
|
|
86
91
|
|
|
92
|
+
Want to see Explorbot work before pointing it at your app? Run it on the sample app:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npx explorbot explore https://todomvc.com/examples/react/dist/
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
You can also run it from the [VS Code extension](https://testomat.ai/explorbot) — pick a site, start a bot, and watch it test without leaving the editor. Full documentation lives at [testomat.ai/docs/explorbot](https://testomat.ai/docs/explorbot).
|
|
99
|
+
|
|
87
100
|
## 3. Tell Explorbot how to log in
|
|
88
101
|
|
|
89
102
|
Most apps need a login. Give Explorbot the credentials once, and it signs in on its own:
|
package/docs/basics/providers.md
CHANGED
|
@@ -4,6 +4,8 @@ Explorbot connects to AI providers through the [Vercel AI SDK](https://sdk.verce
|
|
|
4
4
|
|
|
5
5
|
Every provider below is set up the classical way: install its package, import it, build the client. Explorbot bundles some of these packages — for those you can skip the install and name the model as `'provider/model-id'` instead. See [Getting Started](./getting-started.md#2-configure) for that list and the two styles side by side.
|
|
6
6
|
|
|
7
|
+
A third, shortest style names just the provider: `ai: { recommendedModels: 'openrouter' }` fills every unset role from that provider's bundled recommendations. An explicit role set alongside it wins, so `recommendedModels` with a pinned `agenticModel` mixes both styles.
|
|
8
|
+
|
|
7
9
|
> The `export default` config block inside each `<!-- START/END provider -->` marker is generated from [`models.json`](../../models.json). After editing that file, run `bunosh docs:sync`. Everything else — including the import blocks — is hand-written.
|
|
8
10
|
|
|
9
11
|
## Requirements
|
|
@@ -920,7 +920,9 @@ npx explorbot init --config-path ./explorbot.config.js
|
|
|
920
920
|
npx explorbot init --force
|
|
921
921
|
```
|
|
922
922
|
|
|
923
|
-
|
|
923
|
+
The local wizard picks the provider, takes the API key (optionally checked with one test AI call), and asks for your app's URL — then writes everything: the key goes into `.env`, the URL into `web.url`, and the models come from `ai.recommendedModels: '<provider>'`. Nothing is left to edit by hand.
|
|
924
|
+
|
|
925
|
+
Passing `--config-path` or `--path` means local, and so does running outside a terminal (agents, CI): the chooser is skipped and the project config is written with placeholder values instead. Init then prints the exact paths of the files that still need attention, a demo command to try Explorbot on a sample app (`npx explorbot explore https://todomvc.com/examples/react/dist/`), and links to the [VS Code extension](https://testomat.ai/explorbot) and the [docs](https://testomat.ai/docs/explorbot).
|
|
924
926
|
|
|
925
927
|
`--global` runs the global wizard instead — pick a provider, paste the API key, optionally check it with one test AI call. The wizard writes `~/.explorbot/config.js` with the recommended model ids of this Explorbot version and stores the key in `~/.explorbot/.env`.
|
|
926
928
|
|
|
@@ -939,7 +941,8 @@ The global config holds models and keys, never a site: every command names the s
|
|
|
939
941
|
| `-p, --path <path>` | Working directory for initialization |
|
|
940
942
|
| `-g, --global` | Configure `~/.explorbot` to run from anywhere |
|
|
941
943
|
| `--provider <name>` | AI provider for the global config, skips the wizard |
|
|
942
|
-
| `--api-key <key>` | API key stored in `~/.explorbot/.env` |
|
|
944
|
+
| `--api-key <key>` | API key stored in `~/.explorbot/.env` (local init: written to the project `.env`) |
|
|
945
|
+
| `--url <url>` | Web application URL written to `web.url` in the local config |
|
|
943
946
|
|
|
944
947
|
See [Configuration](configuration.md#running-from-anywhere-the-global-installation) for the directory layout and how a site is resolved.
|
|
945
948
|
|
|
@@ -536,6 +536,7 @@ export default {
|
|
|
536
536
|
// AI provider settings
|
|
537
537
|
ai: {
|
|
538
538
|
model: groq('openai/gpt-oss-20b'), // Default model instance (required)
|
|
539
|
+
recommendedModels: 'openrouter', // Fills every unset role from this provider's bundled recommendations; explicit roles win
|
|
539
540
|
visionModel: groq('meta-llama/llama-4-scout-17b-16e-instruct'), // Model for screenshot analysis; setting it enables vision features
|
|
540
541
|
decisionModel: { provider: 'openrouter', model: 'typesafe/jev-1.13' }, // Optional; see providers docs
|
|
541
542
|
config: {}, // Additional provider config
|
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
|
}
|