explorbot 0.1.12 → 0.1.15
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 +21 -21
- package/dist/bin/explorbot-cli.js +3 -3
- package/dist/package.json +4 -2
- package/dist/rules/researcher/container-rules.md +2 -0
- package/dist/src/action-result.js +2 -1
- package/dist/src/action.js +3 -8
- package/dist/src/ai/captain.js +0 -2
- package/dist/src/ai/conversation.js +20 -4
- package/dist/src/ai/driller.js +1108 -0
- package/dist/src/ai/historian/utils.js +8 -1
- package/dist/src/ai/pilot.js +214 -267
- package/dist/src/ai/provider.js +25 -12
- package/dist/src/ai/quartermaster.js +2 -2
- package/dist/src/ai/rules.js +5 -5
- package/dist/src/ai/session-analyst.js +122 -0
- package/dist/src/ai/tester.js +69 -22
- package/dist/src/ai/tools.js +19 -4
- package/dist/src/commands/base-command.js +6 -6
- package/dist/src/commands/drill-command.js +3 -2
- package/dist/src/commands/exit-command.js +1 -0
- package/dist/src/commands/explore-command.js +9 -2
- package/dist/src/components/AddRule.js +1 -1
- package/dist/src/components/StatusPane.js +6 -1
- package/dist/src/experience-tracker.js +9 -0
- package/dist/src/explorbot.js +48 -8
- package/dist/src/explorer.js +11 -13
- package/dist/src/reporter.js +105 -4
- package/dist/src/state-manager.js +4 -3
- package/dist/src/stats.js +7 -1
- package/dist/src/test-plan.js +47 -3
- package/dist/src/utils/aria.js +354 -529
- package/dist/src/utils/hooks-runner.js +2 -8
- package/dist/src/utils/html.js +371 -0
- package/dist/src/utils/unique-names.js +12 -1
- package/dist/src/utils/url-matcher.js +6 -1
- package/dist/src/utils/web-element.js +27 -24
- package/dist/src/utils/xpath.js +1 -1
- package/package.json +4 -2
- package/rules/researcher/container-rules.md +2 -0
- package/src/action-result.ts +2 -1
- package/src/action.ts +3 -10
- package/src/ai/captain.ts +0 -2
- package/src/ai/conversation.ts +21 -4
- package/src/ai/driller.ts +1194 -0
- package/src/ai/historian/utils.ts +8 -1
- package/src/ai/pilot.ts +215 -265
- package/src/ai/provider.ts +24 -12
- package/src/ai/quartermaster.ts +2 -2
- package/src/ai/rules.ts +5 -5
- package/src/ai/session-analyst.ts +139 -0
- package/src/ai/tester.ts +63 -20
- package/src/ai/tools.ts +18 -4
- package/src/commands/base-command.ts +6 -6
- package/src/commands/drill-command.ts +3 -2
- package/src/commands/exit-command.ts +1 -0
- package/src/commands/explore-command.ts +10 -2
- package/src/components/AddRule.tsx +1 -1
- package/src/components/StatusPane.tsx +6 -3
- package/src/config.ts +4 -0
- package/src/experience-tracker.ts +9 -0
- package/src/explorbot.ts +55 -10
- package/src/explorer.ts +10 -12
- package/src/reporter.ts +108 -4
- package/src/state-manager.ts +4 -3
- package/src/stats.ts +10 -1
- package/src/test-plan.ts +62 -3
- package/src/utils/aria.ts +367 -537
- package/src/utils/hooks-runner.ts +2 -6
- package/src/utils/html.ts +381 -0
- package/src/utils/unique-names.ts +13 -0
- package/src/utils/url-matcher.ts +5 -1
- package/src/utils/web-element.ts +31 -28
- package/src/utils/xpath.ts +1 -1
- package/dist/src/ai/bosun.js +0 -456
- package/src/ai/bosun.ts +0 -571
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
import { tool } from 'ai';
|
|
2
|
+
import dedent from 'dedent';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { ActionResult } from '../action-result.ts';
|
|
5
|
+
import { setActivity } from '../activity.ts';
|
|
6
|
+
import type { ExperienceTracker } from '../experience-tracker.ts';
|
|
7
|
+
import type Explorer from '../explorer.ts';
|
|
8
|
+
import type { KnowledgeTracker } from '../knowledge-tracker.ts';
|
|
9
|
+
import { Observability } from '../observability.ts';
|
|
10
|
+
import { Plan, Test, TestResult } from '../test-plan.ts';
|
|
11
|
+
import { collectInteractiveNodes } from '../utils/aria.ts';
|
|
12
|
+
import { HooksRunner } from '../utils/hooks-runner.ts';
|
|
13
|
+
import {
|
|
14
|
+
EXPLORBOT_ATTRS,
|
|
15
|
+
HTML_COMPOSITE_AREA_HINTS,
|
|
16
|
+
HTML_COMPOSITE_TARGET_ROLES,
|
|
17
|
+
HTML_EXTRACTION_LIMITS,
|
|
18
|
+
HTML_FORM_CONTROL_ROLES,
|
|
19
|
+
HTML_FORM_CONTROL_TAGS,
|
|
20
|
+
HTML_INTERACTIVE_ROLES,
|
|
21
|
+
HTML_SELECTORS,
|
|
22
|
+
HTML_VISIBILITY_LIMITS,
|
|
23
|
+
getComponentScopeHtmlExtractorSource,
|
|
24
|
+
getVisibleOverlayHtmlExtractorSource,
|
|
25
|
+
inferHtmlRole,
|
|
26
|
+
} from '../utils/html.ts';
|
|
27
|
+
import { createDebug, tag } from '../utils/logger.ts';
|
|
28
|
+
import { loop, pause } from '../utils/loop.ts';
|
|
29
|
+
import { WebElement } from '../utils/web-element.ts';
|
|
30
|
+
import type { Agent } from './agent.ts';
|
|
31
|
+
import type { Conversation } from './conversation.ts';
|
|
32
|
+
import type { Navigator } from './navigator.ts';
|
|
33
|
+
import type { Provider } from './provider.ts';
|
|
34
|
+
import { locatorRule } from './rules.ts';
|
|
35
|
+
import { TaskAgent, isInteractive } from './task-agent.ts';
|
|
36
|
+
import { createCodeceptJSTools } from './tools.ts';
|
|
37
|
+
|
|
38
|
+
const debugLog = createDebug('explorbot:driller');
|
|
39
|
+
|
|
40
|
+
interface ComponentInfo {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
role: string;
|
|
44
|
+
locator: string;
|
|
45
|
+
preferredCode: string;
|
|
46
|
+
eidx: string;
|
|
47
|
+
description: string;
|
|
48
|
+
html: string;
|
|
49
|
+
text: string;
|
|
50
|
+
tag: string;
|
|
51
|
+
classes: string[];
|
|
52
|
+
attrs: Record<string, string>;
|
|
53
|
+
context: string;
|
|
54
|
+
variant: string;
|
|
55
|
+
placeholder: string;
|
|
56
|
+
disabled: boolean;
|
|
57
|
+
ariaMatches: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface InteractionResult {
|
|
61
|
+
componentId: string;
|
|
62
|
+
component: string;
|
|
63
|
+
action: string;
|
|
64
|
+
result: 'success' | 'failed' | 'unknown';
|
|
65
|
+
description: string;
|
|
66
|
+
code?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ComponentTest extends Test {
|
|
70
|
+
component?: ComponentInfo;
|
|
71
|
+
interactions?: InteractionResult[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface DrillOptions {
|
|
75
|
+
knowledgePath?: string;
|
|
76
|
+
maxComponents?: number;
|
|
77
|
+
interactive?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class Driller extends TaskAgent implements Agent {
|
|
81
|
+
protected readonly ACTION_TOOLS = ['click', 'pressKey', 'form'];
|
|
82
|
+
emoji = 'D';
|
|
83
|
+
private explorer: Explorer;
|
|
84
|
+
private provider: Provider;
|
|
85
|
+
private navigator: Navigator;
|
|
86
|
+
private hooksRunner: HooksRunner;
|
|
87
|
+
private currentPlan?: Plan;
|
|
88
|
+
private currentConversation: Conversation | null = null;
|
|
89
|
+
private allResults: InteractionResult[] = [];
|
|
90
|
+
private verifiedAction: { componentId: string; toolName: string; code?: string; canonicalCode?: string } | null = null;
|
|
91
|
+
private pendingNestedContext: string | null = null;
|
|
92
|
+
|
|
93
|
+
MAX_COMPONENT_ITERATIONS = 12;
|
|
94
|
+
|
|
95
|
+
constructor(explorer: Explorer, provider: Provider, navigator: Navigator) {
|
|
96
|
+
super();
|
|
97
|
+
this.explorer = explorer;
|
|
98
|
+
this.provider = provider;
|
|
99
|
+
this.navigator = navigator;
|
|
100
|
+
this.hooksRunner = new HooksRunner(explorer, explorer.getConfig());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
protected getNavigator(): Navigator {
|
|
104
|
+
return this.navigator;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
protected getExperienceTracker(): ExperienceTracker {
|
|
108
|
+
return this.explorer.getStateManager().getExperienceTracker();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
protected getKnowledgeTracker(): KnowledgeTracker {
|
|
112
|
+
return this.explorer.getKnowledgeTracker();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
protected getProvider(): Provider {
|
|
116
|
+
return this.provider;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
getSystemMessage(component?: ComponentInfo): string {
|
|
120
|
+
const currentUrl = this.explorer.getStateManager().getCurrentState()?.url;
|
|
121
|
+
const customPrompt = this.provider.getSystemPromptForAgent('driller', currentUrl);
|
|
122
|
+
|
|
123
|
+
return dedent`
|
|
124
|
+
<role>
|
|
125
|
+
You are a senior QA automation engineer focused on drilling one UI component at a time.
|
|
126
|
+
Your goal is to discover reusable interactions for the component using HTML and ARIA only.
|
|
127
|
+
</role>
|
|
128
|
+
|
|
129
|
+
<approach>
|
|
130
|
+
1. Study the provided page HTML and ARIA snapshot
|
|
131
|
+
2. Focus on exactly one component at a time
|
|
132
|
+
3. Try the smallest useful interaction using click, form, and pressKey tools
|
|
133
|
+
4. Restore the page state after navigations, popups, or destructive attempts
|
|
134
|
+
5. Record reusable interactions with drill_record
|
|
135
|
+
6. Call drill_done only after you have finished exploring the component
|
|
136
|
+
</approach>
|
|
137
|
+
|
|
138
|
+
<rules>
|
|
139
|
+
- Never ask for researcher output or rely on page UI maps
|
|
140
|
+
- Work from <page_html>, <page_aria>, and the provided component HTML snippet
|
|
141
|
+
- Never use data-explorbot-eidx in locators
|
|
142
|
+
- Never use container locators in recorded code
|
|
143
|
+
- Prefer one-argument locators or self-contained XPath/CSS locators
|
|
144
|
+
- Prefer aria-* attributes first when they uniquely identify the component: aria-label, aria-labelledby, aria-checked, aria-pressed, aria-expanded, aria-selected
|
|
145
|
+
- Prefer semantic attributes next: role, checked, name, placeholder, title, href, and other stable state-bearing attributes
|
|
146
|
+
- Prefer semantic/state locators over raw classes whenever they are available and specific enough
|
|
147
|
+
- Before choosing a locator, identify what makes the current component semantically different from its siblings
|
|
148
|
+
- If siblings look similar, use text, aria labels, icon clues, variant hints, role, navigation behavior, border/outline classes, or state to target the exact component
|
|
149
|
+
- Component size alone is not enough to choose a sibling instead of the current component, but if the current drilling target differs only by size, keep that exact size variant and record it
|
|
150
|
+
- When an icon is visible, infer its purpose from aria labels, title, class names, SVG names, or nearby text, and mention that purpose in drill_record
|
|
151
|
+
- If there is no meaningful difference between matching siblings, pick the first matching component and say that no semantic difference was found
|
|
152
|
+
- If the component is decorative, duplicated beyond recovery, or not drillable, call drill_skip
|
|
153
|
+
${component ? `- Current component: ${component.name} (${component.role})` : ''}
|
|
154
|
+
</rules>
|
|
155
|
+
|
|
156
|
+
${drillLocatorRule}
|
|
157
|
+
|
|
158
|
+
${customPrompt || ''}
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async drill(opts: DrillOptions = {}): Promise<Plan> {
|
|
163
|
+
const { knowledgePath, maxComponents = 30, interactive = isInteractive() } = opts;
|
|
164
|
+
const currentState = this.explorer.getStateManager().getCurrentState();
|
|
165
|
+
if (!currentState) throw new Error('No page state available');
|
|
166
|
+
|
|
167
|
+
const sessionName = `driller_${Date.now().toString(36)}`;
|
|
168
|
+
this.allResults = [];
|
|
169
|
+
|
|
170
|
+
return Observability.run(`driller: ${currentState.url}`, { tags: ['driller'], sessionId: sessionName }, async () => {
|
|
171
|
+
tag('info').log(`Driller starting on ${currentState.url}`);
|
|
172
|
+
await this.hooksRunner.runBeforeHook('driller', currentState.url);
|
|
173
|
+
|
|
174
|
+
const originalState = await this.captureAnnotatedState();
|
|
175
|
+
const components = await this.collectComponents(originalState, maxComponents);
|
|
176
|
+
|
|
177
|
+
this.currentPlan = new Plan(`Drill: ${originalState.url}`);
|
|
178
|
+
this.currentPlan.url = originalState.url;
|
|
179
|
+
|
|
180
|
+
for (const component of components) {
|
|
181
|
+
const test = new Test(`Drill: ${component.name} [${component.id}]`, 'normal', [`Learn a reusable interaction for ${component.name}`], originalState.url) as ComponentTest;
|
|
182
|
+
test.component = component;
|
|
183
|
+
test.interactions = [];
|
|
184
|
+
this.currentPlan.addTest(test);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (components.length === 0) {
|
|
188
|
+
tag('warning').log('No drillable components found on page');
|
|
189
|
+
await this.hooksRunner.runAfterHook('driller', originalState.url);
|
|
190
|
+
return this.currentPlan;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const test of this.currentPlan.tests) {
|
|
194
|
+
const componentTest = test as ComponentTest;
|
|
195
|
+
if (!componentTest.component) continue;
|
|
196
|
+
await this.restoreOriginalState(originalState, `Prepare component ${componentTest.component.name}`);
|
|
197
|
+
await this.captureAnnotatedState();
|
|
198
|
+
await this.drillComponent(componentTest, originalState, interactive);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await this.saveToExperience(originalState, this.allResults);
|
|
202
|
+
if (knowledgePath) await this.saveToKnowledge(knowledgePath, originalState, this.allResults);
|
|
203
|
+
|
|
204
|
+
await this.hooksRunner.runAfterHook('driller', originalState.url);
|
|
205
|
+
this.logSummary();
|
|
206
|
+
return this.currentPlan;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async captureAnnotatedState(): Promise<ActionResult> {
|
|
211
|
+
setActivity(`${this.emoji} Capturing annotated page state...`, 'action');
|
|
212
|
+
const action = this.explorer.createAction();
|
|
213
|
+
try {
|
|
214
|
+
const annotated = await Promise.race([
|
|
215
|
+
this.explorer.annotateElements(),
|
|
216
|
+
new Promise<never>((_, reject) => {
|
|
217
|
+
setTimeout(() => reject(new Error('annotateElements timeout')), 15000);
|
|
218
|
+
}),
|
|
219
|
+
]);
|
|
220
|
+
return action.capturePageState({ ariaSnapshot: annotated.ariaSnapshot });
|
|
221
|
+
} catch (error) {
|
|
222
|
+
tag('warning').log(`Annotated capture failed, falling back to plain page state: ${error instanceof Error ? error.message : error}`);
|
|
223
|
+
return action.capturePageState();
|
|
224
|
+
} finally {
|
|
225
|
+
setActivity(`${this.emoji} Annotated page state captured`, 'action');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private async collectComponents(state: ActionResult, maxComponents: number): Promise<ComponentInfo[]> {
|
|
230
|
+
setActivity(`${this.emoji} Collecting components...`, 'action');
|
|
231
|
+
const page = this.explorer.playwrightHelper.page;
|
|
232
|
+
const eidxList = await this.explorer.getEidxInContainer(null);
|
|
233
|
+
const webElements = await WebElement.fromEidxList(page, eidxList);
|
|
234
|
+
const ariaNodes = collectInteractiveNodes(state.ariaSnapshot);
|
|
235
|
+
const scored = webElements
|
|
236
|
+
.filter((element) => isDrillableElement(element))
|
|
237
|
+
.map((element) => ({ element, score: scoreComponentPriority(element) }))
|
|
238
|
+
.sort((left, right) => right.score - left.score);
|
|
239
|
+
const primary = scored.filter((entry) => entry.score >= 0).map((entry) => entry.element);
|
|
240
|
+
const fallback = scored.filter((entry) => entry.score < 0).map((entry) => entry.element);
|
|
241
|
+
const primaryButtonLike = primary.filter((element) => isButtonLikeElement(element));
|
|
242
|
+
const primaryOther = primary.filter((element) => !isButtonLikeElement(element));
|
|
243
|
+
const fallbackButtonLike = fallback.filter((element) => isButtonLikeElement(element));
|
|
244
|
+
const fallbackOther = fallback.filter((element) => !isButtonLikeElement(element));
|
|
245
|
+
const prioritized = primaryButtonLike.length >= maxComponents ? primaryButtonLike : [...primaryButtonLike, ...fallbackButtonLike, ...primaryOther, ...fallbackOther];
|
|
246
|
+
const components: ComponentInfo[] = [];
|
|
247
|
+
const seen = new Set<string>();
|
|
248
|
+
|
|
249
|
+
for (const element of prioritized) {
|
|
250
|
+
if (components.length >= maxComponents) break;
|
|
251
|
+
const eidx = element.eidx;
|
|
252
|
+
if (!eidx || !element.clickXPath) continue;
|
|
253
|
+
const component = this.toComponentInfo(element, ariaNodes);
|
|
254
|
+
if (seen.has(component.id)) continue;
|
|
255
|
+
seen.add(component.id);
|
|
256
|
+
components.push(component);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
tag('info').log(`Prepared ${components.length} components for drilling (main content first)`);
|
|
260
|
+
return components;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private toComponentInfo(element: WebElement, ariaNodes: Array<Record<string, unknown>>): ComponentInfo {
|
|
264
|
+
const role = inferRole(element);
|
|
265
|
+
const text = element.text || element.attrs['aria-label'] || element.attrs.placeholder || element.attrs.name || '';
|
|
266
|
+
const fallbackName = element.attrs.id || element.attrs.class || element.tag;
|
|
267
|
+
const context = truncate(element.contextLabel, 80);
|
|
268
|
+
const variant = formatVariant(element.variantHints);
|
|
269
|
+
const name = formatComponentName(role, text || fallbackName, context, variant);
|
|
270
|
+
const normalizedText = normalized(text);
|
|
271
|
+
const ariaMatches = ariaNodes
|
|
272
|
+
.filter((node) => {
|
|
273
|
+
const nodeRole = typeof node.role === 'string' ? node.role : '';
|
|
274
|
+
if (nodeRole !== role) return false;
|
|
275
|
+
const nodeName = typeof node.name === 'string' ? node.name : '';
|
|
276
|
+
const normalizedName = normalized(nodeName);
|
|
277
|
+
if (normalizedName === '' || normalizedText === '') return false;
|
|
278
|
+
return normalizedName === normalizedText || normalizedName.includes(normalizedText) || normalizedText.includes(normalizedName);
|
|
279
|
+
})
|
|
280
|
+
.slice(0, 3)
|
|
281
|
+
.map((node) => formatAriaNode(node));
|
|
282
|
+
|
|
283
|
+
const component: ComponentInfo = {
|
|
284
|
+
id: buildComponentId(element, role, text),
|
|
285
|
+
name,
|
|
286
|
+
role,
|
|
287
|
+
locator: element.clickXPath,
|
|
288
|
+
preferredCode: '',
|
|
289
|
+
eidx: element.eidx!,
|
|
290
|
+
description: element.description,
|
|
291
|
+
html: element.outerHTML,
|
|
292
|
+
text,
|
|
293
|
+
tag: element.tag,
|
|
294
|
+
classes: element.filteredClasses,
|
|
295
|
+
attrs: element.attrs,
|
|
296
|
+
context,
|
|
297
|
+
variant,
|
|
298
|
+
placeholder: element.attrs.placeholder || '',
|
|
299
|
+
disabled: element.variantHints.includes('disabled') || element.filteredClasses.includes('cursor-not-allowed') || element.attrs.disabled !== undefined || element.attrs['aria-disabled'] === 'true',
|
|
300
|
+
ariaMatches,
|
|
301
|
+
};
|
|
302
|
+
component.preferredCode = buildCanonicalClickCode(component);
|
|
303
|
+
return component;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private async drillComponent(test: ComponentTest, originalState: ActionResult, interactive: boolean): Promise<void> {
|
|
307
|
+
const component = test.component;
|
|
308
|
+
if (!component) return;
|
|
309
|
+
|
|
310
|
+
if (component.disabled) {
|
|
311
|
+
const description = 'Component is disabled and has no drillable interactive behavior.';
|
|
312
|
+
test.start();
|
|
313
|
+
test.interactions ||= [];
|
|
314
|
+
test.interactions.push({ componentId: component.id, component: component.name, action: 'skip', result: 'unknown', description });
|
|
315
|
+
test.addNote(`Skipped: ${description}`, TestResult.SKIPPED);
|
|
316
|
+
test.finish(TestResult.SKIPPED);
|
|
317
|
+
this.allResults.push({ componentId: component.id, component: component.name, action: 'skip', result: 'unknown', description });
|
|
318
|
+
tag('warning').log(`Skipped ${component.name}: disabled component`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
test.start();
|
|
323
|
+
this.verifiedAction = null;
|
|
324
|
+
this.pendingNestedContext = null;
|
|
325
|
+
const conversation = this.provider.startConversation(this.getSystemMessage(component), 'driller');
|
|
326
|
+
this.currentConversation = conversation;
|
|
327
|
+
conversation.addUserText(await this.buildComponentPrompt(originalState, component));
|
|
328
|
+
|
|
329
|
+
let finished = false;
|
|
330
|
+
const actionTools = this.createVerifiedActionTools(createCodeceptJSTools(this.explorer, test), component);
|
|
331
|
+
const tools = { ...actionTools, ...this.createDrillFlowTools(originalState, test, interactive) };
|
|
332
|
+
|
|
333
|
+
await loop(
|
|
334
|
+
async ({ stop, iteration }) => {
|
|
335
|
+
debugLog(`Drilling component ${component.name}, iteration ${iteration}`);
|
|
336
|
+
setActivity(`${this.emoji} Drilling ${component.name}...`, 'action');
|
|
337
|
+
|
|
338
|
+
if (iteration > 1) {
|
|
339
|
+
const currentState = ActionResult.fromState(this.explorer.getStateManager().getCurrentState() || originalState);
|
|
340
|
+
conversation.addUserText(await this.buildContextUpdate(currentState, component));
|
|
341
|
+
if (this.pendingNestedContext) {
|
|
342
|
+
conversation.addUserText(this.pendingNestedContext);
|
|
343
|
+
this.pendingNestedContext = null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const result = await this.provider.invokeConversation(conversation, tools, {
|
|
348
|
+
maxToolRoundtrips: 5,
|
|
349
|
+
toolChoice: 'required',
|
|
350
|
+
agentName: 'driller',
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
if (!result) throw new Error('Failed to get response from provider');
|
|
354
|
+
|
|
355
|
+
const toolExecutions = result.toolExecutions || [];
|
|
356
|
+
this.trackToolExecutions(toolExecutions);
|
|
357
|
+
const failedActionCount = toolExecutions.filter((execution: any) => this.ACTION_TOOLS.includes(execution.toolName) && !execution.wasSuccessful).length;
|
|
358
|
+
if (failedActionCount >= 4) stop();
|
|
359
|
+
|
|
360
|
+
const hasDone = toolExecutions.some((execution: any) => execution.toolName === 'drill_done' && execution.wasSuccessful);
|
|
361
|
+
const hasSkip = toolExecutions.some((execution: any) => execution.toolName === 'drill_skip' && execution.wasSuccessful);
|
|
362
|
+
if (hasDone || hasSkip) {
|
|
363
|
+
finished = true;
|
|
364
|
+
stop();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (iteration >= this.MAX_COMPONENT_ITERATIONS) stop();
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
maxAttempts: this.MAX_COMPONENT_ITERATIONS,
|
|
371
|
+
interruptPrompt: `Drill interrupted while testing "${component.name}". Enter instruction (or "stop" to end):`,
|
|
372
|
+
observability: { agent: 'driller', sessionId: `${test.id}_${component.eidx}` },
|
|
373
|
+
catch: async ({ error, stop }) => {
|
|
374
|
+
tag('error').log(`Drill error for ${component.name}: ${error}`);
|
|
375
|
+
stop();
|
|
376
|
+
},
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
if (finished || test.hasFinished) return;
|
|
381
|
+
if ((test.interactions || []).some((interaction) => interaction.result === 'success')) {
|
|
382
|
+
test.addNote('Recorded reusable interactions before loop stopped', TestResult.PASSED);
|
|
383
|
+
test.finish(TestResult.PASSED);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
test.addNote('No reusable interaction recorded', TestResult.FAILED);
|
|
388
|
+
test.finish(TestResult.FAILED);
|
|
389
|
+
this.allResults.push({ componentId: component.id, component: component.name, action: 'drill', result: 'failed', description: 'No reusable interaction recorded' });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private async buildComponentPrompt(originalState: ActionResult, component: ComponentInfo): Promise<string> {
|
|
393
|
+
const html = await this.getComponentScopeHtml(component, originalState);
|
|
394
|
+
const knowledge = this.getKnowledge(originalState);
|
|
395
|
+
const experience = this.getExperience(originalState);
|
|
396
|
+
const ariaMatches = component.ariaMatches.length > 0 ? component.ariaMatches.map((line) => `- ${line}`).join('\n') : '- no direct ARIA match';
|
|
397
|
+
|
|
398
|
+
return dedent`
|
|
399
|
+
<task>
|
|
400
|
+
Drill exactly one component and learn a reusable interaction for it.
|
|
401
|
+
</task>
|
|
402
|
+
|
|
403
|
+
<page>
|
|
404
|
+
URL: ${originalState.url}
|
|
405
|
+
Title: ${originalState.title || 'Unknown'}
|
|
406
|
+
</page>
|
|
407
|
+
|
|
408
|
+
<component>
|
|
409
|
+
ID: ${component.id}
|
|
410
|
+
Name: ${component.name}
|
|
411
|
+
Role: ${component.role}
|
|
412
|
+
Preferred locator: ${component.locator}
|
|
413
|
+
Preferred click code: ${component.preferredCode || '-'}
|
|
414
|
+
eidx: ${component.eidx}
|
|
415
|
+
DOM summary: ${component.description}
|
|
416
|
+
Text: ${component.text || '-'}
|
|
417
|
+
Context: ${component.context || '-'}
|
|
418
|
+
Variant: ${component.variant || '-'}
|
|
419
|
+
Differentiators: ${formatComponentDifferentiators(component)}
|
|
420
|
+
Matching ARIA candidates:
|
|
421
|
+
${ariaMatches}
|
|
422
|
+
</component>
|
|
423
|
+
|
|
424
|
+
<component_html>
|
|
425
|
+
${component.html}
|
|
426
|
+
</component_html>
|
|
427
|
+
|
|
428
|
+
<page_html>
|
|
429
|
+
${html}
|
|
430
|
+
</page_html>
|
|
431
|
+
|
|
432
|
+
<page_aria>
|
|
433
|
+
${originalState.getInteractiveARIA()}
|
|
434
|
+
</page_aria>
|
|
435
|
+
|
|
436
|
+
${knowledge}
|
|
437
|
+
${experience}
|
|
438
|
+
|
|
439
|
+
<instructions>
|
|
440
|
+
1. Work only with this component
|
|
441
|
+
2. Use Preferred click code first unless it clearly fails, then try other self-contained locators from page HTML
|
|
442
|
+
3. When you need a new locator, prefer aria-* attributes first, then semantic/state attributes like role, checked, name, placeholder, title, href
|
|
443
|
+
4. Only fall back to classes or text-heavy XPath when aria/semantic attributes are not sufficient to target the exact component
|
|
444
|
+
5. Never use container locators in code
|
|
445
|
+
6. Never use data-explorbot-eidx in code
|
|
446
|
+
7. If the page changes, use drill_restore before continuing
|
|
447
|
+
8. Call drill_record for each reusable interaction you discover
|
|
448
|
+
9. When you are done exploring the component, call drill_done
|
|
449
|
+
10. If the component is not drillable, call drill_skip
|
|
450
|
+
11. If similar components exist, use Context and Variant to distinguish this exact variant instead of skipping immediately
|
|
451
|
+
12. Do not switch to a sibling with the same text but different variant or size. Stay anchored to the current component's Preferred locator, Context, and Variant.
|
|
452
|
+
12a. If same-text components differ only by size, still record the current size variant instead of treating it as a duplicate.
|
|
453
|
+
13. In drill_record result, describe the component precisely: color/variant, border/outline, icon purpose, text, role, navigation behavior, state, and why this component was chosen over similar siblings.
|
|
454
|
+
14. Prefer results like "Clicked the red outlined button with a leading refresh icon." over generic results like "Button clicked."
|
|
455
|
+
</instructions>
|
|
456
|
+
`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private async buildContextUpdate(currentState: ActionResult, component: ComponentInfo): Promise<string> {
|
|
460
|
+
return dedent`
|
|
461
|
+
<context_update>
|
|
462
|
+
Current URL: ${currentState.url}
|
|
463
|
+
Continue drilling component: ${component.name}
|
|
464
|
+
Context: ${component.context || '-'}
|
|
465
|
+
Variant: ${component.variant || '-'}
|
|
466
|
+
Differentiators: ${formatComponentDifferentiators(component)}
|
|
467
|
+
If the component moved or disappeared, reassess using the current ARIA tree.
|
|
468
|
+
</context_update>
|
|
469
|
+
|
|
470
|
+
<page_aria>
|
|
471
|
+
${currentState.getInteractiveARIA()}
|
|
472
|
+
</page_aria>
|
|
473
|
+
`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private createDrillFlowTools(originalState: ActionResult, test: ComponentTest, interactive: boolean) {
|
|
477
|
+
return {
|
|
478
|
+
drill_record: tool({
|
|
479
|
+
description: 'Record a reusable interaction for the current component. Use only when the code is reusable and does not depend on a container locator.',
|
|
480
|
+
inputSchema: z.object({
|
|
481
|
+
action: z.string().describe('Action performed, for example click, fill, select, open, toggle'),
|
|
482
|
+
result: z.string().describe('What happened after the interaction, including the component differentiators used: icon purpose, color/variant, border/outline, text, role, navigation behavior, or state'),
|
|
483
|
+
code: z.string().describe('Reusable CodeceptJS code that worked'),
|
|
484
|
+
}),
|
|
485
|
+
execute: async ({ action, result, code }) => {
|
|
486
|
+
const component = test.component;
|
|
487
|
+
if (!component) return { success: false, message: 'No active component' };
|
|
488
|
+
if (!this.hasVerifiedAction(component.id)) {
|
|
489
|
+
return { success: false, message: 'drill_record requires a real successful click, form, or pressKey for this component in the current drill run.' };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const exactCode = this.verifiedAction?.code?.trim();
|
|
493
|
+
const canonicalCode = this.verifiedAction?.canonicalCode?.trim();
|
|
494
|
+
const recordedCode = code.trim();
|
|
495
|
+
if (exactCode && canonicalCode && recordedCode !== exactCode && recordedCode !== canonicalCode && !recordedCode.includes(exactCode) && !recordedCode.includes(canonicalCode)) {
|
|
496
|
+
return { success: false, message: `drill_record must save the verified code for this component: ${canonicalCode}` };
|
|
497
|
+
}
|
|
498
|
+
if (exactCode && !canonicalCode && recordedCode !== exactCode && !recordedCode.includes(exactCode)) {
|
|
499
|
+
return { success: false, message: `drill_record must save the exact code that just worked for this component: ${this.verifiedAction?.code || exactCode}` };
|
|
500
|
+
}
|
|
501
|
+
if (hasContainerLocator(code)) {
|
|
502
|
+
return { success: false, message: 'Container locators are not allowed in driller records. Rewrite the code with a self-contained locator.' };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const normalizedResult = normalizeInteractionResult(component, action, result);
|
|
506
|
+
const interaction: InteractionResult = {
|
|
507
|
+
componentId: component.id,
|
|
508
|
+
component: component.name,
|
|
509
|
+
action,
|
|
510
|
+
result: 'success',
|
|
511
|
+
description: normalizedResult,
|
|
512
|
+
code: recordedCode === exactCode || recordedCode === canonicalCode ? canonicalCode || code : code,
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
test.interactions ||= [];
|
|
516
|
+
test.interactions.push(interaction);
|
|
517
|
+
test.addNote(`${action}: ${normalizedResult}`, TestResult.PASSED);
|
|
518
|
+
this.allResults.push(interaction);
|
|
519
|
+
|
|
520
|
+
tag('success').log(`${component.name}: ${action} -> ${normalizedResult}`);
|
|
521
|
+
return { success: true, recorded: `${component.name}: ${action} -> ${normalizedResult}` };
|
|
522
|
+
},
|
|
523
|
+
}),
|
|
524
|
+
|
|
525
|
+
drill_done: tool({
|
|
526
|
+
description: 'Finish drilling the current component after all useful interactions have been recorded.',
|
|
527
|
+
inputSchema: z.object({
|
|
528
|
+
summary: z.string().describe('What was learned about this component'),
|
|
529
|
+
}),
|
|
530
|
+
execute: async ({ summary }) => {
|
|
531
|
+
const component = test.component;
|
|
532
|
+
if (!component) return { success: false, message: 'No active component' };
|
|
533
|
+
if (this.pendingNestedContext) {
|
|
534
|
+
return { success: false, message: 'A nested overlay or popup opened after the last action. Drill useful interactions inside it before calling drill_done.' };
|
|
535
|
+
}
|
|
536
|
+
const successCount = (test.interactions || []).filter((interaction) => interaction.result === 'success').length;
|
|
537
|
+
if (successCount === 0) {
|
|
538
|
+
return { success: false, message: 'Record at least one reusable interaction before calling drill_done, or use drill_skip.' };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
test.addNote(`Completed: ${summary}`, TestResult.PASSED);
|
|
542
|
+
test.finish(TestResult.PASSED);
|
|
543
|
+
return { success: true, summary, recorded: successCount };
|
|
544
|
+
},
|
|
545
|
+
}),
|
|
546
|
+
|
|
547
|
+
drill_skip: tool({
|
|
548
|
+
description: 'Skip the current component when it is decorative, duplicated beyond recovery, or not drillable.',
|
|
549
|
+
inputSchema: z.object({
|
|
550
|
+
reason: z.string().describe('Why the component is being skipped'),
|
|
551
|
+
}),
|
|
552
|
+
execute: async ({ reason }) => {
|
|
553
|
+
const component = test.component;
|
|
554
|
+
if (!component) return { success: false, message: 'No active component' };
|
|
555
|
+
|
|
556
|
+
const interaction: InteractionResult = {
|
|
557
|
+
componentId: component.id,
|
|
558
|
+
component: component.name,
|
|
559
|
+
action: 'skip',
|
|
560
|
+
result: 'unknown',
|
|
561
|
+
description: reason,
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
test.interactions ||= [];
|
|
565
|
+
test.interactions.push(interaction);
|
|
566
|
+
test.addNote(`Skipped: ${reason}`, TestResult.SKIPPED);
|
|
567
|
+
test.finish(TestResult.SKIPPED);
|
|
568
|
+
this.allResults.push(interaction);
|
|
569
|
+
|
|
570
|
+
tag('warning').log(`Skipped ${component.name}: ${reason}`);
|
|
571
|
+
return { success: true, skipped: component.name };
|
|
572
|
+
},
|
|
573
|
+
}),
|
|
574
|
+
|
|
575
|
+
drill_restore: tool({
|
|
576
|
+
description: 'Restore the original page state before continuing drilling.',
|
|
577
|
+
inputSchema: z.object({
|
|
578
|
+
reason: z.string().describe('Why restoration is needed'),
|
|
579
|
+
}),
|
|
580
|
+
execute: async ({ reason }) => {
|
|
581
|
+
await this.restoreOriginalState(originalState, reason);
|
|
582
|
+
await this.captureAnnotatedState();
|
|
583
|
+
const currentState = this.explorer.getStateManager().getCurrentState();
|
|
584
|
+
return { success: true, url: currentState?.url || originalState.url };
|
|
585
|
+
},
|
|
586
|
+
}),
|
|
587
|
+
|
|
588
|
+
drill_ask: tool({
|
|
589
|
+
description: 'Ask the user for help when stuck. Only available in interactive mode.',
|
|
590
|
+
inputSchema: z.object({
|
|
591
|
+
question: z.string().describe('What help is needed'),
|
|
592
|
+
}),
|
|
593
|
+
execute: async ({ question }) => {
|
|
594
|
+
if (!interactive) return { success: false, message: 'Not in interactive mode' };
|
|
595
|
+
const userInput = await pause(`${question}\n\nYour CodeceptJS command ("skip" to continue):`);
|
|
596
|
+
if (!userInput || userInput.toLowerCase() === 'skip') return { success: false, skipped: true };
|
|
597
|
+
return { success: true, userSuggestion: userInput, instruction: `Execute this suggestion if it helps: ${userInput}` };
|
|
598
|
+
},
|
|
599
|
+
}),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private async restoreOriginalState(originalState: ActionResult, reason: string): Promise<void> {
|
|
604
|
+
const currentState = this.explorer.getStateManager().getCurrentState();
|
|
605
|
+
const targetUrl = originalState.fullUrl || originalState.url;
|
|
606
|
+
const action = this.explorer.createAction();
|
|
607
|
+
|
|
608
|
+
if (currentState?.url !== originalState.url) {
|
|
609
|
+
await action.attempt(`I.amOnPage(${JSON.stringify(targetUrl)})`, `${reason} (restore URL)`, false);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
await action.attempt('I.pressKey("Escape")', `${reason} (restore state)`, false);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private async saveToExperience(state: ActionResult, results: InteractionResult[]): Promise<void> {
|
|
617
|
+
const experienceTracker = this.getExperienceTracker();
|
|
618
|
+
const successfulInteractions = results.filter((result) => result.result === 'success' && result.code);
|
|
619
|
+
|
|
620
|
+
for (const interaction of successfulInteractions) {
|
|
621
|
+
experienceTracker.writeAction(state, {
|
|
622
|
+
title: formatExperienceTitle(interaction),
|
|
623
|
+
code: interaction.code!,
|
|
624
|
+
explanation: interaction.description,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (successfulInteractions.length > 0) {
|
|
629
|
+
tag('success').log(`Saved ${successfulInteractions.length} drill interactions to experience`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private createVerifiedActionTools(baseTools: Record<string, any>, component: ComponentInfo): Record<string, any> {
|
|
634
|
+
const wrappedTools = { ...baseTools };
|
|
635
|
+
|
|
636
|
+
for (const toolName of this.ACTION_TOOLS) {
|
|
637
|
+
const originalTool = wrappedTools[toolName];
|
|
638
|
+
if (!originalTool) continue;
|
|
639
|
+
wrappedTools[toolName] = tool({
|
|
640
|
+
description: originalTool.description,
|
|
641
|
+
inputSchema: originalTool.inputSchema,
|
|
642
|
+
execute: async (input: any) => {
|
|
643
|
+
const result = await originalTool.execute(input);
|
|
644
|
+
if (result?.success) {
|
|
645
|
+
this.verifiedAction = {
|
|
646
|
+
componentId: component.id,
|
|
647
|
+
toolName,
|
|
648
|
+
code: typeof result.code === 'string' ? result.code : undefined,
|
|
649
|
+
canonicalCode: typeof result.code === 'string' ? canonicalizeRecordedClick(component, result.code) : undefined,
|
|
650
|
+
};
|
|
651
|
+
this.pendingNestedContext = await this.detectNestedOverlayContext(component, result);
|
|
652
|
+
}
|
|
653
|
+
return result;
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return wrappedTools;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private hasVerifiedAction(componentId: string): boolean {
|
|
662
|
+
return this.verifiedAction?.componentId === componentId;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private async detectNestedOverlayContext(component: ComponentInfo, result: any): Promise<string | null> {
|
|
666
|
+
if (!result?.pageDiff?.ariaChanges || result.pageDiff.urlChanged) return null;
|
|
667
|
+
|
|
668
|
+
const overlayHtml = await this.getVisibleOverlayHtml();
|
|
669
|
+
if (!overlayHtml) return null;
|
|
670
|
+
|
|
671
|
+
const state = this.explorer.getStateManager().getCurrentState();
|
|
672
|
+
if (!state) return null;
|
|
673
|
+
const currentState = ActionResult.fromState(state);
|
|
674
|
+
return dedent`
|
|
675
|
+
<nested_overlay>
|
|
676
|
+
The last action on ${component.name} opened a nested overlay, popup, dropdown, menu, or calendar.
|
|
677
|
+
Drill useful interactions inside this nested UI before calling drill_done.
|
|
678
|
+
Keep the recorded code reusable and include the parent-opening action when the nested element requires the overlay to be open.
|
|
679
|
+
|
|
680
|
+
<overlay_html>
|
|
681
|
+
${overlayHtml}
|
|
682
|
+
</overlay_html>
|
|
683
|
+
|
|
684
|
+
<current_page_aria>
|
|
685
|
+
${currentState.getInteractiveARIA()}
|
|
686
|
+
</current_page_aria>
|
|
687
|
+
</nested_overlay>
|
|
688
|
+
`;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private async getVisibleOverlayHtml(): Promise<string> {
|
|
692
|
+
const page = this.explorer.playwrightHelper.page;
|
|
693
|
+
return page.evaluate(
|
|
694
|
+
({ extractorSource, config }) => {
|
|
695
|
+
const extract = new Function(`return ${extractorSource}`)() as (config: any) => string;
|
|
696
|
+
return extract(config);
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
extractorSource: getVisibleOverlayHtmlExtractorSource(),
|
|
700
|
+
config: {
|
|
701
|
+
interactiveContentSelector: HTML_SELECTORS.interactiveContent,
|
|
702
|
+
limits: HTML_EXTRACTION_LIMITS,
|
|
703
|
+
overlaySelectors: HTML_SELECTORS.semanticOverlays,
|
|
704
|
+
visibilityLimits: HTML_VISIBILITY_LIMITS,
|
|
705
|
+
},
|
|
706
|
+
}
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private async getComponentScopeHtml(component: ComponentInfo, originalState: ActionResult): Promise<string> {
|
|
711
|
+
const page = this.explorer.playwrightHelper.page;
|
|
712
|
+
const scopedHtml = await page.evaluate(
|
|
713
|
+
({ eidx, extractorSource, config }) => {
|
|
714
|
+
const extract = new Function(`return ${extractorSource}`)() as (eidx: string, config: any) => string;
|
|
715
|
+
return extract(eidx, config);
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
eidx: component.eidx,
|
|
719
|
+
extractorSource: getComponentScopeHtmlExtractorSource(),
|
|
720
|
+
config: {
|
|
721
|
+
eidxAttr: EXPLORBOT_ATTRS.eidx,
|
|
722
|
+
interactiveControlSelector: HTML_SELECTORS.interactiveControl,
|
|
723
|
+
limits: HTML_EXTRACTION_LIMITS,
|
|
724
|
+
},
|
|
725
|
+
}
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
if (scopedHtml) return scopedHtml;
|
|
729
|
+
return await originalState.combinedHtml();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private async saveToKnowledge(knowledgePath: string, state: ActionResult, results: InteractionResult[]): Promise<void> {
|
|
733
|
+
const knowledgeTracker = this.getKnowledgeTracker();
|
|
734
|
+
const successfulInteractions = results.filter((result) => result.result === 'success');
|
|
735
|
+
if (successfulInteractions.length === 0) {
|
|
736
|
+
tag('warning').log('No successful interactions to save to knowledge');
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const content = this.generateKnowledgeContent(state, successfulInteractions);
|
|
741
|
+
const result = knowledgeTracker.addKnowledge(knowledgePath, content);
|
|
742
|
+
tag('success').log(`Knowledge saved to: ${result.filePath}`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private generateKnowledgeContent(state: ActionResult, interactions: InteractionResult[]): string {
|
|
746
|
+
const lines: string[] = [];
|
|
747
|
+
lines.push('# Component Interactions\n');
|
|
748
|
+
lines.push(`Learned interactions from drilling ${state.url}\n`);
|
|
749
|
+
|
|
750
|
+
const groupedByComponent = new Map<string, InteractionResult[]>();
|
|
751
|
+
for (const interaction of interactions) {
|
|
752
|
+
const existing = groupedByComponent.get(interaction.component) || [];
|
|
753
|
+
existing.push(interaction);
|
|
754
|
+
groupedByComponent.set(interaction.component, existing);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
for (const [component, items] of groupedByComponent) {
|
|
758
|
+
lines.push(`\n## ${component}\n`);
|
|
759
|
+
for (const item of items) {
|
|
760
|
+
lines.push(`- **${item.action}**: ${item.description}`);
|
|
761
|
+
if (item.code) {
|
|
762
|
+
lines.push('```js');
|
|
763
|
+
lines.push(item.code);
|
|
764
|
+
lines.push('```');
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return lines.join('\n');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private logSummary(): void {
|
|
773
|
+
if (!this.currentPlan) return;
|
|
774
|
+
|
|
775
|
+
const total = this.currentPlan.tests.length;
|
|
776
|
+
const passed = this.currentPlan.tests.filter((test) => test.isSuccessful).length;
|
|
777
|
+
const skipped = this.currentPlan.tests.filter((test) => test.isSkipped).length;
|
|
778
|
+
const failed = this.currentPlan.tests.filter((test) => test.hasFailed).length;
|
|
779
|
+
|
|
780
|
+
tag('info').log('\nDrill Summary:');
|
|
781
|
+
tag('info').log(` Total components: ${total}`);
|
|
782
|
+
tag('success').log(` Successful: ${passed}`);
|
|
783
|
+
if (skipped > 0) tag('warning').log(` Skipped: ${skipped}`);
|
|
784
|
+
if (failed > 0) tag('warning').log(` Failed: ${failed}`);
|
|
785
|
+
|
|
786
|
+
for (const test of this.currentPlan.tests) {
|
|
787
|
+
const componentTest = test as ComponentTest;
|
|
788
|
+
const status = test.isSuccessful ? 'PASS' : test.isSkipped ? 'SKIP' : 'FAIL';
|
|
789
|
+
tag('step').log(` ${status} ${componentTest.component?.name || test.scenario}`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
getCurrentPlan(): Plan | undefined {
|
|
794
|
+
return this.currentPlan;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
getConversation(): Conversation | null {
|
|
798
|
+
return this.currentConversation;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function formatAriaNode(node: Record<string, unknown>): string {
|
|
803
|
+
const role = typeof node.role === 'string' ? node.role : 'unknown';
|
|
804
|
+
const name = typeof node.name === 'string' ? node.name : '';
|
|
805
|
+
const value = typeof node.value === 'string' ? `: ${node.value}` : '';
|
|
806
|
+
return [role, name ? `"${name}"` : '', value].filter(Boolean).join(' ').trim();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function inferRole(element: WebElement): string {
|
|
810
|
+
return inferHtmlRole(element);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function normalized(value: string): string {
|
|
814
|
+
return value.trim().toLowerCase();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function capitalize(value: string): string {
|
|
818
|
+
if (!value) return value;
|
|
819
|
+
return value[0].toUpperCase() + value.slice(1);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function truncate(value: string, maxLength: number): string {
|
|
823
|
+
if (value.length <= maxLength) return value;
|
|
824
|
+
return `${value.slice(0, maxLength - 3)}...`;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function buildComponentId(element: WebElement, role: string, text: string): string {
|
|
828
|
+
const parts = [role, normalized(text), normalized(element.contextLabel), element.variantHints.join('|'), element.clickXPath, String(element.eidx || '')];
|
|
829
|
+
return parts.join('|').toLowerCase();
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function canonicalizeRecordedClick(component: ComponentInfo, fallbackCode: string): string {
|
|
833
|
+
const preferred = buildCanonicalClickCode(component);
|
|
834
|
+
if (preferred) return preferred;
|
|
835
|
+
return fallbackCode;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export function buildCanonicalClickCode(component: ComponentInfo): string {
|
|
839
|
+
if (component.tag === 'a') return '';
|
|
840
|
+
|
|
841
|
+
const semanticCode = buildSemanticClickCode(component);
|
|
842
|
+
if (semanticCode) return semanticCode;
|
|
843
|
+
|
|
844
|
+
const variantHints = parseVariantHints(component.variant);
|
|
845
|
+
const classSelector = buildClassSelector(component.tag, component.classes);
|
|
846
|
+
if (!classSelector) return component.locator ? `I.click(${JSON.stringify(component.locator)})` : '';
|
|
847
|
+
|
|
848
|
+
if (!component.text) {
|
|
849
|
+
let selector = classSelector;
|
|
850
|
+
if (variantHints.has('double-icon')) selector += ':has(svg):has(svg + svg)';
|
|
851
|
+
else if (variantHints.has('has-icon') || variantHints.has('icon-only')) selector += ':has(svg)';
|
|
852
|
+
return `I.click(${JSON.stringify(selector)})`;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
let selector = `${classSelector}:has-text(${JSON.stringify(component.text)})`;
|
|
856
|
+
if (variantHints.has('double-icon')) selector += ':has(svg):has(svg + svg)';
|
|
857
|
+
else if (variantHints.has('trailing-icon')) selector += ':has(svg):not(:has(svg + svg))';
|
|
858
|
+
else if (variantHints.has('leading-icon') || variantHints.has('has-icon')) selector += ':has(svg)';
|
|
859
|
+
|
|
860
|
+
if (!variantHints.has('has-icon') && !variantHints.has('icon-only') && !variantHints.has('leading-icon') && !variantHints.has('trailing-icon') && !variantHints.has('double-icon')) {
|
|
861
|
+
const textLiteral = component.text.replace(/"/g, '\\"');
|
|
862
|
+
const classConditions = component.classes.slice(0, 5).map((cls) => `contains(@class,"${cls}")`);
|
|
863
|
+
const xpathConditions = [`self::${component.tag}`];
|
|
864
|
+
xpathConditions.push(...classConditions);
|
|
865
|
+
xpathConditions.push(`normalize-space(.)="${textLiteral}"`);
|
|
866
|
+
xpathConditions.push('not(.//svg)');
|
|
867
|
+
return `I.click(${JSON.stringify(`//*[${xpathConditions.join(' and ')}]`)})`;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return `I.click(${JSON.stringify(selector)})`;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function buildSemanticClickCode(component: ComponentInfo): string {
|
|
874
|
+
const conditions: string[] = [];
|
|
875
|
+
const target = component.tag && /^[a-z][a-z0-9-]*$/i.test(component.tag) ? component.tag : '*';
|
|
876
|
+
conditions.push(`self::${target}`);
|
|
877
|
+
|
|
878
|
+
const role = component.attrs.role || '';
|
|
879
|
+
if (role) conditions.push(`@role=${xpathLiteral(role)}`);
|
|
880
|
+
|
|
881
|
+
const labelledBy = component.attrs['aria-labelledby'] || '';
|
|
882
|
+
if (labelledBy) conditions.push(`@aria-labelledby=${xpathLiteral(labelledBy)}`);
|
|
883
|
+
|
|
884
|
+
const label = component.attrs['aria-label'] || component.attrs.title || component.attrs.name || '';
|
|
885
|
+
if (label) conditions.push(`@${getLabelAttrName(component)}=${xpathLiteral(label)}`);
|
|
886
|
+
|
|
887
|
+
const placeholder = component.placeholder || component.attrs.placeholder || '';
|
|
888
|
+
if (placeholder) conditions.push(`@placeholder=${xpathLiteral(placeholder)}`);
|
|
889
|
+
|
|
890
|
+
const stateAttrs = ['aria-checked', 'aria-pressed', 'aria-expanded', 'aria-selected', 'checked'];
|
|
891
|
+
for (const attr of stateAttrs) {
|
|
892
|
+
const value = component.attrs[attr];
|
|
893
|
+
if (attr === 'checked') {
|
|
894
|
+
if (value === undefined) continue;
|
|
895
|
+
conditions.push('@checked');
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (!value) continue;
|
|
899
|
+
conditions.push(`@${attr}=${xpathLiteral(value)}`);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (component.text && role && !label && !labelledBy) {
|
|
903
|
+
conditions.push(`normalize-space(.)=${xpathLiteral(component.text)}`);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (conditions.length <= 1) return '';
|
|
907
|
+
if (!role && !label && !labelledBy && !placeholder) return '';
|
|
908
|
+
|
|
909
|
+
return `I.click(${JSON.stringify(`//*[${conditions.join(' and ')}]`)})`;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function getLabelAttrName(component: ComponentInfo): string {
|
|
913
|
+
if (component.attrs['aria-label']) return 'aria-label';
|
|
914
|
+
if (component.attrs.title) return 'title';
|
|
915
|
+
return 'name';
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function formatVariant(variantHints: string[]): string {
|
|
919
|
+
if (variantHints.length === 0) return '';
|
|
920
|
+
return variantHints.slice(0, 4).join(', ');
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function formatComponentName(role: string, label: string, context: string, variant: string): string {
|
|
924
|
+
const safeLabel = label.trim();
|
|
925
|
+
const quotedLabel = safeLabel ? `"${truncate(safeLabel, 48)}"` : role === 'button' ? '"Icon button"' : capitalize(role);
|
|
926
|
+
const parts = [`${capitalize(role)} ${quotedLabel}`.trim()];
|
|
927
|
+
if (context) parts.push(`[${context}]`);
|
|
928
|
+
if (variant) parts.push(`(${variant})`);
|
|
929
|
+
return parts.join(' ').trim();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function formatComponentDifferentiators(component: ComponentInfo): string {
|
|
933
|
+
const details: string[] = [];
|
|
934
|
+
const classes = component.classes.join(' ').toLowerCase();
|
|
935
|
+
const variantHints = parseVariantHints(component.variant);
|
|
936
|
+
|
|
937
|
+
if (component.text) details.push(`text "${truncate(component.text, 48)}"`);
|
|
938
|
+
if (component.context) details.push(`context "${truncate(component.context, 48)}"`);
|
|
939
|
+
if (component.placeholder) details.push(`placeholder "${truncate(component.placeholder, 48)}"`);
|
|
940
|
+
addAriaDifferentiators(component, details);
|
|
941
|
+
if (component.variant) details.push(`variant hints: ${component.variant}`);
|
|
942
|
+
if (component.role) details.push(`role ${component.role}`);
|
|
943
|
+
if (component.tag === 'a') details.push('navigates');
|
|
944
|
+
if (component.disabled) details.push('disabled state');
|
|
945
|
+
if (variantHints.has('has-icon') || variantHints.has('leading-icon') || variantHints.has('trailing-icon') || variantHints.has('icon-only') || variantHints.has('double-icon')) {
|
|
946
|
+
details.push(`icon clues: ${formatIconClues(component)}`);
|
|
947
|
+
}
|
|
948
|
+
if (classes.includes('border') || variantHints.has('outline')) details.push('border or outline styling');
|
|
949
|
+
if (classes.includes('red') || classes.includes('danger')) details.push('red/danger styling');
|
|
950
|
+
if (classes.includes('green') || classes.includes('success')) details.push('green/success styling');
|
|
951
|
+
if (classes.includes('primary')) details.push('primary styling');
|
|
952
|
+
if (classes.includes('secondary')) details.push('secondary styling');
|
|
953
|
+
|
|
954
|
+
return details.length > 0 ? details.join('; ') : 'No clear semantic difference from similar components.';
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function addAriaDifferentiators(component: ComponentInfo, details: string[]): void {
|
|
958
|
+
const label = component.attrs['aria-label'] || component.attrs.title || component.attrs.name || '';
|
|
959
|
+
if (label) details.push(`accessible label "${truncate(label, 48)}"`);
|
|
960
|
+
|
|
961
|
+
const labelledBy = component.attrs['aria-labelledby'] || '';
|
|
962
|
+
if (labelledBy) details.push(`aria-labelledby ${labelledBy}`);
|
|
963
|
+
|
|
964
|
+
const stateAttrs = ['aria-checked', 'aria-pressed', 'aria-expanded', 'aria-selected'];
|
|
965
|
+
for (const attr of stateAttrs) {
|
|
966
|
+
const value = component.attrs[attr];
|
|
967
|
+
if (!value) continue;
|
|
968
|
+
details.push(`${attr} ${value}`);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (component.attrs.checked !== undefined) details.push('checked state');
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function formatIconClues(component: ComponentInfo): string {
|
|
975
|
+
const iconClasses = component.classes.filter((cls) => /(icon|svg|refresh|reload|renew|copy|play|pause|edit|delete|trash|search|plus|minus|close|check|arrow|calendar|date)/i.test(cls));
|
|
976
|
+
if (iconClasses.length > 0) return iconClasses.slice(0, 5).join(', ');
|
|
977
|
+
if (component.variant) return component.variant;
|
|
978
|
+
return 'icon present, purpose not explicit';
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function normalizeInteractionResult(component: ComponentInfo, action: string, result: string): string {
|
|
982
|
+
const value = result.trim();
|
|
983
|
+
if (!value) return fallbackInteractionResult(component, action);
|
|
984
|
+
|
|
985
|
+
const normalizedValue = value.toLowerCase();
|
|
986
|
+
const weakPhrases = ['button clicked', 'clicked button', 'button was clicked', 'component clicked', 'page remains same', 'page stayed the same', 'no visible change', 'action performed', 'clicked'];
|
|
987
|
+
|
|
988
|
+
if (weakPhrases.some((phrase) => normalizedValue === phrase || normalizedValue.includes(phrase))) {
|
|
989
|
+
return fallbackInteractionResult(component, action);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (!/[.!?]$/.test(value)) return `${value}.`;
|
|
993
|
+
return value;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
export function formatExperienceTitle(interaction: InteractionResult): string {
|
|
997
|
+
const verb = normalizeHowToVerb(interaction.action);
|
|
998
|
+
const target = extractHowToTarget(interaction.component);
|
|
999
|
+
if (target) return truncate(`${verb} ${target}`, 90);
|
|
1000
|
+
if (interaction.component.trim()) return truncate(`${verb} ${interaction.component.trim().toLowerCase()}`, 90);
|
|
1001
|
+
return truncate(`${verb} component`, 90);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function fallbackInteractionResult(component: ComponentInfo, action: string): string {
|
|
1005
|
+
const role = component.role || component.tag;
|
|
1006
|
+
const label = component.text ? `"${truncate(component.text, 40)}"` : `the ${role}`;
|
|
1007
|
+
const variant = component.variant ? ` (${component.variant})` : '';
|
|
1008
|
+
const details = formatComponentDifferentiators(component);
|
|
1009
|
+
if (action === 'click') return `Clicked ${label}${variant}; differentiators: ${details}.`;
|
|
1010
|
+
if (action === 'pressKey') return `Pressed key on ${label}${variant}; differentiators: ${details}.`;
|
|
1011
|
+
if (action === 'form') return `Submitted interaction for ${label}${variant}; differentiators: ${details}.`;
|
|
1012
|
+
return `${capitalize(action)} executed for ${label}${variant}; differentiators: ${details}.`;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function normalizeHowToVerb(action: string): string {
|
|
1016
|
+
const normalizedAction = action.trim().toLowerCase();
|
|
1017
|
+
if (normalizedAction === 'click') return 'click';
|
|
1018
|
+
if (normalizedAction === 'presskey') return 'press key on';
|
|
1019
|
+
if (normalizedAction === 'form') return 'submit';
|
|
1020
|
+
if (normalizedAction === 'type') return 'type into';
|
|
1021
|
+
if (normalizedAction === 'select') return 'select';
|
|
1022
|
+
if (normalizedAction === 'open') return 'open';
|
|
1023
|
+
if (normalizedAction === 'toggle') return 'toggle';
|
|
1024
|
+
if (normalizedAction) return normalizedAction;
|
|
1025
|
+
return 'use';
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function extractHowToTarget(component: string): string {
|
|
1029
|
+
const roleMatch = component.match(/^([A-Za-z-]+)/);
|
|
1030
|
+
const quotedMatch = component.match(/"([^"]+)"/);
|
|
1031
|
+
const role = roleMatch?.[1]?.trim().toLowerCase() || '';
|
|
1032
|
+
const label = quotedMatch?.[1]?.trim().toLowerCase() || '';
|
|
1033
|
+
|
|
1034
|
+
if (label && role) return `${label} ${role}`;
|
|
1035
|
+
if (label) return label;
|
|
1036
|
+
if (role) return role;
|
|
1037
|
+
return component
|
|
1038
|
+
.replace(/\[[^\]]*\]/g, '')
|
|
1039
|
+
.replace(/\([^)]*\)/g, '')
|
|
1040
|
+
.replace(/\s+/g, ' ')
|
|
1041
|
+
.trim()
|
|
1042
|
+
.toLowerCase();
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function hasContainerLocator(code: string): boolean {
|
|
1046
|
+
for (const line of code
|
|
1047
|
+
.split('\n')
|
|
1048
|
+
.map((entry) => entry.trim())
|
|
1049
|
+
.filter(Boolean)) {
|
|
1050
|
+
const argCount = countTopLevelArgCount(line);
|
|
1051
|
+
if (line.startsWith('I.click(') && argCount >= 2) return true;
|
|
1052
|
+
if (line.startsWith('I.fillField(') && argCount >= 3) return true;
|
|
1053
|
+
if (line.startsWith('I.selectOption(') && argCount >= 3) return true;
|
|
1054
|
+
if (line.startsWith('I.attachFile(') && argCount >= 3) return true;
|
|
1055
|
+
if (line.startsWith('I.checkOption(') && argCount >= 2) return true;
|
|
1056
|
+
if (line.startsWith('I.uncheckOption(') && argCount >= 2) return true;
|
|
1057
|
+
}
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Lightweight scanner for a single JS call expression line.
|
|
1062
|
+
// It counts commas only at top level and ignores nested (), [], {}, and quoted strings.
|
|
1063
|
+
function countTopLevelArgCount(line: string): number {
|
|
1064
|
+
const start = line.indexOf('(');
|
|
1065
|
+
const end = line.lastIndexOf(')');
|
|
1066
|
+
if (start === -1 || end === -1 || end <= start + 1) return 0;
|
|
1067
|
+
|
|
1068
|
+
const body = line.slice(start + 1, end);
|
|
1069
|
+
let count = 1;
|
|
1070
|
+
let depth = 0;
|
|
1071
|
+
let quote = '';
|
|
1072
|
+
|
|
1073
|
+
for (let i = 0; i < body.length; i++) {
|
|
1074
|
+
const char = body[i];
|
|
1075
|
+
const escaped = body[i - 1] === '\\';
|
|
1076
|
+
|
|
1077
|
+
if (quote) {
|
|
1078
|
+
if (char === quote && !escaped) quote = '';
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
1083
|
+
quote = char;
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (char === '(' || char === '[' || char === '{') {
|
|
1088
|
+
depth++;
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (char === ')' || char === ']' || char === '}') {
|
|
1093
|
+
depth = Math.max(0, depth - 1);
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (char === ',' && depth === 0) count++;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
return count;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function buildClassSelector(tag: string, classes: string[]): string {
|
|
1104
|
+
const safeClasses = classes.filter((cls) => /^[a-z0-9_-]+$/i.test(cls)).slice(0, 5);
|
|
1105
|
+
if (safeClasses.length === 0) return '';
|
|
1106
|
+
return `${tag}${safeClasses.map((cls) => `.${cls}`).join('')}`;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function parseVariantHints(variant: string): Set<string> {
|
|
1110
|
+
return new Set(
|
|
1111
|
+
variant
|
|
1112
|
+
.split(',')
|
|
1113
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
1114
|
+
.filter(Boolean)
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function xpathLiteral(value: string): string {
|
|
1119
|
+
if (!value.includes('"')) return `"${value}"`;
|
|
1120
|
+
if (!value.includes("'")) return `'${value}'`;
|
|
1121
|
+
return `concat("${value.replace(/"/g, '", \'"\', "')}")`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function scoreComponentPriority(element: WebElement): number {
|
|
1125
|
+
let score = 0;
|
|
1126
|
+
const hints = element.areaHints;
|
|
1127
|
+
const text = normalized(element.text);
|
|
1128
|
+
const attrs = Object.values(element.attrs).join(' ').toLowerCase();
|
|
1129
|
+
const role = (element.role || element.attrs.role || element.tag).toLowerCase();
|
|
1130
|
+
|
|
1131
|
+
if (hints.includes('main')) score += 50;
|
|
1132
|
+
if (hints.includes('article')) score += 40;
|
|
1133
|
+
if (hints.includes('section')) score += 20;
|
|
1134
|
+
if (hints.some((hint) => hint.includes('content'))) score += 20;
|
|
1135
|
+
if (role === 'tab') score += 35;
|
|
1136
|
+
if (isSemanticFormControl(element)) score += 35;
|
|
1137
|
+
if (element.tag === 'button') score += 20;
|
|
1138
|
+
if (element.tag === 'input' || element.tag === 'textarea' || element.tag === 'select') score += 18;
|
|
1139
|
+
if (element.tag === 'a') score -= 40;
|
|
1140
|
+
if (text.length > 0) score += Math.min(text.length, 20);
|
|
1141
|
+
if (hints.includes('nav') || hints.includes('menu') || hints.includes('header') || hints.includes('footer') || hints.includes('aside')) score -= 90;
|
|
1142
|
+
if (hints.some((hint) => hint.startsWith('role:navigation') || hint.startsWith('role:menu') || hint.startsWith('role:menubar') || hint.startsWith('role:tablist'))) score -= 90;
|
|
1143
|
+
if (attrs.includes('sidebar') || attrs.includes('sidemenu') || attrs.includes('topnav') || attrs.includes('navbar') || attrs.includes('breadcrumb')) score -= 40;
|
|
1144
|
+
if (text === 'home' || text === 'settings' || text === 'profile' || text === 'logout') score -= 10;
|
|
1145
|
+
if (attrs.includes('tooltip') || attrs.includes('attacher') || attrs.includes('popover') || attrs.includes('dropdown')) score -= 20;
|
|
1146
|
+
return score;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function isDrillableElement(element: WebElement): boolean {
|
|
1150
|
+
const attrs = Object.values(element.attrs).join(' ').toLowerCase();
|
|
1151
|
+
const text = normalized(element.text);
|
|
1152
|
+
if (attrs.includes('tooltip') || attrs.includes('attacher')) return false;
|
|
1153
|
+
if (isNestedCompositeControl(element)) return false;
|
|
1154
|
+
if (text === '') {
|
|
1155
|
+
if (!isInteractiveElement(element)) return false;
|
|
1156
|
+
if (isSemanticFormControl(element)) return true;
|
|
1157
|
+
if (!element.variantHints.includes('icon-only') && !element.variantHints.includes('has-icon')) return false;
|
|
1158
|
+
}
|
|
1159
|
+
return true;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function isNestedCompositeControl(element: WebElement): boolean {
|
|
1163
|
+
const role = (element.role || element.attrs.role || element.tag).toLowerCase();
|
|
1164
|
+
if (HTML_COMPOSITE_TARGET_ROLES.has(role)) return false;
|
|
1165
|
+
if (!isInteractiveElement(element)) return false;
|
|
1166
|
+
return element.areaHints.some((hint) => HTML_COMPOSITE_AREA_HINTS.has(hint));
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function isSemanticFormControl(element: WebElement): boolean {
|
|
1170
|
+
const role = (element.role || element.attrs.role || element.tag).toLowerCase();
|
|
1171
|
+
if (HTML_FORM_CONTROL_TAGS.has(element.tag)) return true;
|
|
1172
|
+
return HTML_FORM_CONTROL_ROLES.has(role);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function isButtonLikeElement(element: WebElement): boolean {
|
|
1176
|
+
if (!isInteractiveElement(element)) return false;
|
|
1177
|
+
const role = (element.role || element.attrs.role || element.tag).toLowerCase();
|
|
1178
|
+
if (role === 'link' || element.tag === 'a') return false;
|
|
1179
|
+
return true;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function isInteractiveElement(element: WebElement): boolean {
|
|
1183
|
+
if (element.tag === 'button') return true;
|
|
1184
|
+
if (element.tag === 'a' && element.attrs.href) return true;
|
|
1185
|
+
if (HTML_FORM_CONTROL_TAGS.has(element.tag)) return true;
|
|
1186
|
+
const role = (element.role || element.attrs.role || element.tag).toLowerCase();
|
|
1187
|
+
if (HTML_INTERACTIVE_ROLES.has(role)) return true;
|
|
1188
|
+
if (element.attrs.contenteditable === 'true') return true;
|
|
1189
|
+
if (element.attrs.tabindex && Number(element.attrs.tabindex) >= 0) return true;
|
|
1190
|
+
if (element.attrs['aria-haspopup'] || element.attrs['aria-expanded'] || element.attrs['aria-controls']) return true;
|
|
1191
|
+
return false;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const drillLocatorRule = locatorRule.replace(/<context_simplification>[\s\S]*?<\/context_simplification>/, '').trim();
|