@sun-asterisk/sungen 1.0.22 → 1.0.24
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/dist/cli/index.js +1 -1
- package/dist/core/live-scanner/element-finder.d.ts +2 -2
- package/dist/core/live-scanner/element-finder.d.ts.map +1 -1
- package/dist/core/live-scanner/element-finder.js +192 -116
- package/dist/core/live-scanner/element-finder.js.map +1 -1
- package/dist/core/live-scanner/matrix-reader.d.ts.map +1 -1
- package/dist/core/live-scanner/matrix-reader.js +1 -0
- package/dist/core/live-scanner/matrix-reader.js.map +1 -1
- package/dist/core/live-scanner/matrix-writer.d.ts.map +1 -1
- package/dist/core/live-scanner/matrix-writer.js +1 -0
- package/dist/core/live-scanner/matrix-writer.js.map +1 -1
- package/dist/core/live-scanner/role-fallback.d.ts.map +1 -1
- package/dist/core/live-scanner/role-fallback.js +1 -0
- package/dist/core/live-scanner/role-fallback.js.map +1 -1
- package/dist/core/live-scanner/scanner.d.ts +9 -0
- package/dist/core/live-scanner/scanner.d.ts.map +1 -1
- package/dist/core/live-scanner/scanner.js +68 -8
- package/dist/core/live-scanner/scanner.js.map +1 -1
- package/dist/core/live-scanner/step-replayer.d.ts +1 -1
- package/dist/core/live-scanner/step-replayer.d.ts.map +1 -1
- package/dist/core/live-scanner/step-replayer.js +194 -6
- package/dist/core/live-scanner/step-replayer.js.map +1 -1
- package/dist/core/live-scanner/types.d.ts +3 -2
- package/dist/core/live-scanner/types.d.ts.map +1 -1
- package/dist/generators/scaffold-generator/index.d.ts +1 -0
- package/dist/generators/scaffold-generator/index.d.ts.map +1 -1
- package/dist/generators/scaffold-generator/index.js +37 -9
- package/dist/generators/scaffold-generator/index.js.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/fill-editor-action.hbs +3 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/label-value-assertion.hbs +2 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/locator.hbs +1 -1
- package/dist/generators/test-generator/patterns/assertion-patterns.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/assertion-patterns.js +31 -8
- package/dist/generators/test-generator/patterns/assertion-patterns.js.map +1 -1
- package/dist/generators/test-generator/patterns/form-patterns.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/form-patterns.js +8 -2
- package/dist/generators/test-generator/patterns/form-patterns.js.map +1 -1
- package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.js +4 -2
- package/dist/generators/test-generator/step-mapper.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts +1 -0
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +21 -17
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/index.ts +1 -1
- package/src/core/live-scanner/element-finder.ts +198 -118
- package/src/core/live-scanner/matrix-reader.ts +1 -0
- package/src/core/live-scanner/matrix-writer.ts +1 -0
- package/src/core/live-scanner/role-fallback.ts +1 -0
- package/src/core/live-scanner/scanner.ts +76 -9
- package/src/core/live-scanner/step-replayer.ts +204 -6
- package/src/core/live-scanner/types.ts +3 -2
- package/src/generators/scaffold-generator/index.ts +39 -9
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/fill-editor-action.hbs +3 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/label-value-assertion.hbs +2 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/locator.hbs +1 -1
- package/src/generators/test-generator/patterns/assertion-patterns.ts +37 -8
- package/src/generators/test-generator/patterns/form-patterns.ts +9 -2
- package/src/generators/test-generator/step-mapper.ts +4 -2
- package/src/generators/test-generator/utils/selector-resolver.ts +25 -17
|
@@ -23,6 +23,19 @@ interface ReplayResult {
|
|
|
23
23
|
errors: string[];
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a {{dataRef}} path against test-data object.
|
|
28
|
+
* Supports dot-notation: "email.valid" → testData.email.valid
|
|
29
|
+
*/
|
|
30
|
+
function resolveDataRef(testData: Record<string, any>, dataRef: string): string {
|
|
31
|
+
const parts = dataRef.replace(/[{}]/g, '').split('.');
|
|
32
|
+
let value: any = testData;
|
|
33
|
+
for (const part of parts) {
|
|
34
|
+
value = value?.[part];
|
|
35
|
+
}
|
|
36
|
+
return typeof value === 'string' ? value : String(value ?? '');
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
/**
|
|
27
40
|
* Replay a sequence of Gherkin steps on a Playwright page,
|
|
28
41
|
* finding and interacting with elements along the way.
|
|
@@ -32,7 +45,8 @@ export async function replaySteps(
|
|
|
32
45
|
steps: ParsedStepInfo[],
|
|
33
46
|
featurePath: string,
|
|
34
47
|
baseUrl: string,
|
|
35
|
-
existingElements?: Record<string, LiveElement
|
|
48
|
+
existingElements?: Record<string, LiveElement>,
|
|
49
|
+
testData?: Record<string, any> | null
|
|
36
50
|
): Promise<ReplayResult> {
|
|
37
51
|
const elements: Record<string, LiveElement> = {};
|
|
38
52
|
const errors: string[] = [];
|
|
@@ -80,6 +94,7 @@ export async function replaySteps(
|
|
|
80
94
|
name: step.selectorRef,
|
|
81
95
|
testid: null,
|
|
82
96
|
tag: '',
|
|
97
|
+
contenteditable: false,
|
|
83
98
|
placeholder: null,
|
|
84
99
|
label: null,
|
|
85
100
|
context: 'page',
|
|
@@ -112,7 +127,7 @@ export async function replaySteps(
|
|
|
112
127
|
(elements[key] as any).skipped = true;
|
|
113
128
|
console.log(` ⏩ [${step.selectorRef}] ${elementType} → cached (${existingElements[key].selectorType})`);
|
|
114
129
|
|
|
115
|
-
// Still execute
|
|
130
|
+
// Still execute actions for cached elements to maintain page state
|
|
116
131
|
if (action === 'click') {
|
|
117
132
|
try {
|
|
118
133
|
await executeClick(page, step.selectorRef, elementType, nth);
|
|
@@ -121,12 +136,32 @@ export async function replaySteps(
|
|
|
121
136
|
} catch {
|
|
122
137
|
// Click failed for cached element, continue anyway
|
|
123
138
|
}
|
|
139
|
+
} else if (action === 'fill' && testData && step.dataRef) {
|
|
140
|
+
try {
|
|
141
|
+
const value = resolveDataRef(testData, step.dataRef);
|
|
142
|
+
if (value) {
|
|
143
|
+
await executeFill(page, step.selectorRef, elementType, nth, value);
|
|
144
|
+
await settle(page);
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Fill failed for cached element, continue anyway
|
|
148
|
+
}
|
|
149
|
+
} else if (action === 'select' && testData && step.dataRef) {
|
|
150
|
+
try {
|
|
151
|
+
const value = resolveDataRef(testData, step.dataRef);
|
|
152
|
+
if (value) {
|
|
153
|
+
await executeSelect(page, step.selectorRef, elementType, nth, value);
|
|
154
|
+
await settle(page);
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// Select failed for cached element, continue anyway
|
|
158
|
+
}
|
|
124
159
|
}
|
|
125
160
|
continue;
|
|
126
161
|
}
|
|
127
162
|
|
|
128
|
-
// Find the element on the page
|
|
129
|
-
const element = await findElement(page, step.selectorRef, elementType, nth);
|
|
163
|
+
// Find the element on the page, scoped to current context (dialog/page)
|
|
164
|
+
const element = await findElement(page, step.selectorRef, elementType, nth, currentContext);
|
|
130
165
|
element.context = currentContext;
|
|
131
166
|
|
|
132
167
|
// Record in matrix (first match wins for duplicate keys)
|
|
@@ -141,11 +176,75 @@ export async function replaySteps(
|
|
|
141
176
|
await settle(page);
|
|
142
177
|
currentContext = await detectCurrentContext(page, currentContext, step.text);
|
|
143
178
|
} else if (action === 'fill') {
|
|
144
|
-
|
|
145
|
-
|
|
179
|
+
if (testData && step.dataRef) {
|
|
180
|
+
const value = resolveDataRef(testData, step.dataRef);
|
|
181
|
+
if (value) {
|
|
182
|
+
try {
|
|
183
|
+
await executeFill(page, step.selectorRef, elementType, nth, value);
|
|
184
|
+
await settle(page);
|
|
185
|
+
} catch (e: any) {
|
|
186
|
+
errors.push(`Step "${step.text}": fill failed — ${e.message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else if (action === 'select') {
|
|
191
|
+
if (testData && step.dataRef) {
|
|
192
|
+
const value = resolveDataRef(testData, step.dataRef);
|
|
193
|
+
if (value) {
|
|
194
|
+
try {
|
|
195
|
+
await executeSelect(page, step.selectorRef, elementType, nth, value);
|
|
196
|
+
await settle(page);
|
|
197
|
+
} catch (e: any) {
|
|
198
|
+
errors.push(`Step "${step.text}": select failed — ${e.message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Label-value detection: check if the data value appears in a sibling element
|
|
204
|
+
if (action === 'see' && normalizedType === 'text' && (elementType === 'label' || elementType === 'title' || elementType === 'caption') && testData && step.dataRef) {
|
|
205
|
+
const dataValue = resolveDataRef(testData, step.dataRef);
|
|
206
|
+
if (dataValue) {
|
|
207
|
+
try {
|
|
208
|
+
// Check if the parent container has both the label text and the data value
|
|
209
|
+
const hasLabelValue = await page.getByText(new RegExp(escapeRegex(element.gherkinRef) + '.*' + escapeRegex(dataValue))).first().isVisible({ timeout: 2000 });
|
|
210
|
+
if (hasLabelValue) {
|
|
211
|
+
element.matchMethod = 'label-value';
|
|
212
|
+
elements[key] = element;
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// Regex match failed, keep original matchMethod
|
|
216
|
+
}
|
|
217
|
+
}
|
|
146
218
|
}
|
|
147
219
|
// 'see' and 'wait' actions: just record, don't interact
|
|
148
220
|
} else {
|
|
221
|
+
// Try data-value fallback for click actions: use the resolved data value as element name
|
|
222
|
+
if (action === 'click' && testData && step.dataRef) {
|
|
223
|
+
const dataValue = resolveDataRef(testData, step.dataRef);
|
|
224
|
+
if (dataValue) {
|
|
225
|
+
// Brief wait for dynamic content (e.g. autocomplete dropdown after fill)
|
|
226
|
+
await page.waitForTimeout(1000);
|
|
227
|
+
const fallback = await findElement(page, dataValue, elementType, nth, currentContext);
|
|
228
|
+
if (fallback.matchMethod !== 'unresolved') {
|
|
229
|
+
// Preserve original gherkinRef; clear locator values since they come
|
|
230
|
+
// from test-data ({{dataRef}}) at runtime, not hardcoded from DOM.
|
|
231
|
+
// Store dataValue for logging in scanner.ts
|
|
232
|
+
fallback.gherkinRef = step.selectorRef;
|
|
233
|
+
(fallback as any).dataValue = dataValue;
|
|
234
|
+
fallback.name = '';
|
|
235
|
+
fallback.exact = false;
|
|
236
|
+
elements[key] = fallback;
|
|
237
|
+
try {
|
|
238
|
+
await executeClick(page, dataValue, elementType, nth);
|
|
239
|
+
await settle(page);
|
|
240
|
+
currentContext = await detectCurrentContext(page, currentContext, step.text);
|
|
241
|
+
} catch {
|
|
242
|
+
// Click failed, element still recorded
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
149
248
|
errors.push(`Step "${step.text}": element [${step.selectorRef}] not found`);
|
|
150
249
|
// Continue scanning remaining steps — record what we can even if some elements are missing
|
|
151
250
|
}
|
|
@@ -198,6 +297,104 @@ async function executeClick(
|
|
|
198
297
|
}
|
|
199
298
|
}
|
|
200
299
|
|
|
300
|
+
async function executeFill(
|
|
301
|
+
page: Page,
|
|
302
|
+
ref: string,
|
|
303
|
+
elementType: string,
|
|
304
|
+
nth: number,
|
|
305
|
+
value: string
|
|
306
|
+
): Promise<void> {
|
|
307
|
+
const namePattern = new RegExp(escapeRegex(ref), 'i');
|
|
308
|
+
|
|
309
|
+
// Try placeholder first — most direct for input fields, avoids slow role attempts
|
|
310
|
+
try {
|
|
311
|
+
const locator = page.getByPlaceholder(namePattern);
|
|
312
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
313
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
314
|
+
await target.fill(value, { timeout: 5000 });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// continue
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Try label
|
|
322
|
+
try {
|
|
323
|
+
const locator = page.getByLabel(namePattern);
|
|
324
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
325
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
326
|
+
await target.fill(value, { timeout: 5000 });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// continue
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Try role-based fill (textbox, combobox, spinbutton)
|
|
334
|
+
const { getRoleFallbacks } = require('./role-fallback');
|
|
335
|
+
const roles = getRoleFallbacks(elementType);
|
|
336
|
+
|
|
337
|
+
for (const role of roles) {
|
|
338
|
+
try {
|
|
339
|
+
const locator = page.getByRole(role as any, { name: namePattern });
|
|
340
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
341
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
342
|
+
await target.fill(value, { timeout: 5000 });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Try testid as last resort
|
|
351
|
+
const normalized = ref.toLowerCase().replace(/[\s_-]+/g, '-');
|
|
352
|
+
await page.locator(`[data-testid*="${normalized}" i]`).first().fill(value, { timeout: 5000 });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function executeSelect(
|
|
356
|
+
page: Page,
|
|
357
|
+
ref: string,
|
|
358
|
+
elementType: string,
|
|
359
|
+
nth: number,
|
|
360
|
+
value: string
|
|
361
|
+
): Promise<void> {
|
|
362
|
+
const namePattern = new RegExp(escapeRegex(ref), 'i');
|
|
363
|
+
|
|
364
|
+
// Try label first — most direct for select elements
|
|
365
|
+
try {
|
|
366
|
+
const locator = page.getByLabel(namePattern);
|
|
367
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
368
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
369
|
+
await target.selectOption(value, { timeout: 5000 });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
// continue
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Try role-based select (combobox, listbox)
|
|
377
|
+
const { getRoleFallbacks } = require('./role-fallback');
|
|
378
|
+
const roles = getRoleFallbacks(elementType);
|
|
379
|
+
|
|
380
|
+
for (const role of roles) {
|
|
381
|
+
try {
|
|
382
|
+
const locator = page.getByRole(role as any, { name: namePattern });
|
|
383
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
384
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
385
|
+
await target.selectOption(value, { timeout: 5000 });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
} catch {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Try testid as last resort
|
|
394
|
+
const normalized = ref.toLowerCase().replace(/[\s_-]+/g, '-');
|
|
395
|
+
await page.locator(`[data-testid*="${normalized}" i]`).first().selectOption(value, { timeout: 5000 });
|
|
396
|
+
}
|
|
397
|
+
|
|
201
398
|
function detectAction(text: string): string {
|
|
202
399
|
const lower = text.toLowerCase();
|
|
203
400
|
if (/\b(is on|navigate|go to|visit|open)\b/.test(lower) && /\bpage\b/.test(lower)) return 'navigate';
|
|
@@ -287,6 +484,7 @@ function normalizeElementType(elementType: string, action: string): string {
|
|
|
287
484
|
if (t === 'element') return 'element';
|
|
288
485
|
if (t === 'column' || t === 'columnheader') return 'column';
|
|
289
486
|
if (t === 'logo' || t === 'image' || t === 'img' || t === 'icon') return 'img';
|
|
487
|
+
if (t === 'option' || t === 'item' || t === 'listitem') return 'option';
|
|
290
488
|
if (t === 'dialog' || t === 'modal') return 'dialog';
|
|
291
489
|
if (t === 'heading' || t === 'header') return 'heading';
|
|
292
490
|
if (t === 'title' || t === 'caption' || t === 'message') return 'text';
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* text → page.getByText(value)
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
export type MatchMethod = 'testid' | 'exact_role' | 'role_fallback' | 'label' | 'placeholder' | 'text_only' | 'unresolved';
|
|
13
|
+
export type MatchMethod = 'testid' | 'exact_role' | 'role_fallback' | 'label' | 'placeholder' | 'text_only' | 'contenteditable' | 'label-value' | 'unresolved';
|
|
14
14
|
|
|
15
|
-
export type SelectorType = 'testid' | 'role' | 'label' | 'placeholder' | 'text';
|
|
15
|
+
export type SelectorType = 'testid' | 'role' | 'label' | 'placeholder' | 'text' | 'locator';
|
|
16
16
|
|
|
17
17
|
export type ElementContext = 'page' | 'dialog' | 'menu' | 'modal' | 'popup';
|
|
18
18
|
|
|
@@ -25,6 +25,7 @@ export interface LiveElement {
|
|
|
25
25
|
name: string;
|
|
26
26
|
testid: string | null;
|
|
27
27
|
tag: string;
|
|
28
|
+
contenteditable: boolean;
|
|
28
29
|
placeholder: string | null;
|
|
29
30
|
label: string | null;
|
|
30
31
|
context: ElementContext;
|
|
@@ -17,6 +17,7 @@ export interface ScaffoldElement {
|
|
|
17
17
|
name?: string; // For role type (accessible name), or label text
|
|
18
18
|
nth: number;
|
|
19
19
|
exact?: boolean; // Whether to use exact matching (default: false)
|
|
20
|
+
inputMethod?: string; // 'pressSequentially' for contenteditable/rich-text elements
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface ScaffoldResult {
|
|
@@ -165,6 +166,9 @@ export class ScaffoldGenerator {
|
|
|
165
166
|
if (/^textarea\b/.test(lowerText) || /^\d*\s*textarea\b/.test(lowerText)) {
|
|
166
167
|
return 'textarea';
|
|
167
168
|
}
|
|
169
|
+
if (/^editor\b/.test(lowerText) || /^\d*\s*editor\b/.test(lowerText)) {
|
|
170
|
+
return 'editor';
|
|
171
|
+
}
|
|
168
172
|
if (/^text\b/.test(lowerText) || /^\d*\s*text\b/.test(lowerText)) {
|
|
169
173
|
return 'text';
|
|
170
174
|
}
|
|
@@ -183,6 +187,9 @@ export class ScaffoldGenerator {
|
|
|
183
187
|
if (/^(logo|image|img|icon)\b/.test(lowerText) || /^\d*\s*(logo|image|img|icon)\b/.test(lowerText)) {
|
|
184
188
|
return 'img';
|
|
185
189
|
}
|
|
190
|
+
if (/^(option|item|listitem)\b/.test(lowerText) || /^\d*\s*(option|item|listitem)\b/.test(lowerText)) {
|
|
191
|
+
return 'option';
|
|
192
|
+
}
|
|
186
193
|
if (/^(dialog|modal)\b/.test(lowerText) || /^\d*\s*(dialog|modal)\b/.test(lowerText)) {
|
|
187
194
|
return 'dialog';
|
|
188
195
|
}
|
|
@@ -690,50 +697,73 @@ export class ScaffoldGenerator {
|
|
|
690
697
|
// Only set exact when true (omit from YAML when false for cleaner output)
|
|
691
698
|
const exactField = liveElement.exact ? { exact: true } : {};
|
|
692
699
|
|
|
700
|
+
// Strip 'name' from scaffold base — only 'role' type uses name
|
|
701
|
+
const { name: _scaffoldName, ...scaffoldBase } = scaffoldElement as any;
|
|
702
|
+
|
|
693
703
|
if (selectorType === 'testid' && liveElement.testid) {
|
|
694
704
|
// page.getByTestId('value')
|
|
695
705
|
enriched[key] = {
|
|
696
|
-
...
|
|
706
|
+
...scaffoldBase,
|
|
697
707
|
type: 'testid' as any,
|
|
698
708
|
value: liveElement.testid,
|
|
699
709
|
};
|
|
700
710
|
} else if (selectorType === 'role' && liveElement.role) {
|
|
701
711
|
// page.getByRole('button', { name: 'Submit' })
|
|
712
|
+
// When name is empty (e.g. data-value fallback), keep it as '' —
|
|
713
|
+
// the actual name comes from test-data at runtime
|
|
702
714
|
enriched[key] = {
|
|
703
|
-
...
|
|
715
|
+
...scaffoldBase,
|
|
704
716
|
type: 'role',
|
|
705
717
|
value: liveElement.role,
|
|
706
|
-
name: liveElement.name
|
|
718
|
+
name: liveElement.name,
|
|
707
719
|
...exactField,
|
|
708
720
|
};
|
|
709
721
|
} else if (selectorType === 'label' && liveElement.label) {
|
|
710
722
|
// page.getByLabel('Username')
|
|
711
723
|
enriched[key] = {
|
|
712
|
-
...
|
|
724
|
+
...scaffoldBase,
|
|
713
725
|
type: 'label',
|
|
714
726
|
value: liveElement.label,
|
|
715
727
|
...exactField,
|
|
716
728
|
};
|
|
717
|
-
} else if (selectorType === 'placeholder' && liveElement.placeholder) {
|
|
729
|
+
} else if (selectorType === 'placeholder' && (liveElement.placeholder || liveElement.selectorValue)) {
|
|
718
730
|
// page.getByPlaceholder('Enter email')
|
|
719
731
|
enriched[key] = {
|
|
720
|
-
...
|
|
732
|
+
...scaffoldBase,
|
|
721
733
|
type: 'placeholder',
|
|
722
|
-
value: liveElement.placeholder,
|
|
734
|
+
value: liveElement.placeholder || liveElement.selectorValue,
|
|
723
735
|
...exactField,
|
|
724
736
|
};
|
|
725
737
|
} else if (selectorType === 'text') {
|
|
726
738
|
// page.getByText('Welcome')
|
|
739
|
+
// When name is empty (data-value fallback), keep value empty —
|
|
740
|
+
// the actual text comes from test-data at runtime
|
|
727
741
|
enriched[key] = {
|
|
728
|
-
...
|
|
742
|
+
...scaffoldBase,
|
|
729
743
|
type: 'text',
|
|
730
|
-
value: liveElement.name
|
|
744
|
+
value: liveElement.name,
|
|
731
745
|
...exactField,
|
|
732
746
|
};
|
|
747
|
+
} else if (selectorType === 'locator' && liveElement.selectorValue) {
|
|
748
|
+
// page.locator('[contenteditable="true"]') — CSS locator
|
|
749
|
+
// Reset nth to 0: the original nth was for text matches, not this locator type
|
|
750
|
+
enriched[key] = {
|
|
751
|
+
...scaffoldBase,
|
|
752
|
+
type: 'locator' as any,
|
|
753
|
+
value: liveElement.selectorValue,
|
|
754
|
+
nth: 0,
|
|
755
|
+
};
|
|
733
756
|
} else {
|
|
734
757
|
continue;
|
|
735
758
|
}
|
|
736
759
|
|
|
760
|
+
// Detect contenteditable div-based editors (rich text editors like TipTap, Quill, ProseMirror)
|
|
761
|
+
// When the DOM element is contenteditable but NOT a native input/textarea,
|
|
762
|
+
// mark it so the generator uses click + pressSequentially instead of .fill()
|
|
763
|
+
if (liveElement.contenteditable && liveElement.tag !== 'textarea' && liveElement.tag !== 'input') {
|
|
764
|
+
enriched[key] = { ...enriched[key], inputMethod: 'pressSequentially' };
|
|
765
|
+
}
|
|
766
|
+
|
|
737
767
|
enrichedCount++;
|
|
738
768
|
if (liveElement.warning) {
|
|
739
769
|
warningCount++;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
{{pageRoot}}.locator('{{escapeQuotes value}}')
|
|
@@ -227,6 +227,43 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
227
227
|
!!step.dataRef &&
|
|
228
228
|
step.text.includes('with'),
|
|
229
229
|
generator: (step, context) => {
|
|
230
|
+
// Resolve data variable value
|
|
231
|
+
let dataValue: string;
|
|
232
|
+
try {
|
|
233
|
+
dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
dataValue = `\${${step.dataRef}}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Label-value pattern: "User see [X] label with {{Y}}"
|
|
239
|
+
// Uses regex getByText to match container with both label and value text
|
|
240
|
+
if (step.elementType === 'label' && step.dataRef) {
|
|
241
|
+
// Check if selector override has empty value — if so, omit label from regex
|
|
242
|
+
let label: string | undefined = step.selectorRef;
|
|
243
|
+
try {
|
|
244
|
+
const resolved = context.selectorResolver.resolveSelector(
|
|
245
|
+
step.selectorRef!,
|
|
246
|
+
context.featureName,
|
|
247
|
+
step.elementType,
|
|
248
|
+
step.nth
|
|
249
|
+
);
|
|
250
|
+
if (resolved.value !== undefined && resolved.value.trim() === '') {
|
|
251
|
+
label = undefined;
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// No selector entry — use label from selectorRef
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const code = context.templateEngine.renderStep('label-value-assertion', {
|
|
258
|
+
label,
|
|
259
|
+
dataValue,
|
|
260
|
+
});
|
|
261
|
+
return {
|
|
262
|
+
code,
|
|
263
|
+
comment: `Assert ${step.selectorRef} label with value ${step.dataRef}`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
230
267
|
// Resolve selector from YAML with feature context
|
|
231
268
|
let resolved: any = {};
|
|
232
269
|
try {
|
|
@@ -240,14 +277,6 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
240
277
|
// Selector not in YAML or context issue - will use variable-only
|
|
241
278
|
}
|
|
242
279
|
|
|
243
|
-
// Resolve data variable value
|
|
244
|
-
let dataValue: string;
|
|
245
|
-
try {
|
|
246
|
-
dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
|
|
247
|
-
} catch (error) {
|
|
248
|
-
dataValue = `\${${step.dataRef}}`;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
280
|
const escapedVariable = dataValue.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
252
281
|
|
|
253
282
|
// Check if it's a locator strategy - use locator with filter
|
|
@@ -52,10 +52,17 @@ export const formPatterns: StepPattern[] = [
|
|
|
52
52
|
value = step.value!;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// Use pressSequentially template for contenteditable/rich-text editors (auto-detected by live-scan)
|
|
56
|
+
// or when Gherkin explicitly uses 'editor' element type
|
|
57
|
+
const isEditor = resolved.inputMethod === 'pressSequentially' || step.elementType === 'editor';
|
|
58
|
+
const templateName = isEditor ? 'fill-editor-action' : 'fill-action';
|
|
59
|
+
|
|
55
60
|
return {
|
|
56
|
-
templateName
|
|
61
|
+
templateName,
|
|
57
62
|
data: { ...resolved, selectorRef: step.selectorRef, fillValue: value },
|
|
58
|
-
comment:
|
|
63
|
+
comment: isEditor
|
|
64
|
+
? `Fill rich text editor ${step.selectorRef} with ${step.dataRef || step.value}`
|
|
65
|
+
: `Fill ${step.selectorRef} with ${step.dataRef || step.value}`,
|
|
59
66
|
};
|
|
60
67
|
},
|
|
61
68
|
priority: 10,
|
|
@@ -104,12 +104,14 @@ export class StepMapper {
|
|
|
104
104
|
const contextVars: Record<string, any> = { inDialog: true };
|
|
105
105
|
|
|
106
106
|
if (step.selectorRef && step.dataRef) {
|
|
107
|
-
// Case 3: [panel] dialog with {{value}}
|
|
107
|
+
// Case 3: [panel] dialog with {{value}}
|
|
108
|
+
// Use dataRef as filter text — the dialog name is dynamic (from test-data),
|
|
109
|
+
// so use .filter({ hasText }) instead of { name } for reliable matching
|
|
108
110
|
let filterText = '';
|
|
109
111
|
try {
|
|
110
112
|
filterText = this.dataResolver.resolveData(step.dataRef, this.featureName);
|
|
111
113
|
} catch {
|
|
112
|
-
filterText =
|
|
114
|
+
filterText = `\${${step.dataRef}}`;
|
|
113
115
|
}
|
|
114
116
|
contextVars.dialogFilterText = filterText;
|
|
115
117
|
} else if (step.selectorRef) {
|
|
@@ -198,6 +198,7 @@ interface SelectorEntry {
|
|
|
198
198
|
name?: string; // Accessible name for role-based selectors (e.g., 'Login', 'Submit')
|
|
199
199
|
nth?: number; // Element index (0 = no index, 1+ = append .nth())
|
|
200
200
|
exact?: boolean; // Whether to use exact matching (default: false)
|
|
201
|
+
inputMethod?: string; // 'pressSequentially' for contenteditable/rich-text elements
|
|
201
202
|
}
|
|
202
203
|
|
|
203
204
|
// New selector file structure: flat key-value pairs
|
|
@@ -224,6 +225,7 @@ export interface ResolvedSelector {
|
|
|
224
225
|
locator?: string; // CSS locator for fallback
|
|
225
226
|
nth?: number; // Index for .nth() if multiple matches exist
|
|
226
227
|
exact?: boolean; // Whether to use exact matching (default: false)
|
|
228
|
+
inputMethod?: string; // 'pressSequentially' for contenteditable/rich-text elements
|
|
227
229
|
}
|
|
228
230
|
|
|
229
231
|
/**
|
|
@@ -288,7 +290,7 @@ export class SelectorResolver {
|
|
|
288
290
|
* NOTE: textbox/textarea must precede "text" to prevent partial matching.
|
|
289
291
|
*/
|
|
290
292
|
static extractNthFromStep(afterElement: string): number {
|
|
291
|
-
const nthRegex = /^\s*(?:field|button|textbox|textarea|text|link|input|element|item|logo|image|img|icon|raw|table|column|columnheader|list|listitem|row|checkbox|radio|dropdown|select|uploader|heading|header|label|caption|title|message)?\s*(\d+)\b/i;
|
|
293
|
+
const nthRegex = /^\s*(?:field|button|textbox|textarea|editor|text|link|input|element|item|logo|image|img|icon|raw|table|column|columnheader|list|listitem|row|checkbox|radio|dropdown|select|uploader|heading|header|label|caption|title|message)?\s*(\d+)\b/i;
|
|
292
294
|
const match = nthRegex.exec(afterElement);
|
|
293
295
|
return match ? parseInt(match[1], 10) : 0;
|
|
294
296
|
}
|
|
@@ -391,6 +393,7 @@ export class SelectorResolver {
|
|
|
391
393
|
'input': 'field',
|
|
392
394
|
'textbox': 'field',
|
|
393
395
|
'textarea': 'field',
|
|
396
|
+
'editor': 'field',
|
|
394
397
|
};
|
|
395
398
|
return aliasMap[elementType] || elementType;
|
|
396
399
|
}
|
|
@@ -518,79 +521,84 @@ export class SelectorResolver {
|
|
|
518
521
|
const name = entry.name !== undefined && entry.name !== null ? entry.name : originalLabel;
|
|
519
522
|
const nth = entry.nth || 0;
|
|
520
523
|
const exact = entry.exact === true;
|
|
524
|
+
const inputMethod = entry.inputMethod;
|
|
525
|
+
|
|
526
|
+
// Helper to attach inputMethod only when present
|
|
527
|
+
const withInputMethod = (resolved: ResolvedSelector): ResolvedSelector =>
|
|
528
|
+
inputMethod ? { ...resolved, inputMethod } : resolved;
|
|
521
529
|
|
|
522
530
|
// If type is 'locator', use locator field (or value as fallback) as the CSS locator directly
|
|
523
531
|
if (type === 'locator') {
|
|
524
532
|
const locatorValue = locator && locator.trim() ? locator : value;
|
|
525
|
-
return {
|
|
533
|
+
return withInputMethod({
|
|
526
534
|
strategy: 'locator',
|
|
527
535
|
value: locatorValue,
|
|
528
536
|
locator: locatorValue,
|
|
529
537
|
nth,
|
|
530
|
-
};
|
|
538
|
+
});
|
|
531
539
|
}
|
|
532
540
|
|
|
533
541
|
// If custom locator is provided and type is not 'locator', use it as CSS selector
|
|
534
542
|
if (locator && locator.trim()) {
|
|
535
|
-
return {
|
|
543
|
+
return withInputMethod({
|
|
536
544
|
strategy: 'css',
|
|
537
545
|
value: locator,
|
|
538
546
|
locator: locator,
|
|
539
547
|
nth,
|
|
540
|
-
};
|
|
548
|
+
});
|
|
541
549
|
}
|
|
542
550
|
|
|
543
551
|
// Use type-based strategy
|
|
544
552
|
switch (type) {
|
|
545
553
|
case 'placeholder':
|
|
546
|
-
return {
|
|
554
|
+
return withInputMethod({
|
|
547
555
|
strategy: 'placeholder',
|
|
548
556
|
value,
|
|
549
557
|
nth,
|
|
550
558
|
exact,
|
|
551
|
-
};
|
|
559
|
+
});
|
|
552
560
|
|
|
553
561
|
case 'role':
|
|
554
|
-
return {
|
|
562
|
+
return withInputMethod({
|
|
555
563
|
strategy: 'role',
|
|
556
564
|
role: value,
|
|
557
565
|
name: name, // Use the name field for accessible name
|
|
558
566
|
value,
|
|
559
567
|
nth,
|
|
560
568
|
exact,
|
|
561
|
-
};
|
|
569
|
+
});
|
|
562
570
|
|
|
563
571
|
case 'testid':
|
|
564
|
-
return {
|
|
572
|
+
return withInputMethod({
|
|
565
573
|
strategy: 'testid',
|
|
566
574
|
value,
|
|
567
575
|
nth,
|
|
568
|
-
};
|
|
576
|
+
});
|
|
569
577
|
|
|
570
578
|
case 'label':
|
|
571
|
-
return {
|
|
579
|
+
return withInputMethod({
|
|
572
580
|
strategy: 'label',
|
|
573
581
|
value,
|
|
574
582
|
nth,
|
|
575
583
|
exact,
|
|
576
|
-
};
|
|
584
|
+
});
|
|
577
585
|
|
|
578
586
|
case 'text':
|
|
579
|
-
return {
|
|
587
|
+
return withInputMethod({
|
|
580
588
|
strategy: 'text',
|
|
581
589
|
value,
|
|
582
590
|
nth,
|
|
583
591
|
exact,
|
|
584
|
-
};
|
|
592
|
+
});
|
|
585
593
|
|
|
586
594
|
default:
|
|
587
595
|
// Fallback to placeholder
|
|
588
|
-
return {
|
|
596
|
+
return withInputMethod({
|
|
589
597
|
strategy: 'placeholder',
|
|
590
598
|
value,
|
|
591
599
|
nth,
|
|
592
600
|
exact,
|
|
593
|
-
};
|
|
601
|
+
});
|
|
594
602
|
}
|
|
595
603
|
}
|
|
596
604
|
|