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
|
@@ -15,14 +15,16 @@ interface InitWizardProps {
|
|
|
15
15
|
onLocal: () => void;
|
|
16
16
|
onComplete: () => void;
|
|
17
17
|
onCancel: () => void;
|
|
18
|
-
|
|
18
|
+
onLocalSetup?: (setup: { provider: string; apiKey: string; url: string }) => void;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLocal, onComplete, onCancel,
|
|
22
|
-
const [step, setStep] = useState<'target' | 'provider' | 'key' | 'validate'>(mode === 'choose' ? 'target' : 'provider');
|
|
21
|
+
const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalSetup }) => {
|
|
22
|
+
const [step, setStep] = useState<'target' | 'provider' | 'key' | 'validate' | 'url'>(mode === 'choose' ? 'target' : 'provider');
|
|
23
23
|
const [targetIndex, setTargetIndex] = useState(0);
|
|
24
24
|
const [providerIndex, setProviderIndex] = useState(0);
|
|
25
25
|
const [apiKey, setApiKey] = useState('');
|
|
26
|
+
const [url, setUrl] = useState('');
|
|
27
|
+
const [urlError, setUrlError] = useState('');
|
|
26
28
|
const [status, setStatus] = useState('');
|
|
27
29
|
const [error, setError] = useState('');
|
|
28
30
|
|
|
@@ -30,6 +32,11 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
|
|
|
30
32
|
const envKey = PROVIDERS[provider].envKey;
|
|
31
33
|
|
|
32
34
|
const save = () => {
|
|
35
|
+
if (mode === 'local') {
|
|
36
|
+
setStatus('');
|
|
37
|
+
setStep('url');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
33
40
|
writeGlobalConfig(provider, apiKey.trim());
|
|
34
41
|
onComplete();
|
|
35
42
|
};
|
|
@@ -61,7 +68,7 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
|
|
|
61
68
|
return;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
|
-
if (step === 'key') return;
|
|
71
|
+
if (step === 'key' || step === 'url') return;
|
|
65
72
|
|
|
66
73
|
if (key.escape) {
|
|
67
74
|
onCancel();
|
|
@@ -79,10 +86,7 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
|
|
|
79
86
|
if (step === 'provider') {
|
|
80
87
|
if (key.upArrow) setProviderIndex((index) => Math.max(0, index - 1));
|
|
81
88
|
if (key.downArrow) setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
|
|
82
|
-
if (key.return)
|
|
83
|
-
if (mode === 'local') onLocalProvider?.(provider);
|
|
84
|
-
else setStep('key');
|
|
85
|
-
}
|
|
89
|
+
if (key.return) setStep('key');
|
|
86
90
|
return;
|
|
87
91
|
}
|
|
88
92
|
|
|
@@ -132,7 +136,10 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
|
|
|
132
136
|
{step === 'key' && (
|
|
133
137
|
<Box flexDirection="column">
|
|
134
138
|
<Text>
|
|
135
|
-
Enter the API key for {provider}
|
|
139
|
+
Enter the API key for {provider}{' '}
|
|
140
|
+
<Text dimColor>
|
|
141
|
+
(stored as {envKey}, create one at {PROVIDERS[provider].keyUrl})
|
|
142
|
+
</Text>
|
|
136
143
|
</Text>
|
|
137
144
|
<Box borderStyle="single" borderColor="blue" paddingX={1}>
|
|
138
145
|
<InputReadline value={apiKey} onChange={setApiKey} onSubmit={() => setStep('validate')} placeholder={process.env[envKey] ? 'leave empty to keep the key from your environment' : 'paste the key'} isActive showPrompt={false} mask />
|
|
@@ -147,15 +154,40 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
|
|
|
147
154
|
{error && (
|
|
148
155
|
<Box flexDirection="column">
|
|
149
156
|
<Text color="red">{error}</Text>
|
|
150
|
-
<Text dimColor>r: re-enter the key | n:
|
|
157
|
+
<Text dimColor>r: re-enter the key | n: continue anyway</Text>
|
|
151
158
|
</Box>
|
|
152
159
|
)}
|
|
153
160
|
</Box>
|
|
154
161
|
)}
|
|
155
162
|
|
|
163
|
+
{step === 'url' && (
|
|
164
|
+
<Box flexDirection="column">
|
|
165
|
+
<Text>
|
|
166
|
+
What is the URL of the web application to test? <Text dimColor>(written to web.url in the config)</Text>
|
|
167
|
+
</Text>
|
|
168
|
+
<Box borderStyle="single" borderColor="blue" paddingX={1}>
|
|
169
|
+
<InputReadline
|
|
170
|
+
value={url}
|
|
171
|
+
onChange={setUrl}
|
|
172
|
+
onSubmit={() => {
|
|
173
|
+
if (!URL.canParse(url.trim())) {
|
|
174
|
+
setUrlError('Enter a full URL, e.g. http://localhost:3000');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
onLocalSetup?.({ provider, apiKey: apiKey.trim(), url: url.trim() });
|
|
178
|
+
}}
|
|
179
|
+
placeholder="http://localhost:3000"
|
|
180
|
+
isActive
|
|
181
|
+
showPrompt={false}
|
|
182
|
+
/>
|
|
183
|
+
</Box>
|
|
184
|
+
{urlError && <Text color="red">{urlError}</Text>}
|
|
185
|
+
</Box>
|
|
186
|
+
)}
|
|
187
|
+
|
|
156
188
|
<Box marginTop={1}>
|
|
157
189
|
<Text dimColor>
|
|
158
|
-
Config goes to {mode === 'local' ? 'the current directory' : globalDir()} | {step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm'} | Ctrl+C: exit
|
|
190
|
+
Config goes to {mode === 'local' ? 'the current directory' : globalDir()} | {step === 'key' || step === 'url' ? 'Enter: continue' : '↑↓: select | Enter: confirm'} | Ctrl+C: exit
|
|
159
191
|
</Text>
|
|
160
192
|
</Box>
|
|
161
193
|
</Box>
|
package/src/config.ts
CHANGED
|
@@ -11,13 +11,13 @@ import { deepMerge } from './utils/merge.js';
|
|
|
11
11
|
import { log, tag } from './utils/logger.js';
|
|
12
12
|
|
|
13
13
|
export const PROVIDERS: Record<string, ProviderInfo> = {
|
|
14
|
-
openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
|
|
15
|
-
anthropic: { envKey: 'ANTHROPIC_API_KEY', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
|
|
16
|
-
google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
|
|
17
|
-
groq: { envKey: 'GROQ_API_KEY', load: async () => (await import('@ai-sdk/groq')).createGroq() },
|
|
18
|
-
mistral: { envKey: 'MISTRAL_API_KEY', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
|
|
19
|
-
openrouter: { envKey: 'OPENROUTER_API_KEY', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
|
|
20
|
-
sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
|
|
14
|
+
openai: { envKey: 'OPENAI_API_KEY', keyUrl: 'https://platform.openai.com/api-keys', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
|
|
15
|
+
anthropic: { envKey: 'ANTHROPIC_API_KEY', keyUrl: 'https://console.anthropic.com/settings/keys', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
|
|
16
|
+
google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', keyUrl: 'https://aistudio.google.com/apikey', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
|
|
17
|
+
groq: { envKey: 'GROQ_API_KEY', keyUrl: 'https://console.groq.com/keys', load: async () => (await import('@ai-sdk/groq')).createGroq() },
|
|
18
|
+
mistral: { envKey: 'MISTRAL_API_KEY', keyUrl: 'https://console.mistral.ai/api-keys', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
|
|
19
|
+
openrouter: { envKey: 'OPENROUTER_API_KEY', keyUrl: 'https://openrouter.ai/keys', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
|
|
20
|
+
sambanova: { envKey: 'SAMBANOVA_API_KEY', keyUrl: 'https://cloud.sambanova.ai/apis', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export const MODEL_ROLES: ModelRole[] = ['model', 'visionModel', 'agenticModel'];
|
|
@@ -169,6 +169,7 @@ interface AgentsConfig {
|
|
|
169
169
|
|
|
170
170
|
interface AIConfig {
|
|
171
171
|
model: any;
|
|
172
|
+
recommendedModels?: string;
|
|
172
173
|
apiKey?: string;
|
|
173
174
|
config?: Record<string, any>;
|
|
174
175
|
langfuse?: {
|
|
@@ -890,11 +891,34 @@ export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
|
|
|
890
891
|
if (typeof ai[role] === 'string') ai[role] = await resolveModel(ai[role], role);
|
|
891
892
|
}
|
|
892
893
|
|
|
894
|
+
await fillRecommendedModels(ai);
|
|
895
|
+
|
|
893
896
|
for (const agent of Object.values(ai.agents || {})) {
|
|
894
897
|
if (typeof agent?.model === 'string') agent.model = await resolveModel(agent.model);
|
|
895
898
|
}
|
|
896
899
|
}
|
|
897
900
|
|
|
901
|
+
async function fillRecommendedModels(ai: AIConfig): Promise<void> {
|
|
902
|
+
const provider = ai.recommendedModels;
|
|
903
|
+
if (!provider) return;
|
|
904
|
+
|
|
905
|
+
const recommended = ConfigParser.recommendedModels()[provider];
|
|
906
|
+
if (!recommended) {
|
|
907
|
+
throw new Error(`No recommended models for "${provider}". Use a provider with recommendations: ${Object.keys(ConfigParser.recommendedModels()).join(', ')}`);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
for (const role of MODEL_ROLES) {
|
|
911
|
+
if (ai[role]) continue;
|
|
912
|
+
if (!recommended[role]) continue;
|
|
913
|
+
ai[role] = await resolveModel(provider, role);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (!ai.decisionModel && recommended.decisionModel) ai.decisionModel = { provider, model: recommended.decisionModel };
|
|
917
|
+
|
|
918
|
+
const missing = missingModelRoles(provider);
|
|
919
|
+
if (missing.length) tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set them explicitly as "provider/model-id"`);
|
|
920
|
+
}
|
|
921
|
+
|
|
898
922
|
export function resolveLangfuse(ai?: AIConfig): void {
|
|
899
923
|
if (!ai) return;
|
|
900
924
|
|
|
@@ -994,6 +1018,7 @@ interface ConfiguredModel {
|
|
|
994
1018
|
|
|
995
1019
|
interface ProviderInfo {
|
|
996
1020
|
envKey: string;
|
|
1021
|
+
keyUrl: string;
|
|
997
1022
|
load: () => Promise<(modelId: string) => any>;
|
|
998
1023
|
}
|
|
999
1024
|
|
package/src/explorbot.ts
CHANGED
|
@@ -137,6 +137,7 @@ export class ExplorBot {
|
|
|
137
137
|
if (typeof this.options.session === 'string') this.options.session = path.resolve(this.options.path || '.', this.options.session);
|
|
138
138
|
if (this.options.optionalAi) return this.bootstrapOptionalProvider();
|
|
139
139
|
this.provider = new AIProvider(this.config.ai);
|
|
140
|
+
this.provider.setJudge(this.judge());
|
|
140
141
|
await this.provider.validateConnection();
|
|
141
142
|
}
|
|
142
143
|
|
|
@@ -266,8 +267,11 @@ export class ExplorBot {
|
|
|
266
267
|
return new Tester(deps, researcher, navigator, tools);
|
|
267
268
|
});
|
|
268
269
|
|
|
269
|
-
|
|
270
|
-
|
|
270
|
+
if (this.config.ai?.agents?.quartermaster?.enabled === true) {
|
|
271
|
+
const quartermaster = this.agentQuartermaster();
|
|
272
|
+
quartermaster.start(this.stateManager());
|
|
273
|
+
this.agents.tester.setQuartermaster(quartermaster);
|
|
274
|
+
}
|
|
271
275
|
if (this.isHistorianEnabled()) this.agents.tester.setHistorian(this.agentHistorian());
|
|
272
276
|
this.agents.tester.setPilot(this.agentPilot());
|
|
273
277
|
this.agents.tester.setCaptain(this.agentCaptain());
|
|
@@ -289,17 +293,8 @@ export class ExplorBot {
|
|
|
289
293
|
return (this.agents.experienceCompactor ||= new ExperienceCompactor(this.provider, this.experienceTracker()));
|
|
290
294
|
}
|
|
291
295
|
|
|
292
|
-
agentQuartermaster(): Quartermaster
|
|
293
|
-
|
|
294
|
-
if (config?.enabled !== true) return null;
|
|
295
|
-
|
|
296
|
-
if (!this.agents.quartermaster) {
|
|
297
|
-
this.agents.quartermaster = new Quartermaster(this.provider, {
|
|
298
|
-
model: config?.model,
|
|
299
|
-
});
|
|
300
|
-
this.agents.quartermaster.start(this.explorer, this.stateManager());
|
|
301
|
-
}
|
|
302
|
-
return this.agents.quartermaster;
|
|
296
|
+
agentQuartermaster(): Quartermaster {
|
|
297
|
+
return (this.agents.quartermaster ||= new Quartermaster(this.provider, this.explorer, { model: this.config.ai?.agents?.quartermaster?.model }));
|
|
303
298
|
}
|
|
304
299
|
|
|
305
300
|
agentHistorian(): Historian {
|
|
@@ -571,6 +566,7 @@ export class ExplorBot {
|
|
|
571
566
|
private async bootstrapOptionalProvider(): Promise<void> {
|
|
572
567
|
try {
|
|
573
568
|
const provider = new AIProvider(this.config.ai);
|
|
569
|
+
provider.setJudge(this.judge());
|
|
574
570
|
await provider.validateConnection();
|
|
575
571
|
this.provider = provider;
|
|
576
572
|
} catch (error) {
|
package/src/explorer.ts
CHANGED
|
@@ -104,6 +104,7 @@ class Explorer {
|
|
|
104
104
|
throw new Error('Playwright helper not available');
|
|
105
105
|
}
|
|
106
106
|
this.applyBasePathToHelper();
|
|
107
|
+
codeceptjs.store.visibleLocator = this.playwrightHelper.options?.visibleLocator;
|
|
107
108
|
await this.connectOrLaunchBrowser();
|
|
108
109
|
const hasSession = !this.options?.attachedBrowser && this.options?.session && existsSync(this.options.session);
|
|
109
110
|
await this.openContextPage();
|
package/src/global-config.ts
CHANGED
|
@@ -6,6 +6,7 @@ import dedent from 'dedent';
|
|
|
6
6
|
|
|
7
7
|
const GLOBAL_CONFIG_NAMES = ['config.js', 'config.mjs', 'config.ts'];
|
|
8
8
|
const SITE_DIRS = ['knowledge', 'experience', 'output'];
|
|
9
|
+
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
|
|
9
10
|
|
|
10
11
|
export const EXPLORBOT_CONFIG_PATHS = ['explorbot.config.js', 'explorbot.config.mjs', 'explorbot.config.ts'];
|
|
11
12
|
|
|
@@ -34,7 +35,9 @@ export function sitesDir(): string {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export function siteFolderName(url: string): string {
|
|
37
|
-
|
|
38
|
+
const parsed = new URL(url);
|
|
39
|
+
if (LOOPBACK_HOSTS.includes(parsed.hostname)) parsed.hostname = 'localhost';
|
|
40
|
+
return parsed.host.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export function listSites(): SiteRecord[] {
|
|
@@ -81,7 +84,7 @@ function validateSiteConfig(config: any, configPath: string, baseUrl: string): v
|
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
const declared = URL.parse(url)?.origin;
|
|
84
|
-
if (declared === baseUrl) return;
|
|
87
|
+
if (declared && siteFolderName(declared) === siteFolderName(baseUrl)) return;
|
|
85
88
|
|
|
86
89
|
throw new Error(dedent`
|
|
87
90
|
Site config declares a different site.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { HTML_SELECTORS, HTML_VISIBILITY_LIMITS } from './html.ts';
|
|
2
|
+
|
|
3
|
+
export const UX_FOCUS_ATTR = 'data-explorbot-ux';
|
|
4
|
+
|
|
5
|
+
const UX_LIMITS = {
|
|
6
|
+
touchTarget: 44,
|
|
7
|
+
pointerTarget: 24,
|
|
8
|
+
touchWidth: 1024,
|
|
9
|
+
targetGap: 8,
|
|
10
|
+
minFontSize: 12,
|
|
11
|
+
minLineHeight: 1.3,
|
|
12
|
+
maxEntries: 8,
|
|
13
|
+
minOpacity: HTML_VISIBILITY_LIMITS.minOpacity,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function uxProbeArgs(scope: string): UxProbeArgs {
|
|
17
|
+
return { scope, controlsSelector: HTML_SELECTORS.interactiveControl, limits: UX_LIMITS };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderUxMeasurements(measurements: UxMeasurements): string {
|
|
21
|
+
const lines = [`viewport: ${measurements.width}px wide`];
|
|
22
|
+
for (const [check, entries] of Object.entries(measurements.checks)) {
|
|
23
|
+
if (!entries.length) continue;
|
|
24
|
+
lines.push(`${check}:`, ...entries.map((entry) => ` - ${entry}`));
|
|
25
|
+
}
|
|
26
|
+
if (lines.length === 1) lines.push('no measurable problems');
|
|
27
|
+
return lines.join('\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Passed to page.evaluate as a function — must stay self-contained with no outer-scope references.
|
|
31
|
+
export function measureUx({ scope, controlsSelector, limits }: UxProbeArgs): UxMeasurements {
|
|
32
|
+
const root = document.querySelector(scope) || document.body;
|
|
33
|
+
const width = window.innerWidth;
|
|
34
|
+
const touch = width <= limits.touchWidth;
|
|
35
|
+
const visibleWidth = document.documentElement.clientWidth;
|
|
36
|
+
const checks: Record<string, string[]> = {
|
|
37
|
+
'horizontal overflow': [],
|
|
38
|
+
'zoom and viewport meta': [],
|
|
39
|
+
'small targets': [],
|
|
40
|
+
'crowded targets': [],
|
|
41
|
+
'placeholder-only labels': [],
|
|
42
|
+
'small text': [],
|
|
43
|
+
'cramped line-height': [],
|
|
44
|
+
'truncated text without full text': [],
|
|
45
|
+
'layout-property transitions': [],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const add = (check: string, entry: string) => {
|
|
49
|
+
if (checks[check].length >= limits.maxEntries) return;
|
|
50
|
+
if (checks[check].includes(entry)) return;
|
|
51
|
+
checks[check].push(entry);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const describe = (el: Element) => {
|
|
55
|
+
const role = el.getAttribute('role') || el.tagName.toLowerCase();
|
|
56
|
+
const name = (el.getAttribute('aria-label') || (el as HTMLElement).innerText || el.getAttribute('placeholder') || el.getAttribute('title') || '').trim().replace(/\s+/g, ' ');
|
|
57
|
+
if (!name) return `${role} (unnamed)`;
|
|
58
|
+
return `${role} "${name.slice(0, 40)}"`;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const visible = (el: Element) => {
|
|
62
|
+
const rect = el.getBoundingClientRect();
|
|
63
|
+
if (!rect.width || !rect.height) return false;
|
|
64
|
+
const style = window.getComputedStyle(el);
|
|
65
|
+
return style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > limits.minOpacity;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (root === document.body || root.matches('main, [role="main"]')) {
|
|
69
|
+
const docWidth = document.documentElement.scrollWidth;
|
|
70
|
+
if (docWidth > visibleWidth + 1) add('horizontal overflow', `page is ${docWidth}px wide in a ${visibleWidth}px visible area`);
|
|
71
|
+
|
|
72
|
+
const meta = document.querySelector('meta[name="viewport"]')?.getAttribute('content');
|
|
73
|
+
if (!meta) add('zoom and viewport meta', 'no viewport meta tag');
|
|
74
|
+
const metaRules = (meta || '').toLowerCase().replace(/\s/g, '');
|
|
75
|
+
if (metaRules.includes('user-scalable=no') || metaRules.includes('user-scalable=0')) add('zoom and viewport meta', 'zoom disabled by user-scalable');
|
|
76
|
+
const maxScale = metaRules.split(',').find((part) => part.startsWith('maximum-scale='));
|
|
77
|
+
if (maxScale && Number(maxScale.split('=')[1]) <= 1) add('zoom and viewport meta', `zoom capped by ${maxScale}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const minTarget = touch ? limits.touchTarget : limits.pointerTarget;
|
|
81
|
+
const controls = Array.from(root.querySelectorAll(controlsSelector)).filter(visible);
|
|
82
|
+
const boxes = controls.map((el) => ({ el, rect: el.getBoundingClientRect() }));
|
|
83
|
+
for (const { el, rect } of boxes) {
|
|
84
|
+
if (el.tagName === 'A' && window.getComputedStyle(el).display === 'inline') continue;
|
|
85
|
+
if (rect.width >= minTarget && rect.height >= minTarget) continue;
|
|
86
|
+
add('small targets', `${describe(el)} is ${Math.round(rect.width)}×${Math.round(rect.height)}px, below ${minTarget}px`);
|
|
87
|
+
}
|
|
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)) continue;
|
|
95
|
+
const gapX = Math.max(b.rect.left - a.rect.right, a.rect.left - b.rect.right);
|
|
96
|
+
const gapY = Math.max(b.rect.top - a.rect.bottom, a.rect.top - b.rect.bottom);
|
|
97
|
+
const gap = Math.max(gapX, gapY);
|
|
98
|
+
if (gap >= limits.targetGap) continue;
|
|
99
|
+
if (a.rect.height >= limits.touchTarget && b.rect.height >= limits.touchTarget && a.rect.width >= limits.touchTarget && b.rect.width >= limits.touchTarget) continue;
|
|
100
|
+
add('crowded targets', `${describe(a.el)} and ${describe(b.el)} are ${Math.max(0, Math.round(gap))}px apart`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const el of Array.from(root.querySelectorAll('input[placeholder], textarea[placeholder]'))) {
|
|
106
|
+
if (!visible(el)) continue;
|
|
107
|
+
if (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title')) continue;
|
|
108
|
+
if ((el as HTMLInputElement).labels?.length) continue;
|
|
109
|
+
add('placeholder-only labels', describe(el));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
|
113
|
+
if (!visible(el)) continue;
|
|
114
|
+
const rect = el.getBoundingClientRect();
|
|
115
|
+
const style = window.getComputedStyle(el);
|
|
116
|
+
|
|
117
|
+
const overflows = rect.right > visibleWidth + 1 && rect.left < visibleWidth;
|
|
118
|
+
if (overflows && !(el.parentElement && el.parentElement.getBoundingClientRect().right > visibleWidth + 1)) add('horizontal overflow', `${describe(el)} ends at ${Math.round(rect.right)}px`);
|
|
119
|
+
|
|
120
|
+
const animated = style.transitionProperty.split(',').map((property) => property.trim());
|
|
121
|
+
const layout = animated.filter((property) => ['width', 'height', 'top', 'left', 'right', 'bottom', 'margin', 'padding'].includes(property));
|
|
122
|
+
const transitions = style.transitionDuration.split(',').some((duration) => Number.parseFloat(duration) > 0);
|
|
123
|
+
if (transitions && layout.length) add('layout-property transitions', `${describe(el)} transitions ${layout.join(', ')}`);
|
|
124
|
+
|
|
125
|
+
const ownText = Array.from(el.childNodes)
|
|
126
|
+
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
|
127
|
+
.map((node) => node.textContent || '')
|
|
128
|
+
.join('')
|
|
129
|
+
.trim();
|
|
130
|
+
if (!ownText) continue;
|
|
131
|
+
|
|
132
|
+
const fontSize = Number.parseFloat(style.fontSize);
|
|
133
|
+
if (fontSize < limits.minFontSize) add('small text', `"${ownText.slice(0, 40)}" at ${fontSize}px`);
|
|
134
|
+
|
|
135
|
+
const lineHeight = Number.parseFloat(style.lineHeight);
|
|
136
|
+
const multiline = rect.height > fontSize * 2.5;
|
|
137
|
+
if (multiline && lineHeight && lineHeight / fontSize < limits.minLineHeight) add('cramped line-height', `"${ownText.slice(0, 40)}" at ${(lineHeight / fontSize).toFixed(2)}`);
|
|
138
|
+
|
|
139
|
+
const htmlEl = el as HTMLElement;
|
|
140
|
+
if (style.textOverflow === 'ellipsis' && htmlEl.scrollWidth > htmlEl.clientWidth && !el.getAttribute('title')) add('truncated text without full text', `"${ownText.slice(0, 40)}"`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { width, checks };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function countRunningAnimations(scope: string): number {
|
|
147
|
+
const root = document.querySelector(scope) || document.body;
|
|
148
|
+
return document.getAnimations().filter((animation) => {
|
|
149
|
+
const target = (animation.effect as KeyframeEffect | null)?.target;
|
|
150
|
+
return animation.playState === 'running' && target && root.contains(target);
|
|
151
|
+
}).length;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface UxProbeArgs {
|
|
155
|
+
scope: string;
|
|
156
|
+
controlsSelector: string;
|
|
157
|
+
limits: typeof UX_LIMITS;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface UxMeasurements {
|
|
161
|
+
width: number;
|
|
162
|
+
checks: Record<string, string[]>;
|
|
163
|
+
}
|