playwright-intelligence-engine 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +229 -229
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/core/version.d.ts +3 -3
- package/dist/index.js +81 -24
- package/package.json +76 -76
- package/src/authoring/author.ts +81 -81
- package/src/authoring/generation-contract.ts +283 -283
- package/src/authoring/hybrid-author.ts +115 -115
- package/src/authoring/index.ts +7 -7
- package/src/authoring/llm-author.ts +282 -282
- package/src/authoring/naming/name-plan.ts +28 -28
- package/src/authoring/naming/name-planner.ts +339 -339
- package/src/authoring/naming/naming-linter.ts +292 -292
- package/src/cli/index.ts +1238 -1172
- package/src/collectors/git-diff-collector.ts +190 -190
- package/src/core/version.ts +25 -25
- package/src/diagnostics/diagnostic-report.ts +204 -204
- package/src/diagnostics/failure-classifier.ts +701 -701
- package/src/evaluation/repository-runner.ts +197 -197
- package/src/gate/quality.ts +101 -101
- package/src/generator/architecture-generator.ts +582 -582
- package/src/generator/assertion-quality-gate.ts +88 -88
- package/src/generator/candidate-writer.ts +288 -288
- package/src/generator/test-generator.ts +52 -52
- package/src/index.ts +107 -107
- package/src/intelligence/evidence-bundle.ts +300 -300
- package/src/intelligence/generation-agent.ts +561 -561
- package/src/intelligence/generation-verifier.ts +1115 -1115
- package/src/intelligence/locator-disambiguation-engine.ts +331 -331
- package/src/intelligence/project-intelligence.ts +140 -140
- package/src/intelligence/repository-profile.ts +796 -796
- package/src/intelligence/trace-proposal-workflow.ts +318 -318
- package/src/intelligence/workflow-planner.ts +966 -966
- package/src/mcp/server.ts +970 -970
- package/src/perception/app-model.ts +32 -32
- package/src/perception/crawler.ts +263 -263
- package/src/perception/index.ts +3 -3
- package/src/repair/assertion-hasher.ts +386 -386
- package/src/repair/diff-allowlist.ts +612 -612
- package/src/repair/intent-validator.ts +761 -761
- package/src/repair/repair-planner.ts +467 -467
- package/src/repair/statement-patcher.ts +544 -544
- package/src/reproduce/interactive-workflow-runner.ts +828 -828
- package/src/sandbox/execution-runner.ts +408 -408
- package/src/selector/abstention-policy.ts +175 -175
- package/src/selector/candidate-extractor.ts +343 -343
- package/src/selector/repair-candidate-ranker.ts +92 -92
- package/src/selector/selector-scorer.ts +344 -344
|
@@ -1,339 +1,339 @@
|
|
|
1
|
-
import type { GeneratedStep } from '../../generator/test-generator.js';
|
|
2
|
-
import type { NamePlan, SymbolEntry } from './name-plan.js';
|
|
3
|
-
|
|
4
|
-
const RESERVED_WORDS = new Set([
|
|
5
|
-
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
|
|
6
|
-
'else', 'export', 'extends', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof',
|
|
7
|
-
'new', 'return', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while',
|
|
8
|
-
'with', 'yield', 'enum', 'implements', 'interface', 'let', 'package', 'private', 'protected',
|
|
9
|
-
'public', 'static', 'await', 'abstract', 'boolean', 'byte', 'char', 'double', 'final', 'float',
|
|
10
|
-
'goto', 'int', 'long', 'native', 'short', 'synchronized', 'throws', 'transient', 'volatile',
|
|
11
|
-
'page', 'constructor', 'click', 'fill', 'expect', 'locator', 'waitFor', 'reload', 'close',
|
|
12
|
-
]);
|
|
13
|
-
|
|
14
|
-
const ROLE_SUFFIXES: Record<string, string> = {
|
|
15
|
-
button: 'Button',
|
|
16
|
-
textbox: 'Input',
|
|
17
|
-
input: 'Input',
|
|
18
|
-
searchbox: 'SearchInput',
|
|
19
|
-
link: 'Link',
|
|
20
|
-
checkbox: 'Checkbox',
|
|
21
|
-
combobox: 'Select',
|
|
22
|
-
listbox: 'Select',
|
|
23
|
-
select: 'Select',
|
|
24
|
-
cell: 'Cell',
|
|
25
|
-
heading: 'Heading',
|
|
26
|
-
dialog: 'Dialog',
|
|
27
|
-
status: 'Status',
|
|
28
|
-
tab: 'Tab',
|
|
29
|
-
radio: 'Radio',
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const ARTICLES = new Set(['a', 'an', 'the']);
|
|
33
|
-
|
|
34
|
-
export function stripArticles(text: string): string {
|
|
35
|
-
const words = text.trim().split(/\s+/);
|
|
36
|
-
const filtered = words.filter((w) => !ARTICLES.has(w.toLowerCase()));
|
|
37
|
-
return filtered.length > 0 ? filtered.join(' ') : text.trim();
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function toPascalCase(text: string): string {
|
|
41
|
-
const words = text
|
|
42
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
43
|
-
.match(/[A-Za-z0-9]+/g);
|
|
44
|
-
if (!words || words.length === 0) return 'Element';
|
|
45
|
-
return words.map((w) => w[0]!.toUpperCase() + w.slice(1).toLowerCase()).join('');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function toCamelCase(text: string): string {
|
|
49
|
-
const pascal = toPascalCase(text);
|
|
50
|
-
if (!pascal) return 'element';
|
|
51
|
-
return pascal[0]!.toLowerCase() + pascal.slice(1);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function sanitizeIdentifier(identifier: string, suffixFallback = 'Element'): string {
|
|
55
|
-
let cleaned = identifier.replace(/[^a-zA-Z0-9_$]/g, '');
|
|
56
|
-
if (!cleaned || /^[0-9]/.test(cleaned)) {
|
|
57
|
-
cleaned = `${suffixFallback.toLowerCase()}${cleaned}`;
|
|
58
|
-
}
|
|
59
|
-
if (RESERVED_WORDS.has(cleaned.toLowerCase())) {
|
|
60
|
-
cleaned = `${cleaned}${suffixFallback}`;
|
|
61
|
-
}
|
|
62
|
-
return cleaned;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function extractPageName(featureOrTitle: string): { className: string; variable: string } {
|
|
66
|
-
const lower = featureOrTitle.toLowerCase();
|
|
67
|
-
let canonicalName: string | undefined;
|
|
68
|
-
|
|
69
|
-
if (/\b(login|log\s*in|sign\s*in|auth|authenticate)\b/i.test(lower)) {
|
|
70
|
-
canonicalName = 'LoginPage';
|
|
71
|
-
} else if (/\b(register|sign\s*up|registration|signup)\b/i.test(lower)) {
|
|
72
|
-
canonicalName = 'RegisterPage';
|
|
73
|
-
} else if (/\b(checkout|pay|payment)\b/i.test(lower)) {
|
|
74
|
-
canonicalName = 'CheckoutPage';
|
|
75
|
-
} else if (/\b(cart|basket)\b/i.test(lower)) {
|
|
76
|
-
canonicalName = 'CartPage';
|
|
77
|
-
} else if (/\b(dashboard|home)\b/i.test(lower)) {
|
|
78
|
-
canonicalName = 'DashboardPage';
|
|
79
|
-
} else if (/\b(profile|account|setting)\b/i.test(lower)) {
|
|
80
|
-
canonicalName = 'ProfilePage';
|
|
81
|
-
} else if (/\b(product|catalog|search)\b/i.test(lower)) {
|
|
82
|
-
canonicalName = 'ProductsPage';
|
|
83
|
-
} else if (/\b(admin)\b/i.test(lower)) {
|
|
84
|
-
canonicalName = 'AdminPage';
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (canonicalName) {
|
|
88
|
-
const variable = canonicalName[0]!.toLowerCase() + canonicalName.slice(1);
|
|
89
|
-
return { className: canonicalName, variable };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Strip common testing / scenario noise
|
|
93
|
-
let cleaned = featureOrTitle
|
|
94
|
-
.replace(/\b(should|complete|workflow|flow|spec|test|sanity|suite|journey|scenario)\b/gi, '')
|
|
95
|
-
.replace(/\b(with|for|and|using|valid|invalid|empty|wrong|credentials)\b/gi, '')
|
|
96
|
-
.replace(/\b(user|admin|customer|client)\b/gi, '')
|
|
97
|
-
.trim();
|
|
98
|
-
|
|
99
|
-
// If stripping left empty or too short, revert to original
|
|
100
|
-
if (!cleaned || cleaned.length < 3) {
|
|
101
|
-
cleaned = featureOrTitle;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const words = cleaned
|
|
105
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
106
|
-
.match(/[A-Za-z0-9]+/g) ?? ['Feature'];
|
|
107
|
-
// Keep meaningful domain words up to 3 words
|
|
108
|
-
const domainWords = words.slice(0, 3);
|
|
109
|
-
let baseName = domainWords.map((w) => w[0]!.toUpperCase() + w.slice(1).toLowerCase()).join('');
|
|
110
|
-
|
|
111
|
-
if (!baseName.endsWith('Page')) {
|
|
112
|
-
baseName = `${baseName}Page`;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const className = sanitizeIdentifier(baseName, 'Page');
|
|
116
|
-
const variable = className[0]!.toLowerCase() + className.slice(1);
|
|
117
|
-
return { className, variable };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function derivePropertyIdentifier(step: GeneratedStep, roleSuffix: string): { property: string; isFallback: boolean } {
|
|
121
|
-
const rawName = step.name ?? step.placeholder ?? step.selector;
|
|
122
|
-
if (rawName) {
|
|
123
|
-
const stripped = stripArticles(rawName);
|
|
124
|
-
const words = stripped
|
|
125
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
126
|
-
.match(/[A-Za-z0-9]+/g);
|
|
127
|
-
if (words && words.length > 0) {
|
|
128
|
-
const base = toCamelCase(stripped);
|
|
129
|
-
const lowerBase = base.toLowerCase();
|
|
130
|
-
const lowerSuffix = roleSuffix.toLowerCase();
|
|
131
|
-
let property = base;
|
|
132
|
-
if (!lowerBase.endsWith(lowerSuffix)) {
|
|
133
|
-
if (lowerSuffix === 'searchinput' && lowerBase.endsWith('search')) {
|
|
134
|
-
property = `${base}Input`;
|
|
135
|
-
} else {
|
|
136
|
-
property = `${base}${roleSuffix}`;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return { property: sanitizeIdentifier(property, roleSuffix), isFallback: false };
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Fallback when no semantic name exists
|
|
144
|
-
const role = step.role ?? (step.action === 'fill' ? 'textbox' : 'button');
|
|
145
|
-
const roleBase = toCamelCase(role);
|
|
146
|
-
let fallbackBase = roleBase;
|
|
147
|
-
if (roleBase.toLowerCase() === roleSuffix.toLowerCase()) {
|
|
148
|
-
fallbackBase = role === 'button' ? 'action' : 'input';
|
|
149
|
-
}
|
|
150
|
-
const fallback = sanitizeIdentifier(`${fallbackBase}${roleSuffix}`, roleSuffix);
|
|
151
|
-
return { property: fallback, isFallback: true };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export function deriveMethodIdentifier(step: GeneratedStep, propertyName: string): { method: string; verb: string } {
|
|
155
|
-
const rawName = step.name ?? step.placeholder ?? step.selector;
|
|
156
|
-
const stripped = rawName ? stripArticles(rawName) : '';
|
|
157
|
-
const pascalTarget = stripped ? toPascalCase(stripped) : toPascalCase(propertyName);
|
|
158
|
-
|
|
159
|
-
const formatWithVerb = (verb: string, target: string): string => {
|
|
160
|
-
const lowerVerb = verb.toLowerCase();
|
|
161
|
-
const lowerTarget = target.toLowerCase();
|
|
162
|
-
if (lowerTarget.startsWith(lowerVerb)) {
|
|
163
|
-
const remainder = target.slice(verb.length);
|
|
164
|
-
return sanitizeIdentifier(remainder.length > 0 ? `${verb}${remainder}` : verb, 'Action');
|
|
165
|
-
}
|
|
166
|
-
return sanitizeIdentifier(`${verb}${target}`, 'Action');
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
if (step.action === 'press') {
|
|
170
|
-
return {
|
|
171
|
-
method: formatWithVerb('press', pascalTarget || 'Key'),
|
|
172
|
-
verb: 'press',
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (step.action === 'fill') {
|
|
177
|
-
return {
|
|
178
|
-
method: formatWithVerb('fill', pascalTarget),
|
|
179
|
-
verb: 'fill',
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (step.action === 'click') {
|
|
184
|
-
const lower = (rawName ?? '').toLowerCase();
|
|
185
|
-
const roleLower = (step.role ?? '').toLowerCase();
|
|
186
|
-
if (roleLower === 'link' || roleLower === 'tab' || /\b(open|goto|go\s*to|home|back|nav|view|plans|settings)\b/.test(lower)) {
|
|
187
|
-
return { method: formatWithVerb('open', pascalTarget), verb: 'open' };
|
|
188
|
-
}
|
|
189
|
-
if (/\b(sign\s*in|log\s*in|login|signin)\b/.test(lower)) {
|
|
190
|
-
return { method: sanitizeIdentifier('signIn', 'Action'), verb: 'signIn' };
|
|
191
|
-
}
|
|
192
|
-
if (/\b(sign\s*up|register)\b/.test(lower)) {
|
|
193
|
-
return { method: sanitizeIdentifier('signUp', 'Action'), verb: 'signUp' };
|
|
194
|
-
}
|
|
195
|
-
if (/\b(submit|send|confirm|save|apply|checkout)\b/.test(lower)) {
|
|
196
|
-
return { method: formatWithVerb('submit', pascalTarget), verb: 'submit' };
|
|
197
|
-
}
|
|
198
|
-
return {
|
|
199
|
-
method: formatWithVerb('click', pascalTarget),
|
|
200
|
-
verb: 'click',
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (step.action === 'assert-visible') {
|
|
205
|
-
return {
|
|
206
|
-
method: sanitizeIdentifier(`expect${pascalTarget}Visible`, 'Action'),
|
|
207
|
-
verb: 'expectVisible',
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (step.action === 'assert-text') {
|
|
212
|
-
return {
|
|
213
|
-
method: sanitizeIdentifier(`expect${pascalTarget}Text`, 'Action'),
|
|
214
|
-
verb: 'expectText',
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (step.action === 'assert-count') {
|
|
219
|
-
return {
|
|
220
|
-
method: sanitizeIdentifier(`expect${pascalTarget}Count`, 'Action'),
|
|
221
|
-
verb: 'expectCount',
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (step.action === 'assert-value') {
|
|
226
|
-
return {
|
|
227
|
-
method: sanitizeIdentifier(`expect${pascalTarget}Value`, 'Action'),
|
|
228
|
-
verb: 'expectValue',
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return {
|
|
233
|
-
method: sanitizeIdentifier(`perform${pascalTarget}`, 'Action'),
|
|
234
|
-
verb: 'perform',
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
export class NamePlanner {
|
|
239
|
-
public static planNames(
|
|
240
|
-
featureOrTitle: string,
|
|
241
|
-
steps: GeneratedStep[],
|
|
242
|
-
options?: { existingPageClassName?: string; source?: 'llm' | 'heuristic' }
|
|
243
|
-
): NamePlan {
|
|
244
|
-
const assumptions: string[] = [];
|
|
245
|
-
const source = options?.source ?? 'heuristic';
|
|
246
|
-
const page = options?.existingPageClassName
|
|
247
|
-
? { className: options.existingPageClassName, variable: options.existingPageClassName[0]!.toLowerCase() + options.existingPageClassName.slice(1) }
|
|
248
|
-
: extractPageName(featureOrTitle);
|
|
249
|
-
|
|
250
|
-
const symbols: SymbolEntry[] = [];
|
|
251
|
-
const usedProperties = new Set<string>();
|
|
252
|
-
const usedMethods = new Set<string>();
|
|
253
|
-
|
|
254
|
-
const disambiguationQualifiers = ['primary', 'secondary', 'confirm', 'alternate', 'detail', 'modal', 'auxiliary'];
|
|
255
|
-
|
|
256
|
-
steps.forEach((step, idx) => {
|
|
257
|
-
const ref = `step-${idx + 1}`;
|
|
258
|
-
// Grounding rule: a step with no accessible name AND no selector has no
|
|
259
|
-
// DOM identity. Never invent a symbol for it (a navigate step must not
|
|
260
|
-
// silently become a synthetic "button").
|
|
261
|
-
if (!step.name && !step.selector) {
|
|
262
|
-
if (step.action !== 'navigate') {
|
|
263
|
-
assumptions.push(`Step ${ref} (${step.action}) has no accessible name or selector and was skipped as ungrounded.`);
|
|
264
|
-
}
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
const role = step.role ?? (step.action === 'fill' ? 'textbox' : step.action === 'click' ? 'button' : undefined);
|
|
268
|
-
const roleSuffix = ROLE_SUFFIXES[role?.toLowerCase() ?? ''] ?? (step.action === 'fill' ? 'Input' : 'Button');
|
|
269
|
-
|
|
270
|
-
const propResult = derivePropertyIdentifier(step, roleSuffix);
|
|
271
|
-
let property = propResult.property;
|
|
272
|
-
|
|
273
|
-
if (propResult.isFallback) {
|
|
274
|
-
assumptions.push(`Step ${ref} has no accessible name; generated generic semantic property '${property}'`);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Disambiguate property collisions using semantic qualifiers, NEVER by bare index numbers
|
|
278
|
-
if (usedProperties.has(property)) {
|
|
279
|
-
let disambiguated = false;
|
|
280
|
-
for (const qualifier of disambiguationQualifiers) {
|
|
281
|
-
const candidate = `${qualifier}${property[0]!.toUpperCase()}${property.slice(1)}`;
|
|
282
|
-
if (!usedProperties.has(candidate)) {
|
|
283
|
-
assumptions.push(`Property name collision for '${property}'; disambiguated to '${candidate}'`);
|
|
284
|
-
property = candidate;
|
|
285
|
-
disambiguated = true;
|
|
286
|
-
break;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
if (!disambiguated) {
|
|
290
|
-
property = `${property}Alt`;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
usedProperties.add(property);
|
|
294
|
-
|
|
295
|
-
const methodResult = deriveMethodIdentifier(step, property);
|
|
296
|
-
let method = methodResult.method;
|
|
297
|
-
|
|
298
|
-
// Disambiguate method collisions
|
|
299
|
-
if (usedMethods.has(method)) {
|
|
300
|
-
let disambiguated = false;
|
|
301
|
-
for (const qualifier of disambiguationQualifiers) {
|
|
302
|
-
const candidate = `${methodResult.verb}${qualifier[0]!.toUpperCase()}${qualifier.slice(1)}`;
|
|
303
|
-
if (!usedMethods.has(candidate)) {
|
|
304
|
-
assumptions.push(`Method name collision for '${method}'; disambiguated to '${candidate}'`);
|
|
305
|
-
method = candidate;
|
|
306
|
-
disambiguated = true;
|
|
307
|
-
break;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
if (!disambiguated) {
|
|
311
|
-
method = `${method}Alt`;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
usedMethods.add(method);
|
|
315
|
-
|
|
316
|
-
const frame = step.frame ?? step.frameSelector;
|
|
317
|
-
symbols.push({
|
|
318
|
-
ref,
|
|
319
|
-
role: step.role,
|
|
320
|
-
accessibleName: step.name,
|
|
321
|
-
property,
|
|
322
|
-
method,
|
|
323
|
-
verb: methodResult.verb,
|
|
324
|
-
confidence: propResult.isFallback ? 0.75 : 0.95,
|
|
325
|
-
source,
|
|
326
|
-
assumptions: [],
|
|
327
|
-
...(frame ? { frame } : {}),
|
|
328
|
-
});
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
return {
|
|
332
|
-
planVersion: '1',
|
|
333
|
-
pageClassName: page.className,
|
|
334
|
-
pageVariable: page.variable,
|
|
335
|
-
symbols,
|
|
336
|
-
assumptions,
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
}
|
|
1
|
+
import type { GeneratedStep } from '../../generator/test-generator.js';
|
|
2
|
+
import type { NamePlan, SymbolEntry } from './name-plan.js';
|
|
3
|
+
|
|
4
|
+
const RESERVED_WORDS = new Set([
|
|
5
|
+
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
|
|
6
|
+
'else', 'export', 'extends', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof',
|
|
7
|
+
'new', 'return', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while',
|
|
8
|
+
'with', 'yield', 'enum', 'implements', 'interface', 'let', 'package', 'private', 'protected',
|
|
9
|
+
'public', 'static', 'await', 'abstract', 'boolean', 'byte', 'char', 'double', 'final', 'float',
|
|
10
|
+
'goto', 'int', 'long', 'native', 'short', 'synchronized', 'throws', 'transient', 'volatile',
|
|
11
|
+
'page', 'constructor', 'click', 'fill', 'expect', 'locator', 'waitFor', 'reload', 'close',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const ROLE_SUFFIXES: Record<string, string> = {
|
|
15
|
+
button: 'Button',
|
|
16
|
+
textbox: 'Input',
|
|
17
|
+
input: 'Input',
|
|
18
|
+
searchbox: 'SearchInput',
|
|
19
|
+
link: 'Link',
|
|
20
|
+
checkbox: 'Checkbox',
|
|
21
|
+
combobox: 'Select',
|
|
22
|
+
listbox: 'Select',
|
|
23
|
+
select: 'Select',
|
|
24
|
+
cell: 'Cell',
|
|
25
|
+
heading: 'Heading',
|
|
26
|
+
dialog: 'Dialog',
|
|
27
|
+
status: 'Status',
|
|
28
|
+
tab: 'Tab',
|
|
29
|
+
radio: 'Radio',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ARTICLES = new Set(['a', 'an', 'the']);
|
|
33
|
+
|
|
34
|
+
export function stripArticles(text: string): string {
|
|
35
|
+
const words = text.trim().split(/\s+/);
|
|
36
|
+
const filtered = words.filter((w) => !ARTICLES.has(w.toLowerCase()));
|
|
37
|
+
return filtered.length > 0 ? filtered.join(' ') : text.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function toPascalCase(text: string): string {
|
|
41
|
+
const words = text
|
|
42
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
43
|
+
.match(/[A-Za-z0-9]+/g);
|
|
44
|
+
if (!words || words.length === 0) return 'Element';
|
|
45
|
+
return words.map((w) => w[0]!.toUpperCase() + w.slice(1).toLowerCase()).join('');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function toCamelCase(text: string): string {
|
|
49
|
+
const pascal = toPascalCase(text);
|
|
50
|
+
if (!pascal) return 'element';
|
|
51
|
+
return pascal[0]!.toLowerCase() + pascal.slice(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function sanitizeIdentifier(identifier: string, suffixFallback = 'Element'): string {
|
|
55
|
+
let cleaned = identifier.replace(/[^a-zA-Z0-9_$]/g, '');
|
|
56
|
+
if (!cleaned || /^[0-9]/.test(cleaned)) {
|
|
57
|
+
cleaned = `${suffixFallback.toLowerCase()}${cleaned}`;
|
|
58
|
+
}
|
|
59
|
+
if (RESERVED_WORDS.has(cleaned.toLowerCase())) {
|
|
60
|
+
cleaned = `${cleaned}${suffixFallback}`;
|
|
61
|
+
}
|
|
62
|
+
return cleaned;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function extractPageName(featureOrTitle: string): { className: string; variable: string } {
|
|
66
|
+
const lower = featureOrTitle.toLowerCase();
|
|
67
|
+
let canonicalName: string | undefined;
|
|
68
|
+
|
|
69
|
+
if (/\b(login|log\s*in|sign\s*in|auth|authenticate)\b/i.test(lower)) {
|
|
70
|
+
canonicalName = 'LoginPage';
|
|
71
|
+
} else if (/\b(register|sign\s*up|registration|signup)\b/i.test(lower)) {
|
|
72
|
+
canonicalName = 'RegisterPage';
|
|
73
|
+
} else if (/\b(checkout|pay|payment)\b/i.test(lower)) {
|
|
74
|
+
canonicalName = 'CheckoutPage';
|
|
75
|
+
} else if (/\b(cart|basket)\b/i.test(lower)) {
|
|
76
|
+
canonicalName = 'CartPage';
|
|
77
|
+
} else if (/\b(dashboard|home)\b/i.test(lower)) {
|
|
78
|
+
canonicalName = 'DashboardPage';
|
|
79
|
+
} else if (/\b(profile|account|setting)\b/i.test(lower)) {
|
|
80
|
+
canonicalName = 'ProfilePage';
|
|
81
|
+
} else if (/\b(product|catalog|search)\b/i.test(lower)) {
|
|
82
|
+
canonicalName = 'ProductsPage';
|
|
83
|
+
} else if (/\b(admin)\b/i.test(lower)) {
|
|
84
|
+
canonicalName = 'AdminPage';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (canonicalName) {
|
|
88
|
+
const variable = canonicalName[0]!.toLowerCase() + canonicalName.slice(1);
|
|
89
|
+
return { className: canonicalName, variable };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Strip common testing / scenario noise
|
|
93
|
+
let cleaned = featureOrTitle
|
|
94
|
+
.replace(/\b(should|complete|workflow|flow|spec|test|sanity|suite|journey|scenario)\b/gi, '')
|
|
95
|
+
.replace(/\b(with|for|and|using|valid|invalid|empty|wrong|credentials)\b/gi, '')
|
|
96
|
+
.replace(/\b(user|admin|customer|client)\b/gi, '')
|
|
97
|
+
.trim();
|
|
98
|
+
|
|
99
|
+
// If stripping left empty or too short, revert to original
|
|
100
|
+
if (!cleaned || cleaned.length < 3) {
|
|
101
|
+
cleaned = featureOrTitle;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const words = cleaned
|
|
105
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
106
|
+
.match(/[A-Za-z0-9]+/g) ?? ['Feature'];
|
|
107
|
+
// Keep meaningful domain words up to 3 words
|
|
108
|
+
const domainWords = words.slice(0, 3);
|
|
109
|
+
let baseName = domainWords.map((w) => w[0]!.toUpperCase() + w.slice(1).toLowerCase()).join('');
|
|
110
|
+
|
|
111
|
+
if (!baseName.endsWith('Page')) {
|
|
112
|
+
baseName = `${baseName}Page`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const className = sanitizeIdentifier(baseName, 'Page');
|
|
116
|
+
const variable = className[0]!.toLowerCase() + className.slice(1);
|
|
117
|
+
return { className, variable };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function derivePropertyIdentifier(step: GeneratedStep, roleSuffix: string): { property: string; isFallback: boolean } {
|
|
121
|
+
const rawName = step.name ?? step.placeholder ?? step.selector;
|
|
122
|
+
if (rawName) {
|
|
123
|
+
const stripped = stripArticles(rawName);
|
|
124
|
+
const words = stripped
|
|
125
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
126
|
+
.match(/[A-Za-z0-9]+/g);
|
|
127
|
+
if (words && words.length > 0) {
|
|
128
|
+
const base = toCamelCase(stripped);
|
|
129
|
+
const lowerBase = base.toLowerCase();
|
|
130
|
+
const lowerSuffix = roleSuffix.toLowerCase();
|
|
131
|
+
let property = base;
|
|
132
|
+
if (!lowerBase.endsWith(lowerSuffix)) {
|
|
133
|
+
if (lowerSuffix === 'searchinput' && lowerBase.endsWith('search')) {
|
|
134
|
+
property = `${base}Input`;
|
|
135
|
+
} else {
|
|
136
|
+
property = `${base}${roleSuffix}`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { property: sanitizeIdentifier(property, roleSuffix), isFallback: false };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Fallback when no semantic name exists
|
|
144
|
+
const role = step.role ?? (step.action === 'fill' ? 'textbox' : 'button');
|
|
145
|
+
const roleBase = toCamelCase(role);
|
|
146
|
+
let fallbackBase = roleBase;
|
|
147
|
+
if (roleBase.toLowerCase() === roleSuffix.toLowerCase()) {
|
|
148
|
+
fallbackBase = role === 'button' ? 'action' : 'input';
|
|
149
|
+
}
|
|
150
|
+
const fallback = sanitizeIdentifier(`${fallbackBase}${roleSuffix}`, roleSuffix);
|
|
151
|
+
return { property: fallback, isFallback: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function deriveMethodIdentifier(step: GeneratedStep, propertyName: string): { method: string; verb: string } {
|
|
155
|
+
const rawName = step.name ?? step.placeholder ?? step.selector;
|
|
156
|
+
const stripped = rawName ? stripArticles(rawName) : '';
|
|
157
|
+
const pascalTarget = stripped ? toPascalCase(stripped) : toPascalCase(propertyName);
|
|
158
|
+
|
|
159
|
+
const formatWithVerb = (verb: string, target: string): string => {
|
|
160
|
+
const lowerVerb = verb.toLowerCase();
|
|
161
|
+
const lowerTarget = target.toLowerCase();
|
|
162
|
+
if (lowerTarget.startsWith(lowerVerb)) {
|
|
163
|
+
const remainder = target.slice(verb.length);
|
|
164
|
+
return sanitizeIdentifier(remainder.length > 0 ? `${verb}${remainder}` : verb, 'Action');
|
|
165
|
+
}
|
|
166
|
+
return sanitizeIdentifier(`${verb}${target}`, 'Action');
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (step.action === 'press') {
|
|
170
|
+
return {
|
|
171
|
+
method: formatWithVerb('press', pascalTarget || 'Key'),
|
|
172
|
+
verb: 'press',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (step.action === 'fill') {
|
|
177
|
+
return {
|
|
178
|
+
method: formatWithVerb('fill', pascalTarget),
|
|
179
|
+
verb: 'fill',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (step.action === 'click') {
|
|
184
|
+
const lower = (rawName ?? '').toLowerCase();
|
|
185
|
+
const roleLower = (step.role ?? '').toLowerCase();
|
|
186
|
+
if (roleLower === 'link' || roleLower === 'tab' || /\b(open|goto|go\s*to|home|back|nav|view|plans|settings)\b/.test(lower)) {
|
|
187
|
+
return { method: formatWithVerb('open', pascalTarget), verb: 'open' };
|
|
188
|
+
}
|
|
189
|
+
if (/\b(sign\s*in|log\s*in|login|signin)\b/.test(lower)) {
|
|
190
|
+
return { method: sanitizeIdentifier('signIn', 'Action'), verb: 'signIn' };
|
|
191
|
+
}
|
|
192
|
+
if (/\b(sign\s*up|register)\b/.test(lower)) {
|
|
193
|
+
return { method: sanitizeIdentifier('signUp', 'Action'), verb: 'signUp' };
|
|
194
|
+
}
|
|
195
|
+
if (/\b(submit|send|confirm|save|apply|checkout)\b/.test(lower)) {
|
|
196
|
+
return { method: formatWithVerb('submit', pascalTarget), verb: 'submit' };
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
method: formatWithVerb('click', pascalTarget),
|
|
200
|
+
verb: 'click',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (step.action === 'assert-visible') {
|
|
205
|
+
return {
|
|
206
|
+
method: sanitizeIdentifier(`expect${pascalTarget}Visible`, 'Action'),
|
|
207
|
+
verb: 'expectVisible',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (step.action === 'assert-text') {
|
|
212
|
+
return {
|
|
213
|
+
method: sanitizeIdentifier(`expect${pascalTarget}Text`, 'Action'),
|
|
214
|
+
verb: 'expectText',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (step.action === 'assert-count') {
|
|
219
|
+
return {
|
|
220
|
+
method: sanitizeIdentifier(`expect${pascalTarget}Count`, 'Action'),
|
|
221
|
+
verb: 'expectCount',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (step.action === 'assert-value') {
|
|
226
|
+
return {
|
|
227
|
+
method: sanitizeIdentifier(`expect${pascalTarget}Value`, 'Action'),
|
|
228
|
+
verb: 'expectValue',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
method: sanitizeIdentifier(`perform${pascalTarget}`, 'Action'),
|
|
234
|
+
verb: 'perform',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export class NamePlanner {
|
|
239
|
+
public static planNames(
|
|
240
|
+
featureOrTitle: string,
|
|
241
|
+
steps: GeneratedStep[],
|
|
242
|
+
options?: { existingPageClassName?: string; source?: 'llm' | 'heuristic' }
|
|
243
|
+
): NamePlan {
|
|
244
|
+
const assumptions: string[] = [];
|
|
245
|
+
const source = options?.source ?? 'heuristic';
|
|
246
|
+
const page = options?.existingPageClassName
|
|
247
|
+
? { className: options.existingPageClassName, variable: options.existingPageClassName[0]!.toLowerCase() + options.existingPageClassName.slice(1) }
|
|
248
|
+
: extractPageName(featureOrTitle);
|
|
249
|
+
|
|
250
|
+
const symbols: SymbolEntry[] = [];
|
|
251
|
+
const usedProperties = new Set<string>();
|
|
252
|
+
const usedMethods = new Set<string>();
|
|
253
|
+
|
|
254
|
+
const disambiguationQualifiers = ['primary', 'secondary', 'confirm', 'alternate', 'detail', 'modal', 'auxiliary'];
|
|
255
|
+
|
|
256
|
+
steps.forEach((step, idx) => {
|
|
257
|
+
const ref = `step-${idx + 1}`;
|
|
258
|
+
// Grounding rule: a step with no accessible name AND no selector has no
|
|
259
|
+
// DOM identity. Never invent a symbol for it (a navigate step must not
|
|
260
|
+
// silently become a synthetic "button").
|
|
261
|
+
if (!step.name && !step.selector) {
|
|
262
|
+
if (step.action !== 'navigate') {
|
|
263
|
+
assumptions.push(`Step ${ref} (${step.action}) has no accessible name or selector and was skipped as ungrounded.`);
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const role = step.role ?? (step.action === 'fill' ? 'textbox' : step.action === 'click' ? 'button' : undefined);
|
|
268
|
+
const roleSuffix = ROLE_SUFFIXES[role?.toLowerCase() ?? ''] ?? (step.action === 'fill' ? 'Input' : 'Button');
|
|
269
|
+
|
|
270
|
+
const propResult = derivePropertyIdentifier(step, roleSuffix);
|
|
271
|
+
let property = propResult.property;
|
|
272
|
+
|
|
273
|
+
if (propResult.isFallback) {
|
|
274
|
+
assumptions.push(`Step ${ref} has no accessible name; generated generic semantic property '${property}'`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Disambiguate property collisions using semantic qualifiers, NEVER by bare index numbers
|
|
278
|
+
if (usedProperties.has(property)) {
|
|
279
|
+
let disambiguated = false;
|
|
280
|
+
for (const qualifier of disambiguationQualifiers) {
|
|
281
|
+
const candidate = `${qualifier}${property[0]!.toUpperCase()}${property.slice(1)}`;
|
|
282
|
+
if (!usedProperties.has(candidate)) {
|
|
283
|
+
assumptions.push(`Property name collision for '${property}'; disambiguated to '${candidate}'`);
|
|
284
|
+
property = candidate;
|
|
285
|
+
disambiguated = true;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (!disambiguated) {
|
|
290
|
+
property = `${property}Alt`;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
usedProperties.add(property);
|
|
294
|
+
|
|
295
|
+
const methodResult = deriveMethodIdentifier(step, property);
|
|
296
|
+
let method = methodResult.method;
|
|
297
|
+
|
|
298
|
+
// Disambiguate method collisions
|
|
299
|
+
if (usedMethods.has(method)) {
|
|
300
|
+
let disambiguated = false;
|
|
301
|
+
for (const qualifier of disambiguationQualifiers) {
|
|
302
|
+
const candidate = `${methodResult.verb}${qualifier[0]!.toUpperCase()}${qualifier.slice(1)}`;
|
|
303
|
+
if (!usedMethods.has(candidate)) {
|
|
304
|
+
assumptions.push(`Method name collision for '${method}'; disambiguated to '${candidate}'`);
|
|
305
|
+
method = candidate;
|
|
306
|
+
disambiguated = true;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (!disambiguated) {
|
|
311
|
+
method = `${method}Alt`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
usedMethods.add(method);
|
|
315
|
+
|
|
316
|
+
const frame = step.frame ?? step.frameSelector;
|
|
317
|
+
symbols.push({
|
|
318
|
+
ref,
|
|
319
|
+
role: step.role,
|
|
320
|
+
accessibleName: step.name,
|
|
321
|
+
property,
|
|
322
|
+
method,
|
|
323
|
+
verb: methodResult.verb,
|
|
324
|
+
confidence: propResult.isFallback ? 0.75 : 0.95,
|
|
325
|
+
source,
|
|
326
|
+
assumptions: [],
|
|
327
|
+
...(frame ? { frame } : {}),
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
planVersion: '1',
|
|
333
|
+
pageClassName: page.className,
|
|
334
|
+
pageVariable: page.variable,
|
|
335
|
+
symbols,
|
|
336
|
+
assumptions,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|