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
package/dist/src/ai/planner.js
CHANGED
|
@@ -18,6 +18,7 @@ import { WithSubPages, getPlannedByStateHash, getRegisteredPlan, registerPlan }
|
|
|
18
18
|
import { POSSIBLE_SECTIONS } from "./researcher.js";
|
|
19
19
|
import { findSimilarStateHash } from "./researcher/cache.js";
|
|
20
20
|
import { hasFocusedSection } from "./researcher/focus.js";
|
|
21
|
+
import { RulesLoader } from "../utils/rules-loader.js";
|
|
21
22
|
import { capabilityGroundingRule, dataProtectionRules, fileUploadRule } from "./rules.js";
|
|
22
23
|
const debugLog = createDebug('explorbot:planner');
|
|
23
24
|
const TasksSchema = z.object({
|
|
@@ -33,6 +34,7 @@ const TasksSchema = z.object({
|
|
|
33
34
|
.describe('List of expected outcomes that can be verified. Each outcome should be simple, specific, and easy to check (e.g., "Success message appears", "URL changes to /dashboard", "Form field shows error"). Keep outcomes atomic - do not combine multiple checks into one.'),
|
|
34
35
|
}))
|
|
35
36
|
.describe('List of testing scenarios'),
|
|
37
|
+
notPlannableReason: z.string().nullish().describe('When scenarios is empty, one sentence saying why this page has nothing to test; otherwise null.'),
|
|
36
38
|
});
|
|
37
39
|
const PlannerBase = WithSessionDedup(WithSubPages(Object));
|
|
38
40
|
export class Planner extends PlannerBase {
|
|
@@ -178,6 +180,9 @@ export class Planner extends PlannerBase {
|
|
|
178
180
|
if (!aiResult?.object?.scenarios) {
|
|
179
181
|
throw new Error('No tasks were created successfully');
|
|
180
182
|
}
|
|
183
|
+
if (!this.currentPlan && aiResult.object.scenarios.length === 0 && aiResult.object.notPlannableReason) {
|
|
184
|
+
tag('warning').log(`Page is not plannable: ${aiResult.object.notPlannableReason}`);
|
|
185
|
+
}
|
|
181
186
|
const defaultStartUrl = this.getDefaultStartUrl(state);
|
|
182
187
|
const fromPlanning = aiResult.object.scenarios.map((s) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || defaultStartUrl, s.steps || []));
|
|
183
188
|
return { tests: fromPlanning, planName: aiResult.object.planName };
|
|
@@ -303,11 +308,14 @@ export class Planner extends PlannerBase {
|
|
|
303
308
|
conversation.autoTrimTag('tested_scenarios', 10000);
|
|
304
309
|
conversation.autoTrimTag('docs_context', 8000);
|
|
305
310
|
conversation.addUserText(this.getSystemMessage(feature));
|
|
311
|
+
let ignoredPagesRule = '';
|
|
312
|
+
if (!feature)
|
|
313
|
+
ignoredPagesRule = `\n${RulesLoader.loadRules('planner', ['ignored-pages'], state.url)}`;
|
|
306
314
|
const planningPrompt = dedent `
|
|
307
315
|
<task>
|
|
308
316
|
Based on the page research, create ${this.MIN_TASKS}-${this.MAX_TASKS} exploratory testing scenarios.
|
|
309
317
|
For each scenario provide specific steps and expected outcomes.
|
|
310
|
-
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
|
|
318
|
+
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}
|
|
311
319
|
</task>
|
|
312
320
|
|
|
313
321
|
<rules>
|
|
@@ -3,6 +3,7 @@ import type { ModelMessage } from 'ai';
|
|
|
3
3
|
import { type AIConfig } from '../config.js';
|
|
4
4
|
import { type RetryOptions } from '../utils/retry.js';
|
|
5
5
|
import { Conversation } from './conversation.js';
|
|
6
|
+
import type { Judge } from './judge.js';
|
|
6
7
|
declare class AiError extends Error {
|
|
7
8
|
}
|
|
8
9
|
export declare class ContextLengthError extends Error {
|
|
@@ -10,6 +11,7 @@ export declare class ContextLengthError extends Error {
|
|
|
10
11
|
export declare function flushTelemetry(): Promise<void>;
|
|
11
12
|
export declare class Provider {
|
|
12
13
|
config: AIConfig;
|
|
14
|
+
judge: Judge | null;
|
|
13
15
|
telemetryEnabled: boolean;
|
|
14
16
|
otelSdk: NodeSDK | null;
|
|
15
17
|
defaultRetryOptions: RetryOptions;
|
|
@@ -20,6 +22,8 @@ export declare class Provider {
|
|
|
20
22
|
validateConnection(): Promise<void>;
|
|
21
23
|
stop(): Promise<void>;
|
|
22
24
|
getModelForAgent(agentName?: string): any;
|
|
25
|
+
setJudge(judge: Judge | null): void;
|
|
26
|
+
oracle(statement: string, state: unknown, options?: any): Promise<boolean>;
|
|
23
27
|
getAgenticModel(agentName?: string): any;
|
|
24
28
|
getVisionModel(): any;
|
|
25
29
|
getConfiguredModels(): Record<string, string>;
|
package/dist/src/ai/provider.js
CHANGED
|
@@ -2,11 +2,11 @@ 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 dedent from 'dedent';
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { clearActivity, setActivity } from "../activity.js";
|
|
9
|
-
import { configuredModels, modelName as getModelName } from '../config.js';
|
|
9
|
+
import { PROVIDERS, configuredModels, modelName as getModelName, modelProvider } from '../config.js';
|
|
10
10
|
import { executionController } from "../execution-controller.js";
|
|
11
11
|
import { Observability } from "../observability.js";
|
|
12
12
|
import { Stats } from "../stats.js";
|
|
@@ -75,6 +75,7 @@ function combinedAbortSignal(controller) {
|
|
|
75
75
|
}
|
|
76
76
|
export class Provider {
|
|
77
77
|
config;
|
|
78
|
+
judge = null;
|
|
78
79
|
telemetryEnabled = false;
|
|
79
80
|
otelSdk = null;
|
|
80
81
|
defaultRetryOptions = {
|
|
@@ -89,6 +90,7 @@ export class Provider {
|
|
|
89
90
|
error.message.includes('AI request timeout') ||
|
|
90
91
|
error.message.includes('schema') ||
|
|
91
92
|
error.message.includes('No object generated') ||
|
|
93
|
+
error.message.includes('No output generated') ||
|
|
92
94
|
error.message.includes('No response text') ||
|
|
93
95
|
error.message.includes('validate JSON')) &&
|
|
94
96
|
!error.message.includes('output truncated at maxTokens'));
|
|
@@ -112,6 +114,13 @@ export class Provider {
|
|
|
112
114
|
});
|
|
113
115
|
}
|
|
114
116
|
catch (error) {
|
|
117
|
+
const envKey = PROVIDERS[modelProvider(this.config.model)]?.envKey;
|
|
118
|
+
const apiKey = envKey && process.env[envKey];
|
|
119
|
+
if (envKey && !apiKey)
|
|
120
|
+
throw new AiError(`AI connection failed: ${envKey} is not set. ${error.message}`);
|
|
121
|
+
if (apiKey && [401, 403].includes(error.statusCode)) {
|
|
122
|
+
throw new AiError(`AI connection failed: ${error.message}. ${envKey}=${apiKey.slice(0, 5)}**${apiKey.slice(-3)} was rejected, check the key`);
|
|
123
|
+
}
|
|
115
124
|
throw new AiError(`AI connection failed: ${error.message}`);
|
|
116
125
|
}
|
|
117
126
|
}
|
|
@@ -125,6 +134,18 @@ export class Provider {
|
|
|
125
134
|
const agentConfig = this.config.agents?.[agentName];
|
|
126
135
|
return agentConfig?.model || this.config.model;
|
|
127
136
|
}
|
|
137
|
+
setJudge(judge) {
|
|
138
|
+
this.judge = judge;
|
|
139
|
+
}
|
|
140
|
+
async oracle(statement, state, options = {}) {
|
|
141
|
+
const judged = await this.judge?.assert(statement, state);
|
|
142
|
+
if (typeof judged === 'boolean')
|
|
143
|
+
return judged;
|
|
144
|
+
const schema = z.object({ holds: z.boolean().describe('whether the statement is true') });
|
|
145
|
+
const content = `${statement}\n\n${JSON.stringify(state, null, 2)}`;
|
|
146
|
+
const response = await this.generateObject([{ role: 'user', content }], schema, this.getAgenticModel(options.agentName), options).catch(() => null);
|
|
147
|
+
return response?.object?.holds === true;
|
|
148
|
+
}
|
|
128
149
|
getAgenticModel(agentName) {
|
|
129
150
|
if (agentName) {
|
|
130
151
|
const agentConfig = this.config.agents?.[agentName];
|
|
@@ -482,11 +503,12 @@ export class Provider {
|
|
|
482
503
|
const modelName = getModelName(modelToUse);
|
|
483
504
|
setActivity(`🤖 Asking ${modelName} for structured output`, 'ai');
|
|
484
505
|
promptLog(`Using model: ${modelName}`);
|
|
485
|
-
const config = this.buildGenerateConfig({ schema }, { model: modelToUse }, options);
|
|
486
506
|
try {
|
|
507
|
+
const { timeout, ...config } = this.buildGenerateConfig({ output: Output.object({ schema }) }, { model: modelToUse }, options);
|
|
487
508
|
promptLog(messages[messages.length - 1].content);
|
|
488
509
|
const response = await this.withModelRequestSlot(() => withRetry(async () => {
|
|
489
|
-
|
|
510
|
+
const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), timeout || 30000));
|
|
511
|
+
return { object: result.output, usage: result.usage };
|
|
490
512
|
}, this.getRetryOptions(options)));
|
|
491
513
|
clearActivity();
|
|
492
514
|
responseLog(response.object);
|
|
@@ -502,7 +524,7 @@ export class Provider {
|
|
|
502
524
|
if (Provider.isContextLengthError(error)) {
|
|
503
525
|
return this.recoverFromContextLength(error, messages, options, (m, o) => this.generateObject(m, schema, model, o));
|
|
504
526
|
}
|
|
505
|
-
if (NoObjectGeneratedError.isInstance(error))
|
|
527
|
+
if (NoObjectGeneratedError.isInstance(error) || NoOutputGeneratedError.isInstance(error))
|
|
506
528
|
return this.recoverWithPlainJson(messages, schema, modelToUse, options);
|
|
507
529
|
throw new AiError(error.message || error.toString());
|
|
508
530
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import type { Locator, Page } from 'playwright';
|
|
1
2
|
import type { ActionResult } from '../action-result.js';
|
|
2
3
|
import type Explorer from '../explorer.js';
|
|
3
4
|
import type { StateManager, StateTransition, WebPageState } from '../state-manager.js';
|
|
4
5
|
import type { Task } from '../test-plan.js';
|
|
6
|
+
import { type UxMeasurements } from '../utils/ux-probe.js';
|
|
5
7
|
import type { Conversation, ToolExecution } from './conversation.js';
|
|
6
8
|
import type { Provider } from './provider.js';
|
|
7
9
|
export declare class Quartermaster {
|
|
@@ -10,20 +12,46 @@ export declare class Quartermaster {
|
|
|
10
12
|
outputDir: string;
|
|
11
13
|
pageAnalyses: Map<string, PageAnalysis>;
|
|
12
14
|
unsubscribe: (() => void) | null;
|
|
13
|
-
explorer: Explorer
|
|
15
|
+
explorer: Explorer;
|
|
14
16
|
pendingAnalyses: Promise<void>[];
|
|
15
|
-
constructor(provider: Provider, options?: {
|
|
17
|
+
constructor(provider: Provider, explorer: Explorer, options?: {
|
|
16
18
|
model?: any;
|
|
17
19
|
});
|
|
18
|
-
start(
|
|
20
|
+
start(stateManager: StateManager): void;
|
|
19
21
|
stop(): void;
|
|
20
22
|
onPageChange(event: StateTransition): void;
|
|
21
23
|
runAxeAnalysis(state: WebPageState): Promise<void>;
|
|
22
24
|
analyzeSession(task: Task, initialState: ActionResult, conversation: Conversation): Promise<AnalysisReport | null>;
|
|
25
|
+
auditUx(result: ActionResult, context: UxAuditContext): Promise<string>;
|
|
23
26
|
generateSemanticAnalysis(axeViolations: AxeViolation[], executions: ToolExecution[], initialState: ActionResult): Promise<SemanticIssue[]>;
|
|
24
27
|
addNotesToTask(task: Task, report: AnalysisReport): void;
|
|
25
28
|
saveReport(stateHash: string, report: AnalysisReport): void;
|
|
26
29
|
formatReportMarkdown(report: AnalysisReport): string;
|
|
30
|
+
axeViolations(page: Page, include?: string): Promise<AxeViolation[]>;
|
|
31
|
+
markUxFocus(result: ActionResult, context: UxAuditContext): Promise<UxFocus>;
|
|
32
|
+
pickUxFocus(context: UxAuditContext, mark: (locator: Locator) => Promise<boolean>): Promise<UxFocus | null>;
|
|
33
|
+
collectUxEvidence(page: Page): Promise<UxEvidence>;
|
|
34
|
+
animationsIgnoringReducedMotion(page: Page): Promise<number>;
|
|
35
|
+
captureUxBreakpoint(page: Page): Promise<UxBreakpoint>;
|
|
36
|
+
writeUxReport(result: ActionResult, focus: UxFocus, evidence: UxEvidence, context: UxAuditContext): Promise<string>;
|
|
37
|
+
}
|
|
38
|
+
export interface UxAuditContext {
|
|
39
|
+
focus?: string;
|
|
40
|
+
commands: string[];
|
|
41
|
+
}
|
|
42
|
+
interface UxFocus {
|
|
43
|
+
area: string;
|
|
44
|
+
reason: string;
|
|
45
|
+
}
|
|
46
|
+
interface UxBreakpoint {
|
|
47
|
+
measurements: UxMeasurements;
|
|
48
|
+
aria: string;
|
|
49
|
+
screenshot?: string;
|
|
50
|
+
}
|
|
51
|
+
interface UxEvidence {
|
|
52
|
+
breakpoints: UxBreakpoint[];
|
|
53
|
+
axe: AxeViolation[];
|
|
54
|
+
notes: string[];
|
|
27
55
|
}
|
|
28
56
|
interface AxeViolation {
|
|
29
57
|
id: string;
|
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import dedent from 'dedent';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
import { outputPath } from "../config.js";
|
|
6
|
+
import { ariaRefSelector, ariaRefSnapshot, parseAriaRefs } from "../utils/aria-ref.js";
|
|
7
|
+
import { compactAriaSnapshot } from "../utils/aria.js";
|
|
5
8
|
import { createDebug, tag } from "../utils/logger.js";
|
|
9
|
+
import { RulesLoader } from "../utils/rules-loader.js";
|
|
6
10
|
import { isCodeceptToolName } from "../utils/step-analyzer.js";
|
|
11
|
+
import { UX_FOCUS_ATTR, countRunningAnimations, measureUx, renderUxMeasurements, uxProbeArgs } from "../utils/ux-probe.js";
|
|
7
12
|
const debugLog = createDebug('explorbot:quartermaster');
|
|
13
|
+
const UX_RULES = ['ux/accessibility', 'ux/interaction', 'ux/responsive', 'ux/navigation', 'ux/copy', 'ux/forms', 'ux/readability', 'ux/motion', 'ux/charts'];
|
|
14
|
+
const UX_BREAKPOINTS = [768, 375];
|
|
15
|
+
const UX_FOCUS_SELECTOR = `[${UX_FOCUS_ATTR}]`;
|
|
16
|
+
const UX_SETTLE_MS = 400;
|
|
17
|
+
const UX_ARIA_CAP = 8000;
|
|
18
|
+
const UX_SCREENSHOT_TIMEOUT = 5000;
|
|
19
|
+
const UX_MAX_FINDINGS = 6;
|
|
20
|
+
const AGENT_NAME = 'quartermaster';
|
|
21
|
+
const FOCUS_CONTAINER_ROLES = ['main', 'region', 'form', 'dialog', 'alertdialog', 'navigation', 'complementary', 'article', 'table', 'grid', 'tabpanel', 'list', 'group', 'banner', 'contentinfo', 'search'];
|
|
8
22
|
export class Quartermaster {
|
|
9
23
|
provider;
|
|
10
24
|
model;
|
|
11
25
|
outputDir;
|
|
12
26
|
pageAnalyses = new Map();
|
|
13
27
|
unsubscribe = null;
|
|
14
|
-
explorer
|
|
28
|
+
explorer;
|
|
15
29
|
pendingAnalyses = [];
|
|
16
|
-
constructor(provider, options) {
|
|
30
|
+
constructor(provider, explorer, options) {
|
|
17
31
|
this.provider = provider;
|
|
32
|
+
this.explorer = explorer;
|
|
18
33
|
this.model = options?.model;
|
|
19
34
|
this.outputDir = outputPath('a11y');
|
|
20
35
|
}
|
|
21
|
-
start(
|
|
22
|
-
this.
|
|
36
|
+
start(stateManager) {
|
|
37
|
+
if (this.unsubscribe)
|
|
38
|
+
return;
|
|
23
39
|
mkdirSync(this.outputDir, { recursive: true });
|
|
24
40
|
this.unsubscribe = stateManager.onStateChange((event) => {
|
|
25
41
|
this.onPageChange(event);
|
|
@@ -45,24 +61,13 @@ export class Quartermaster {
|
|
|
45
61
|
this.pendingAnalyses.push(analysisPromise);
|
|
46
62
|
}
|
|
47
63
|
async runAxeAnalysis(state) {
|
|
48
|
-
const page = this.explorer
|
|
64
|
+
const page = this.explorer.page;
|
|
49
65
|
if (!page || !state.hash) {
|
|
50
66
|
debugLog('No page available for axe analysis');
|
|
51
67
|
return;
|
|
52
68
|
}
|
|
53
69
|
try {
|
|
54
|
-
const
|
|
55
|
-
const results = await new AxeBuilder({ page }).analyze();
|
|
56
|
-
const violations = results.violations.map((v) => ({
|
|
57
|
-
id: v.id,
|
|
58
|
-
impact: v.impact,
|
|
59
|
-
description: v.description,
|
|
60
|
-
helpUrl: v.helpUrl,
|
|
61
|
-
nodes: v.nodes.map((n) => ({
|
|
62
|
-
html: n.html,
|
|
63
|
-
target: n.target,
|
|
64
|
-
})),
|
|
65
|
-
}));
|
|
70
|
+
const violations = await this.axeViolations(page);
|
|
66
71
|
this.pageAnalyses.set(state.hash, {
|
|
67
72
|
url: state.url,
|
|
68
73
|
stateHash: state.hash,
|
|
@@ -110,6 +115,22 @@ export class Quartermaster {
|
|
|
110
115
|
return null;
|
|
111
116
|
}
|
|
112
117
|
}
|
|
118
|
+
async auditUx(result, context) {
|
|
119
|
+
const focus = await this.markUxFocus(result, context);
|
|
120
|
+
tag('step').log(`Auditing UX of ${focus.area}`);
|
|
121
|
+
try {
|
|
122
|
+
const evidence = await this.explorer.withPage((page) => this.collectUxEvidence(page));
|
|
123
|
+
return await this.writeUxReport(result, focus, evidence, context);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await this.explorer
|
|
127
|
+
.withPage((page) => page.evaluate((attr) => {
|
|
128
|
+
for (const el of Array.from(document.querySelectorAll(`[${attr}]`)))
|
|
129
|
+
el.removeAttribute(attr);
|
|
130
|
+
}, UX_FOCUS_ATTR))
|
|
131
|
+
.catch(() => { });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
113
134
|
async generateSemanticAnalysis(axeViolations, executions, initialState) {
|
|
114
135
|
const failedExecs = executions.filter((e) => !e.wasSuccessful);
|
|
115
136
|
if (failedExecs.length === 0 && axeViolations.length === 0)
|
|
@@ -200,4 +221,228 @@ Focus on what would confuse a real user or caused the agent to make mistakes.`;
|
|
|
200
221
|
}
|
|
201
222
|
return content;
|
|
202
223
|
}
|
|
224
|
+
async axeViolations(page, include) {
|
|
225
|
+
const { AxeBuilder } = await import('@axe-core/playwright');
|
|
226
|
+
let builder = new AxeBuilder({ page });
|
|
227
|
+
if (include)
|
|
228
|
+
builder = builder.include(include);
|
|
229
|
+
const results = await builder.analyze();
|
|
230
|
+
return results.violations.map((v) => ({
|
|
231
|
+
id: v.id,
|
|
232
|
+
impact: v.impact,
|
|
233
|
+
description: v.description,
|
|
234
|
+
helpUrl: v.helpUrl,
|
|
235
|
+
nodes: v.nodes.map((n) => ({
|
|
236
|
+
html: n.html,
|
|
237
|
+
target: n.target,
|
|
238
|
+
})),
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
async markUxFocus(result, context) {
|
|
242
|
+
const mark = (locator) => locator
|
|
243
|
+
.first()
|
|
244
|
+
.evaluate((el, attr) => el.setAttribute(attr, ''), UX_FOCUS_ATTR)
|
|
245
|
+
.then(() => true)
|
|
246
|
+
.catch(() => false);
|
|
247
|
+
const region = result.overlay;
|
|
248
|
+
const locateRegion = (page) => {
|
|
249
|
+
if (region.root)
|
|
250
|
+
return page.locator(region.root);
|
|
251
|
+
if (region.xpath)
|
|
252
|
+
return page.locator(`xpath=${region.xpath}`);
|
|
253
|
+
const name = region.name || undefined;
|
|
254
|
+
return page.getByRole('dialog', { name }).or(page.getByRole('alertdialog', { name }));
|
|
255
|
+
};
|
|
256
|
+
const regionFocus = { area: region.name || `open ${region.type}`, reason: `it is the ${region.type} open on the page` };
|
|
257
|
+
if (!context.focus && region.isOpen && (await this.explorer.withPage((page) => mark(locateRegion(page)))))
|
|
258
|
+
return regionFocus;
|
|
259
|
+
if (context.focus || context.commands.length) {
|
|
260
|
+
const picked = await this.pickUxFocus(context, mark);
|
|
261
|
+
if (picked)
|
|
262
|
+
return picked;
|
|
263
|
+
}
|
|
264
|
+
if (region.isOpen && (await this.explorer.withPage((page) => mark(locateRegion(page)))))
|
|
265
|
+
return regionFocus;
|
|
266
|
+
let reason = 'nothing narrower is open or was touched by the session on this page';
|
|
267
|
+
if (context.focus)
|
|
268
|
+
reason = `"${context.focus}" matched no single container`;
|
|
269
|
+
return this.explorer.withPage(async (page) => {
|
|
270
|
+
if (await mark(page.locator('main, [role="main"]')))
|
|
271
|
+
return { area: 'main content', reason };
|
|
272
|
+
await mark(page.locator('body'));
|
|
273
|
+
return { area: 'whole page', reason: `${reason} and the page has no main landmark` };
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
async pickUxFocus(context, mark) {
|
|
277
|
+
const snapshot = await this.explorer.withPage(ariaRefSnapshot).catch(() => '');
|
|
278
|
+
const candidates = parseAriaRefs(snapshot).filter((entry) => FOCUS_CONTAINER_ROLES.includes(entry.role));
|
|
279
|
+
if (!candidates.length)
|
|
280
|
+
return null;
|
|
281
|
+
const hints = [];
|
|
282
|
+
if (context.focus)
|
|
283
|
+
hints.push(`The developer asked to review: ${context.focus}`);
|
|
284
|
+
if (context.commands.length)
|
|
285
|
+
hints.push('Commands the session ran on this page, latest last:', ...context.commands.map((command) => `- ${command}`));
|
|
286
|
+
const schema = z.object({
|
|
287
|
+
ref: z.enum(['none', ...candidates.map((entry) => entry.ref)]).describe('ref of the chosen container, or none'),
|
|
288
|
+
area: z.string().describe('the area named as a user would call it, a few words'),
|
|
289
|
+
});
|
|
290
|
+
const prompt = dedent `
|
|
291
|
+
Pick the page area a developer is working on, so a UX review covers it and nothing else.
|
|
292
|
+
Choose the smallest container that holds everything the hints point at.
|
|
293
|
+
Answer none when no listed container fits the hints.
|
|
294
|
+
|
|
295
|
+
<hints>
|
|
296
|
+
${hints.join('\n')}
|
|
297
|
+
</hints>
|
|
298
|
+
|
|
299
|
+
<containers>
|
|
300
|
+
${candidates.map((entry) => `- [${entry.ref}] ${entry.role} "${entry.name}"`).join('\n')}
|
|
301
|
+
</containers>
|
|
302
|
+
|
|
303
|
+
<page_aria>
|
|
304
|
+
${snapshot.slice(0, UX_ARIA_CAP)}
|
|
305
|
+
</page_aria>
|
|
306
|
+
`;
|
|
307
|
+
const response = await this.provider.generateObject([{ role: 'user', content: prompt }], schema, this.model, { agentName: AGENT_NAME }).catch(() => null);
|
|
308
|
+
const ref = response?.object?.ref;
|
|
309
|
+
if (!ref || ref === 'none')
|
|
310
|
+
return null;
|
|
311
|
+
if (!(await this.explorer.withPage((page) => mark(page.locator(ariaRefSelector(ref))))))
|
|
312
|
+
return null;
|
|
313
|
+
let reason = 'the session commands on this page act inside it';
|
|
314
|
+
if (context.focus)
|
|
315
|
+
reason = `it holds "${context.focus}"`;
|
|
316
|
+
return { area: response.object.area, reason };
|
|
317
|
+
}
|
|
318
|
+
async collectUxEvidence(page) {
|
|
319
|
+
const evidence = { breakpoints: [], axe: [], notes: [] };
|
|
320
|
+
evidence.axe = await this.axeViolations(page, UX_FOCUS_SELECTOR).catch((error) => {
|
|
321
|
+
evidence.notes.push(`axe could not run: ${error.message}`);
|
|
322
|
+
return [];
|
|
323
|
+
});
|
|
324
|
+
const ignoredMotion = await this.animationsIgnoringReducedMotion(page);
|
|
325
|
+
if (ignoredMotion)
|
|
326
|
+
evidence.notes.push(`${ignoredMotion} animations in the area keep running when reduced motion is requested`);
|
|
327
|
+
const width = await page.evaluate(() => window.innerWidth);
|
|
328
|
+
evidence.breakpoints.push(await this.captureUxBreakpoint(page));
|
|
329
|
+
const narrower = UX_BREAKPOINTS.filter((breakpoint) => breakpoint < width);
|
|
330
|
+
if (!narrower.length)
|
|
331
|
+
return evidence;
|
|
332
|
+
const viewport = page.viewportSize();
|
|
333
|
+
if (!viewport) {
|
|
334
|
+
evidence.notes.push('the browser window has no fixed viewport, so narrower widths were not measured');
|
|
335
|
+
return evidence;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
for (const breakpoint of narrower) {
|
|
339
|
+
await page.setViewportSize({ width: breakpoint, height: viewport.height });
|
|
340
|
+
await page.waitForTimeout(UX_SETTLE_MS);
|
|
341
|
+
if ((await page.evaluate(() => window.innerWidth)) !== breakpoint) {
|
|
342
|
+
evidence.notes.push(`resizing to ${breakpoint}px had no effect, so that width was not measured`);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
evidence.breakpoints.push(await this.captureUxBreakpoint(page));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
await page.setViewportSize(viewport);
|
|
350
|
+
}
|
|
351
|
+
return evidence;
|
|
352
|
+
}
|
|
353
|
+
async animationsIgnoringReducedMotion(page) {
|
|
354
|
+
if (!(await page.evaluate(countRunningAnimations, UX_FOCUS_SELECTOR)))
|
|
355
|
+
return 0;
|
|
356
|
+
try {
|
|
357
|
+
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
358
|
+
await page.waitForTimeout(UX_SETTLE_MS);
|
|
359
|
+
return await page.evaluate(countRunningAnimations, UX_FOCUS_SELECTOR);
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
await page.emulateMedia({ reducedMotion: null });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async captureUxBreakpoint(page) {
|
|
366
|
+
const focus = page.locator(UX_FOCUS_SELECTOR).first();
|
|
367
|
+
const measurements = await page.evaluate(measureUx, uxProbeArgs(UX_FOCUS_SELECTOR));
|
|
368
|
+
const aria = await focus.ariaSnapshot().catch(() => '');
|
|
369
|
+
const breakpoint = { measurements, aria: compactAriaSnapshot(aria, true).slice(0, UX_ARIA_CAP) };
|
|
370
|
+
if (!this.provider.hasVision())
|
|
371
|
+
return breakpoint;
|
|
372
|
+
const screenshot = await focus.screenshot({ timeout: UX_SCREENSHOT_TIMEOUT }).catch(() => null);
|
|
373
|
+
if (screenshot)
|
|
374
|
+
breakpoint.screenshot = screenshot.toString('base64');
|
|
375
|
+
return breakpoint;
|
|
376
|
+
}
|
|
377
|
+
async writeUxReport(result, focus, evidence, context) {
|
|
378
|
+
const url = result.url || '';
|
|
379
|
+
const rules = [RulesLoader.loadRules(AGENT_NAME, UX_RULES, url), this.provider.getSystemPromptForAgent(AGENT_NAME, url)].filter(Boolean).join('\n\n');
|
|
380
|
+
const system = dedent `
|
|
381
|
+
<role>
|
|
382
|
+
You review the UX of one area of a web page a developer is building right now.
|
|
383
|
+
The report goes back to that developer, so every finding must point at something they can find in that area.
|
|
384
|
+
</role>
|
|
385
|
+
|
|
386
|
+
<focus>
|
|
387
|
+
Area under review: ${focus.area}, chosen because ${focus.reason}.
|
|
388
|
+
Report only on elements inside it. Page-wide header, navigation and footer are out of scope unless they are the area under review.
|
|
389
|
+
</focus>
|
|
390
|
+
|
|
391
|
+
<scope>
|
|
392
|
+
You see one page of one app, not the app.
|
|
393
|
+
- Report that a text fails and why. Never propose replacement wording, terminology, tone or language: the app's vocabulary is unknown to you.
|
|
394
|
+
- Never recommend colors, fonts, spacing values, design tokens or visual style: the project owns its design system.
|
|
395
|
+
- Sizes, gaps, overflow and contrast come only from <measurements> and <axe>. Never estimate them from a screenshot.
|
|
396
|
+
- Report only what the evidence shows. A check the evidence cannot settle is skipped, not guessed.
|
|
397
|
+
</scope>
|
|
398
|
+
|
|
399
|
+
<rules>
|
|
400
|
+
Each rule set carries the priority its findings are reported under.
|
|
401
|
+
|
|
402
|
+
${rules}
|
|
403
|
+
</rules>
|
|
404
|
+
|
|
405
|
+
<output>
|
|
406
|
+
Markdown, no preamble.
|
|
407
|
+
|
|
408
|
+
## Focus
|
|
409
|
+
One line: the area reviewed and the widths covered.
|
|
410
|
+
|
|
411
|
+
Then one section per priority that has findings, in this order: ## Critical, ## High, ## Medium, ## Low. Omit empty sections.
|
|
412
|
+
Each finding is one bullet of one sentence, at most 30 words:
|
|
413
|
+
- **<element as the user sees it>** (<rule set>, <width or "all widths">): <evidence>, <what it costs the user>.
|
|
414
|
+
|
|
415
|
+
One bullet per cause: elements failing the same check share a bullet naming at most three of them.
|
|
416
|
+
Write at most ${UX_MAX_FINDINGS} bullets in total across all sections, most severe first; drop the rest.
|
|
417
|
+
No fix wording, no rule ids, no list of skipped checks, no summary, no tables.
|
|
418
|
+
When nothing fails, write the Focus section and the line: No UX issues found in this area.
|
|
419
|
+
</output>
|
|
420
|
+
`;
|
|
421
|
+
const sections = [`<page url="${url}" title="${result.title || ''}"></page>`];
|
|
422
|
+
if (context.commands.length)
|
|
423
|
+
sections.push(`<session_commands>\n${context.commands.join('\n')}\n</session_commands>`);
|
|
424
|
+
sections.push(`<axe>\n${evidence.axe.map((v) => `[${v.impact}] ${v.id}: ${v.description} — ${v.nodes[0]?.html.slice(0, 100) || ''}`).join('\n') || 'no violations'}\n</axe>`);
|
|
425
|
+
if (evidence.notes.length)
|
|
426
|
+
sections.push(`<notes>\n${evidence.notes.join('\n')}\n</notes>`);
|
|
427
|
+
for (const breakpoint of evidence.breakpoints) {
|
|
428
|
+
const aria = breakpoint.aria || 'the area is not rendered at this width';
|
|
429
|
+
sections.push(`<breakpoint width="${breakpoint.measurements.width}">\n<measurements>\n${renderUxMeasurements(breakpoint.measurements)}\n</measurements>\n<aria>\n${aria}\n</aria>\n</breakpoint>`);
|
|
430
|
+
}
|
|
431
|
+
const shots = evidence.breakpoints.filter((breakpoint) => breakpoint.screenshot);
|
|
432
|
+
if (!shots.length)
|
|
433
|
+
sections.push('No screenshots are attached: judge from ARIA and measurements only, and say in Focus that visual checks were not possible.');
|
|
434
|
+
const content = [{ type: 'text', text: sections.join('\n\n') }];
|
|
435
|
+
for (const shot of shots) {
|
|
436
|
+
content.push({ type: 'text', text: `Screenshot of the area at ${shot.measurements.width}px:` });
|
|
437
|
+
content.push({ type: 'file', mediaType: 'image/png', data: shot.screenshot });
|
|
438
|
+
}
|
|
439
|
+
let model = this.model || this.provider.getModelForAgent(AGENT_NAME);
|
|
440
|
+
if (shots.length)
|
|
441
|
+
model = this.provider.getVisionModel();
|
|
442
|
+
const response = await this.provider.chat([
|
|
443
|
+
{ role: 'system', content: system },
|
|
444
|
+
{ role: 'user', content },
|
|
445
|
+
], model, { agentName: AGENT_NAME });
|
|
446
|
+
return response.text;
|
|
447
|
+
}
|
|
203
448
|
}
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -322,8 +322,9 @@ export class Tester extends TaskAgent {
|
|
|
322
322
|
return;
|
|
323
323
|
}
|
|
324
324
|
if (actionPerformed && !wasSuccessful) {
|
|
325
|
+
const noted = new Set(Object.values(task.notes).map((note) => note.message));
|
|
325
326
|
result?.toolExecutions
|
|
326
|
-
?.filter((execution) => !execution.wasSuccessful && execution.input?.explanation)
|
|
327
|
+
?.filter((execution) => !execution.wasSuccessful && execution.input?.explanation && !noted.has(execution.input.explanation))
|
|
327
328
|
.forEach((execution) => {
|
|
328
329
|
task.addNote(`Failed to ${execution.input.explanation} (${execution.toolName})`, TestResult.FAILED);
|
|
329
330
|
});
|
|
@@ -333,8 +334,10 @@ export class Tester extends TaskAgent {
|
|
|
333
334
|
return;
|
|
334
335
|
}
|
|
335
336
|
if (assertionPerformed) {
|
|
336
|
-
const
|
|
337
|
-
|
|
337
|
+
const passedVerifications = result?.toolExecutions?.filter((execution) => this.ASSERTION_TOOLS.includes(execution.toolName) && execution.wasSuccessful) || [];
|
|
338
|
+
for (const execution of passedVerifications)
|
|
339
|
+
task.addNote(execution.output?.message || '', TestResult.PASSED);
|
|
340
|
+
const message = passedVerifications.at(-1)?.output?.message || '';
|
|
338
341
|
if (wasSuccessful) {
|
|
339
342
|
conversation.addUserText(dedent `
|
|
340
343
|
Assertion "${message}" successfully passed!
|
|
@@ -576,6 +576,13 @@ export class ExploreCommand extends BaseCommand {
|
|
|
576
576
|
commands,
|
|
577
577
|
});
|
|
578
578
|
}
|
|
579
|
+
sections.push({
|
|
580
|
+
label: 'Explore more',
|
|
581
|
+
commands: [
|
|
582
|
+
{ label: 'Whole site', command: `${cli} explore /` },
|
|
583
|
+
{ label: 'Docs', command: 'https://testomat.ai/docs/explorbot' },
|
|
584
|
+
],
|
|
585
|
+
});
|
|
579
586
|
printNextSteps(sections);
|
|
580
587
|
}
|
|
581
588
|
isLimitReached() {
|