@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
|
@@ -32,11 +32,12 @@ export async function findElement(
|
|
|
32
32
|
page: Page,
|
|
33
33
|
gherkinRef: string,
|
|
34
34
|
gherkinType: string,
|
|
35
|
-
nth: number = 0
|
|
35
|
+
nth: number = 0,
|
|
36
|
+
context: ElementContext = 'page'
|
|
36
37
|
): Promise<LiveElement> {
|
|
37
38
|
const namePattern = new RegExp(escapeRegex(gherkinRef), 'i');
|
|
38
39
|
|
|
39
|
-
const result = await cascadingSearch(page, gherkinRef, gherkinType, namePattern, nth);
|
|
40
|
+
const result = await cascadingSearch(page, gherkinRef, gherkinType, namePattern, nth, context);
|
|
40
41
|
|
|
41
42
|
if (!result) {
|
|
42
43
|
return buildUnresolvedElement(gherkinRef, gherkinType);
|
|
@@ -80,6 +81,7 @@ export async function findElement(
|
|
|
80
81
|
name: actualName,
|
|
81
82
|
testid: attrs.testid,
|
|
82
83
|
tag: attrs.tag,
|
|
84
|
+
contenteditable: attrs.contenteditable,
|
|
83
85
|
placeholder: attrs.placeholder,
|
|
84
86
|
label: attrs.label,
|
|
85
87
|
context: 'page',
|
|
@@ -94,7 +96,8 @@ async function cascadingSearch(
|
|
|
94
96
|
gherkinRef: string,
|
|
95
97
|
gherkinType: string,
|
|
96
98
|
namePattern: RegExp,
|
|
97
|
-
nth: number
|
|
99
|
+
nth: number,
|
|
100
|
+
context: ElementContext = 'page'
|
|
98
101
|
): Promise<FindResult | null> {
|
|
99
102
|
// ① getByTestId
|
|
100
103
|
const testidResult = await tryTestId(page, gherkinRef, nth);
|
|
@@ -134,6 +137,14 @@ async function cascadingSearch(
|
|
|
134
137
|
// ⑤ getByPlaceholder — for input fields
|
|
135
138
|
const placeholderResult = await tryPlaceholder(page, gherkinRef, namePattern, nth);
|
|
136
139
|
if (placeholderResult) return placeholderResult;
|
|
140
|
+
|
|
141
|
+
// ⑤.5 contenteditable — for rich text editors (textarea/textbox/field types)
|
|
142
|
+
// When role match fails (editor has role="textbox" but no accessible name),
|
|
143
|
+
// try to find a [contenteditable="true"] element scoped to the current context
|
|
144
|
+
if (isEditableType(gherkinType)) {
|
|
145
|
+
const ceResult = await tryContentEditable(page, nth, context);
|
|
146
|
+
if (ceResult) return ceResult;
|
|
147
|
+
}
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
// ⑥ getByText — final fallback
|
|
@@ -173,7 +184,7 @@ async function tryTestId(page: Page, ref: string, nth: number): Promise<FindResu
|
|
|
173
184
|
return null;
|
|
174
185
|
}
|
|
175
186
|
|
|
176
|
-
// ② ③ getByRole —
|
|
187
|
+
// ② ③ getByRole — regex first, exact disambiguation when ambiguous
|
|
177
188
|
async function tryRole(
|
|
178
189
|
page: Page,
|
|
179
190
|
role: string,
|
|
@@ -181,12 +192,13 @@ async function tryRole(
|
|
|
181
192
|
namePattern: RegExp,
|
|
182
193
|
nth: number
|
|
183
194
|
): Promise<FindResult | null> {
|
|
184
|
-
// Step 1: Exact string match (prevents short refs matching longer names)
|
|
185
195
|
try {
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
189
|
-
|
|
196
|
+
const locator = page.getByRole(role as any, { name: namePattern });
|
|
197
|
+
const count = await locator.count();
|
|
198
|
+
if (count === 0) return null;
|
|
199
|
+
|
|
200
|
+
if (count === 1) {
|
|
201
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
190
202
|
if (await target.isVisible({ timeout: 3000 })) {
|
|
191
203
|
return {
|
|
192
204
|
locator: target,
|
|
@@ -197,22 +209,31 @@ async function tryRole(
|
|
|
197
209
|
warning: null,
|
|
198
210
|
};
|
|
199
211
|
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
212
|
+
} else {
|
|
213
|
+
// Multiple matches — try exact to disambiguate
|
|
214
|
+
try {
|
|
215
|
+
const exactLocator = page.getByRole(role as any, { name: gherkinRef, exact: true });
|
|
216
|
+
const exactCount = await exactLocator.count();
|
|
217
|
+
if (exactCount > 0) {
|
|
218
|
+
const target = nth > 0 ? exactLocator.nth(nth) : exactLocator.first();
|
|
219
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
220
|
+
return {
|
|
221
|
+
locator: target,
|
|
222
|
+
selectorType: 'role',
|
|
223
|
+
selectorValue: role,
|
|
224
|
+
role: role,
|
|
225
|
+
matchMethod: 'exact_role',
|
|
226
|
+
warning: null,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// Exact disambiguation failed
|
|
232
|
+
}
|
|
204
233
|
|
|
205
|
-
|
|
206
|
-
try {
|
|
207
|
-
const locator = page.getByRole(role as any, { name: namePattern });
|
|
208
|
-
const count = await locator.count();
|
|
209
|
-
if (count > 0) {
|
|
234
|
+
// Fall back to first regex match
|
|
210
235
|
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
211
236
|
if (await target.isVisible({ timeout: 3000 })) {
|
|
212
|
-
// Get the actual accessible name for the selector
|
|
213
|
-
const accessibleName = await target.evaluate((el) =>
|
|
214
|
-
el.getAttribute('aria-label') || el.textContent?.trim()?.substring(0, 100) || ''
|
|
215
|
-
);
|
|
216
237
|
return {
|
|
217
238
|
locator: target,
|
|
218
239
|
selectorType: 'role',
|
|
@@ -229,140 +250,191 @@ async function tryRole(
|
|
|
229
250
|
return null;
|
|
230
251
|
}
|
|
231
252
|
|
|
232
|
-
// ④ getByLabel —
|
|
253
|
+
// ④ getByLabel — regex first, exact disambiguation when ambiguous
|
|
233
254
|
async function tryLabel(page: Page, gherkinRef: string, namePattern: RegExp, nth: number): Promise<FindResult | null> {
|
|
234
|
-
// Step 1: Exact string match
|
|
235
|
-
try {
|
|
236
|
-
const exactLocator = page.getByLabel(gherkinRef, { exact: true });
|
|
237
|
-
const exactCount = await exactLocator.count();
|
|
238
|
-
if (exactCount > 0) {
|
|
239
|
-
const target = nth > 0 ? exactLocator.nth(nth) : exactLocator.first();
|
|
240
|
-
if (await target.isVisible({ timeout: 3000 })) {
|
|
241
|
-
return {
|
|
242
|
-
locator: target,
|
|
243
|
-
selectorType: 'label',
|
|
244
|
-
selectorValue: '', // Will be filled from attrs
|
|
245
|
-
role: '',
|
|
246
|
-
matchMethod: 'label',
|
|
247
|
-
warning: null,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
} catch {
|
|
252
|
-
// Exact match not found, continue to regex
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Step 2: Regex fallback
|
|
256
255
|
try {
|
|
257
256
|
const locator = page.getByLabel(namePattern);
|
|
258
257
|
const count = await locator.count();
|
|
259
|
-
if (count
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
258
|
+
if (count === 0) return null;
|
|
259
|
+
|
|
260
|
+
if (count > 1) {
|
|
261
|
+
// Multiple matches — try exact to disambiguate
|
|
262
|
+
try {
|
|
263
|
+
const exactLocator = page.getByLabel(gherkinRef, { exact: true });
|
|
264
|
+
const exactCount = await exactLocator.count();
|
|
265
|
+
if (exactCount > 0) {
|
|
266
|
+
const target = nth > 0 ? exactLocator.nth(nth) : exactLocator.first();
|
|
267
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
268
|
+
return {
|
|
269
|
+
locator: target,
|
|
270
|
+
selectorType: 'label',
|
|
271
|
+
selectorValue: '',
|
|
272
|
+
role: '',
|
|
273
|
+
matchMethod: 'label',
|
|
274
|
+
warning: null,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// Exact disambiguation failed
|
|
270
280
|
}
|
|
271
281
|
}
|
|
282
|
+
|
|
283
|
+
// Single match or exact failed — use first regex match
|
|
284
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
285
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
286
|
+
return {
|
|
287
|
+
locator: target,
|
|
288
|
+
selectorType: 'label',
|
|
289
|
+
selectorValue: '',
|
|
290
|
+
role: '',
|
|
291
|
+
matchMethod: 'label',
|
|
292
|
+
warning: null,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
272
295
|
} catch {
|
|
273
296
|
// Label not found
|
|
274
297
|
}
|
|
275
298
|
return null;
|
|
276
299
|
}
|
|
277
300
|
|
|
278
|
-
// ⑤ getByPlaceholder —
|
|
301
|
+
// ⑤ getByPlaceholder — regex first, exact disambiguation when ambiguous
|
|
279
302
|
async function tryPlaceholder(page: Page, gherkinRef: string, namePattern: RegExp, nth: number): Promise<FindResult | null> {
|
|
280
|
-
// Step 1: Exact string match
|
|
281
|
-
try {
|
|
282
|
-
const exactLocator = page.getByPlaceholder(gherkinRef, { exact: true });
|
|
283
|
-
const exactCount = await exactLocator.count();
|
|
284
|
-
if (exactCount > 0) {
|
|
285
|
-
const target = nth > 0 ? exactLocator.nth(nth) : exactLocator.first();
|
|
286
|
-
if (await target.isVisible({ timeout: 3000 })) {
|
|
287
|
-
const rawPlaceholder = await target.getAttribute('placeholder');
|
|
288
|
-
return {
|
|
289
|
-
locator: target,
|
|
290
|
-
selectorType: 'placeholder',
|
|
291
|
-
selectorValue: sanitizeText(rawPlaceholder || ''),
|
|
292
|
-
role: '',
|
|
293
|
-
matchMethod: 'placeholder',
|
|
294
|
-
warning: null,
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
} catch {
|
|
299
|
-
// Exact match not found, continue to regex
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Step 2: Regex fallback
|
|
303
303
|
try {
|
|
304
304
|
const locator = page.getByPlaceholder(namePattern);
|
|
305
305
|
const count = await locator.count();
|
|
306
|
-
if (count
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
306
|
+
if (count === 0) return null;
|
|
307
|
+
|
|
308
|
+
if (count > 1) {
|
|
309
|
+
// Multiple matches — try exact to disambiguate
|
|
310
|
+
try {
|
|
311
|
+
const exactLocator = page.getByPlaceholder(gherkinRef, { exact: true });
|
|
312
|
+
const exactCount = await exactLocator.count();
|
|
313
|
+
if (exactCount > 0) {
|
|
314
|
+
const target = nth > 0 ? exactLocator.nth(nth) : exactLocator.first();
|
|
315
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
316
|
+
const rawPlaceholder = await target.getAttribute('placeholder');
|
|
317
|
+
return {
|
|
318
|
+
locator: target,
|
|
319
|
+
selectorType: 'placeholder',
|
|
320
|
+
selectorValue: sanitizeText(rawPlaceholder || ''),
|
|
321
|
+
role: '',
|
|
322
|
+
matchMethod: 'placeholder',
|
|
323
|
+
warning: null,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
// Exact disambiguation failed
|
|
318
329
|
}
|
|
319
330
|
}
|
|
331
|
+
|
|
332
|
+
// Single match or exact failed — use first regex match
|
|
333
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
334
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
335
|
+
const rawPlaceholder = await target.getAttribute('placeholder');
|
|
336
|
+
return {
|
|
337
|
+
locator: target,
|
|
338
|
+
selectorType: 'placeholder',
|
|
339
|
+
selectorValue: sanitizeText(rawPlaceholder || ''),
|
|
340
|
+
role: '',
|
|
341
|
+
matchMethod: 'placeholder',
|
|
342
|
+
warning: null,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
320
345
|
} catch {
|
|
321
346
|
// Placeholder not found
|
|
322
347
|
}
|
|
323
348
|
return null;
|
|
324
349
|
}
|
|
325
350
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
351
|
+
/**
|
|
352
|
+
* Check if gherkinType is an editable/input type that could be a contenteditable div
|
|
353
|
+
*/
|
|
354
|
+
function isEditableType(gherkinType: string): boolean {
|
|
355
|
+
const normalized = gherkinType.toLowerCase().trim();
|
|
356
|
+
return normalized === 'textarea' || normalized === 'textbox' || normalized === 'field' || normalized === 'editor';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
// ⑤.5 contenteditable — for rich text editors
|
|
362
|
+
// Scoped to current context (dialog/page) to avoid matching wrong editor.
|
|
363
|
+
// The nth from Gherkin refers to text matches, not contenteditable count,
|
|
364
|
+
// so we try the requested nth first, then fall back to first match.
|
|
365
|
+
async function tryContentEditable(page: Page, nth: number, context: ElementContext): Promise<FindResult | null> {
|
|
329
366
|
try {
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
367
|
+
// Scope the search to the current context
|
|
368
|
+
const root = context === 'dialog' ? page.getByRole('dialog') : page;
|
|
369
|
+
const locator = root.locator('[contenteditable="true"]');
|
|
370
|
+
const count = await locator.count();
|
|
371
|
+
if (count === 0) return null;
|
|
372
|
+
|
|
373
|
+
// Try requested nth first, then fall back to first visible
|
|
374
|
+
const candidates = nth > 0 && count > nth ? [nth, 0] : [0];
|
|
375
|
+
for (const idx of candidates) {
|
|
376
|
+
const target = idx > 0 ? locator.nth(idx) : locator.first();
|
|
334
377
|
if (await target.isVisible({ timeout: 3000 })) {
|
|
335
378
|
return {
|
|
336
379
|
locator: target,
|
|
337
|
-
selectorType: '
|
|
338
|
-
selectorValue: '',
|
|
339
|
-
role: '',
|
|
340
|
-
matchMethod: '
|
|
341
|
-
warning:
|
|
380
|
+
selectorType: 'locator',
|
|
381
|
+
selectorValue: '[contenteditable="true"]',
|
|
382
|
+
role: 'textbox',
|
|
383
|
+
matchMethod: 'contenteditable',
|
|
384
|
+
warning: count > 1
|
|
385
|
+
? `${count} contenteditable elements in ${context}, using index ${idx}. Use nth in Gherkin to disambiguate.`
|
|
386
|
+
: null,
|
|
342
387
|
};
|
|
343
388
|
}
|
|
344
389
|
}
|
|
345
390
|
} catch {
|
|
346
|
-
//
|
|
391
|
+
// Not found
|
|
347
392
|
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
348
395
|
|
|
349
|
-
|
|
396
|
+
// ⑥ getByText — regex first, exact disambiguation when ambiguous
|
|
397
|
+
async function tryText(page: Page, gherkinRef: string, namePattern: RegExp, nth: number): Promise<FindResult | null> {
|
|
350
398
|
try {
|
|
351
399
|
const locator = page.getByText(namePattern);
|
|
352
400
|
const count = await locator.count();
|
|
353
|
-
if (count
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
401
|
+
if (count === 0) return null;
|
|
402
|
+
|
|
403
|
+
if (count > 1) {
|
|
404
|
+
// Multiple matches — try exact to disambiguate
|
|
405
|
+
try {
|
|
406
|
+
const exactLocator = page.getByText(gherkinRef, { exact: true });
|
|
407
|
+
const exactCount = await exactLocator.count();
|
|
408
|
+
if (exactCount > 0) {
|
|
409
|
+
const target = nth > 0 ? exactLocator.nth(nth) : exactLocator.first();
|
|
410
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
411
|
+
return {
|
|
412
|
+
locator: target,
|
|
413
|
+
selectorType: 'text',
|
|
414
|
+
selectorValue: '',
|
|
415
|
+
role: '',
|
|
416
|
+
matchMethod: 'text_only',
|
|
417
|
+
warning: null,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
} catch {
|
|
422
|
+
// Exact disambiguation failed
|
|
364
423
|
}
|
|
365
424
|
}
|
|
425
|
+
|
|
426
|
+
// Single match or exact failed — use first regex match
|
|
427
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
428
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
429
|
+
return {
|
|
430
|
+
locator: target,
|
|
431
|
+
selectorType: 'text',
|
|
432
|
+
selectorValue: '',
|
|
433
|
+
role: '',
|
|
434
|
+
matchMethod: 'text_only',
|
|
435
|
+
warning: null,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
366
438
|
} catch {
|
|
367
439
|
// Text not found
|
|
368
440
|
}
|
|
@@ -372,6 +444,7 @@ async function tryText(page: Page, gherkinRef: string, namePattern: RegExp, nth:
|
|
|
372
444
|
async function extractElementAttributes(locator: Locator): Promise<{
|
|
373
445
|
testid: string | null;
|
|
374
446
|
tag: string;
|
|
447
|
+
contenteditable: boolean;
|
|
375
448
|
accessibleName: string;
|
|
376
449
|
placeholder: string | null;
|
|
377
450
|
label: string | null;
|
|
@@ -392,16 +465,22 @@ async function extractElementAttributes(locator: Locator): Promise<{
|
|
|
392
465
|
label = clean(el.closest('label')?.textContent) || null;
|
|
393
466
|
}
|
|
394
467
|
|
|
468
|
+
// Detect contenteditable: check element itself and ancestors (rich text editors
|
|
469
|
+
// often have contenteditable on a parent div wrapping the editable area)
|
|
470
|
+
const isContentEditable = el.getAttribute('contenteditable') === 'true'
|
|
471
|
+
|| el.isContentEditable;
|
|
472
|
+
|
|
395
473
|
return {
|
|
396
474
|
testid: el.getAttribute('data-testid'),
|
|
397
475
|
tag: el.tagName.toLowerCase(),
|
|
476
|
+
contenteditable: isContentEditable,
|
|
398
477
|
accessibleName: clean(el.getAttribute('aria-label') || el.textContent?.trim()?.substring(0, 100)),
|
|
399
478
|
placeholder: clean(el.getAttribute('placeholder')) || null,
|
|
400
479
|
label,
|
|
401
480
|
};
|
|
402
481
|
});
|
|
403
482
|
} catch {
|
|
404
|
-
return { testid: null, tag: 'unknown', accessibleName: '', placeholder: null, label: null };
|
|
483
|
+
return { testid: null, tag: 'unknown', contenteditable: false, accessibleName: '', placeholder: null, label: null };
|
|
405
484
|
}
|
|
406
485
|
}
|
|
407
486
|
|
|
@@ -415,6 +494,7 @@ function buildUnresolvedElement(gherkinRef: string, gherkinType: string): LiveEl
|
|
|
415
494
|
name: gherkinRef,
|
|
416
495
|
testid: null,
|
|
417
496
|
tag: '',
|
|
497
|
+
contenteditable: false,
|
|
418
498
|
placeholder: null,
|
|
419
499
|
label: null,
|
|
420
500
|
context: 'page',
|
|
@@ -45,6 +45,7 @@ export function mergeMatrixElements(result: LiveScanResult): Record<string, Live
|
|
|
45
45
|
name: el.name || '',
|
|
46
46
|
testid: el.testid || null,
|
|
47
47
|
tag: el.tag || '',
|
|
48
|
+
contenteditable: el.contenteditable === true,
|
|
48
49
|
placeholder: el.placeholder || null,
|
|
49
50
|
label: el.label || null,
|
|
50
51
|
context: (el.context as any) || 'page',
|
|
@@ -46,6 +46,7 @@ export function writeMatrix(result: LiveScanResult, outputPath: string): void {
|
|
|
46
46
|
name: sanitizeForYaml(element.name),
|
|
47
47
|
testid: element.testid,
|
|
48
48
|
tag: element.tag,
|
|
49
|
+
contenteditable: element.contenteditable,
|
|
49
50
|
placeholder: sanitizeForYaml(element.placeholder),
|
|
50
51
|
label: sanitizeForYaml(element.label),
|
|
51
52
|
context: element.context,
|
|
@@ -14,6 +14,7 @@ const ROLE_FALLBACK_GROUPS: Record<string, string[]> = {
|
|
|
14
14
|
dialog: ['dialog', 'alertdialog'],
|
|
15
15
|
modal: ['dialog', 'alertdialog'],
|
|
16
16
|
tab: ['tab', 'button', 'link'],
|
|
17
|
+
option: ['option', 'menuitem', 'listitem'],
|
|
17
18
|
row: ['row', 'listitem', 'option'],
|
|
18
19
|
tag: ['button', 'link', 'option'],
|
|
19
20
|
uploader: ['textbox', 'button'],
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
import * as fs from 'fs';
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import { glob } from 'glob';
|
|
9
|
+
import * as yaml from 'yaml';
|
|
9
10
|
import { LiveScanResult, LiveScanScenario, LiveScanOptions } from './types';
|
|
10
11
|
import { replaySteps } from './step-replayer';
|
|
11
12
|
import { writeMatrix } from './matrix-writer';
|
|
12
13
|
import { readMatrix, mergeMatrixElements } from './matrix-reader';
|
|
13
14
|
import { readBaseUrlFromPlaywrightConfig } from './config-reader';
|
|
15
|
+
import { readYamlIfExists } from '../../utils/yaml-io';
|
|
14
16
|
|
|
15
17
|
export class LiveScanner {
|
|
16
18
|
private options: LiveScanOptions;
|
|
@@ -81,7 +83,11 @@ export class LiveScanner {
|
|
|
81
83
|
|
|
82
84
|
try {
|
|
83
85
|
const parsed = parser.parseFeatureFile(featureFile);
|
|
84
|
-
const result = await this.scanFeature(browser, playwright, parsed, baseUrl, existingResult);
|
|
86
|
+
const result = await this.scanFeature(browser, playwright, parsed, baseUrl, existingResult, featureName);
|
|
87
|
+
if (!result) {
|
|
88
|
+
console.log(` ⏭️ Feature skipped (missing test-data)`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
85
91
|
results.push(result);
|
|
86
92
|
|
|
87
93
|
// Write matrix file
|
|
@@ -99,6 +105,42 @@ export class LiveScanner {
|
|
|
99
105
|
return results;
|
|
100
106
|
}
|
|
101
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Load test-data YAML for a feature, merging override over base.
|
|
110
|
+
* Returns null if no test-data file exists.
|
|
111
|
+
*/
|
|
112
|
+
private loadTestData(featureName: string): Record<string, any> | null {
|
|
113
|
+
const testDataDir = path.join(this.options.screensDir, this.options.screenName, 'test-data');
|
|
114
|
+
const basePath = path.join(testDataDir, `${featureName}.yaml`);
|
|
115
|
+
const overridePath = path.join(testDataDir, `${featureName}.override.yaml`);
|
|
116
|
+
|
|
117
|
+
const base = readYamlIfExists<Record<string, any>>(basePath);
|
|
118
|
+
const override = readYamlIfExists<Record<string, any>>(overridePath);
|
|
119
|
+
|
|
120
|
+
if (!base && !override) return null;
|
|
121
|
+
|
|
122
|
+
return { ...(base || {}), ...(override || {}) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Find dataRef keys from steps that resolve to empty string in testData.
|
|
127
|
+
*/
|
|
128
|
+
private findEmptyDataKeys(testData: Record<string, any>, steps: any[]): string[] {
|
|
129
|
+
const emptyKeys: string[] = [];
|
|
130
|
+
for (const step of steps) {
|
|
131
|
+
if (!step.dataRef) continue;
|
|
132
|
+
const parts = step.dataRef.replace(/[{}]/g, '').split('.');
|
|
133
|
+
let value: any = testData;
|
|
134
|
+
for (const part of parts) {
|
|
135
|
+
value = value?.[part];
|
|
136
|
+
}
|
|
137
|
+
if (value === undefined || value === null || value === '') {
|
|
138
|
+
emptyKeys.push(step.dataRef);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return [...new Set(emptyKeys)];
|
|
142
|
+
}
|
|
143
|
+
|
|
102
144
|
private resolveBaseUrl(): string {
|
|
103
145
|
// 1. Explicit option (from --url flag)
|
|
104
146
|
if (this.options.baseUrl) {
|
|
@@ -124,8 +166,9 @@ export class LiveScanner {
|
|
|
124
166
|
playwright: any,
|
|
125
167
|
feature: any,
|
|
126
168
|
baseUrl: string,
|
|
127
|
-
existingResult?: LiveScanResult | null
|
|
128
|
-
|
|
169
|
+
existingResult?: LiveScanResult | null,
|
|
170
|
+
featureFileName?: string
|
|
171
|
+
): Promise<LiveScanResult | null> {
|
|
129
172
|
const featurePath = feature.path || '/';
|
|
130
173
|
const scenarios: Record<string, LiveScanScenario> = {};
|
|
131
174
|
|
|
@@ -140,6 +183,31 @@ export class LiveScanner {
|
|
|
140
183
|
}
|
|
141
184
|
}
|
|
142
185
|
|
|
186
|
+
// Collect all steps across scenarios to check for dataRefs
|
|
187
|
+
const allSteps = feature.scenarios.flatMap((s: any) => s.steps || []);
|
|
188
|
+
const hasDataRefs = allSteps.some((s: any) => s.dataRef);
|
|
189
|
+
|
|
190
|
+
// Load test-data if needed
|
|
191
|
+
let testData: Record<string, any> | null = null;
|
|
192
|
+
if (hasDataRefs) {
|
|
193
|
+
const testDataName = featureFileName || path.basename(feature.sourceFile || '', '.feature');
|
|
194
|
+
testData = this.loadTestData(testDataName);
|
|
195
|
+
if (!testData) {
|
|
196
|
+
const screenName = this.options.screenName;
|
|
197
|
+
console.warn(` ⚠️ Feature has {{dataRef}} steps but no test-data file found — skipping feature`);
|
|
198
|
+
console.warn(` 💡 To fix:`);
|
|
199
|
+
console.warn(` 1. Run: sungen map --screen ${screenName} --skip-live-scan`);
|
|
200
|
+
console.warn(` 2. Fill test-data values in qa/screens/${screenName}/test-data/`);
|
|
201
|
+
console.warn(` 3. Re-run: sungen live-scan --screen ${screenName}`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const emptyKeys = this.findEmptyDataKeys(testData, allSteps);
|
|
205
|
+
if (emptyKeys.length > 0) {
|
|
206
|
+
console.warn(` ⚠️ Empty test-data values for: ${emptyKeys.join(', ')}`);
|
|
207
|
+
console.warn(` 💡 Fill these in qa/screens/${this.options.screenName}/test-data/ for accurate results`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
143
211
|
for (const scenario of feature.scenarios) {
|
|
144
212
|
// Skip @steps-only scenarios (they're reusable blocks, not standalone)
|
|
145
213
|
if (scenario.stepsName && !scenario.extendsName) {
|
|
@@ -182,7 +250,7 @@ export class LiveScanner {
|
|
|
182
250
|
const page = await context.newPage();
|
|
183
251
|
|
|
184
252
|
try {
|
|
185
|
-
const result = await replaySteps(page, steps, featurePath, baseUrl, existingElements);
|
|
253
|
+
const result = await replaySteps(page, steps, featurePath, baseUrl, existingElements, testData);
|
|
186
254
|
|
|
187
255
|
scenarios[scenarioKey] = {
|
|
188
256
|
auth: authRole,
|
|
@@ -216,15 +284,14 @@ export class LiveScanner {
|
|
|
216
284
|
// Already printed in replaySteps
|
|
217
285
|
continue;
|
|
218
286
|
}
|
|
287
|
+
// Use dataValue (from data-value fallback) for display when name is empty
|
|
288
|
+
const displayName = el.name || el.dataValue || el.gherkinRef;
|
|
219
289
|
if (el.matchMethod === 'unresolved') {
|
|
220
290
|
console.log(` ✗ [${el.gherkinRef}] ${el.gherkinType} → NOT FOUND`);
|
|
221
291
|
} else if (el.warning) {
|
|
222
|
-
console.log(` ⚠ [${el.gherkinRef}] ${el.gherkinType} → ${el.selectorType}(${el.selectorValue ||
|
|
292
|
+
console.log(` ⚠ [${el.gherkinRef}] ${el.gherkinType} → ${el.selectorType}(${el.selectorValue || displayName}) (${el.warning})`);
|
|
223
293
|
} else {
|
|
224
|
-
|
|
225
|
-
? `${el.selectorType}${el.role ? `('${el.role}', {name: '${el.name}'})` : `('${el.selectorValue || el.name}')`}`
|
|
226
|
-
: el.gherkinType;
|
|
227
|
-
console.log(` ✓ [${el.gherkinRef}] → getBy${capitalize(el.selectorType || 'text')}${el.role ? `('${el.role}', {name: '${el.name}'})` : `('${el.selectorValue || el.name}')`}`);
|
|
294
|
+
console.log(` ✓ [${el.gherkinRef}] → getBy${capitalize(el.selectorType || 'text')}${el.role ? `('${el.role}', {name: '${displayName}'})` : `('${el.selectorValue || displayName}')`}`);
|
|
228
295
|
}
|
|
229
296
|
}
|
|
230
297
|
} finally {
|