@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.
Files changed (61) hide show
  1. package/dist/cli/index.js +1 -1
  2. package/dist/core/live-scanner/element-finder.d.ts +2 -2
  3. package/dist/core/live-scanner/element-finder.d.ts.map +1 -1
  4. package/dist/core/live-scanner/element-finder.js +192 -116
  5. package/dist/core/live-scanner/element-finder.js.map +1 -1
  6. package/dist/core/live-scanner/matrix-reader.d.ts.map +1 -1
  7. package/dist/core/live-scanner/matrix-reader.js +1 -0
  8. package/dist/core/live-scanner/matrix-reader.js.map +1 -1
  9. package/dist/core/live-scanner/matrix-writer.d.ts.map +1 -1
  10. package/dist/core/live-scanner/matrix-writer.js +1 -0
  11. package/dist/core/live-scanner/matrix-writer.js.map +1 -1
  12. package/dist/core/live-scanner/role-fallback.d.ts.map +1 -1
  13. package/dist/core/live-scanner/role-fallback.js +1 -0
  14. package/dist/core/live-scanner/role-fallback.js.map +1 -1
  15. package/dist/core/live-scanner/scanner.d.ts +9 -0
  16. package/dist/core/live-scanner/scanner.d.ts.map +1 -1
  17. package/dist/core/live-scanner/scanner.js +68 -8
  18. package/dist/core/live-scanner/scanner.js.map +1 -1
  19. package/dist/core/live-scanner/step-replayer.d.ts +1 -1
  20. package/dist/core/live-scanner/step-replayer.d.ts.map +1 -1
  21. package/dist/core/live-scanner/step-replayer.js +194 -6
  22. package/dist/core/live-scanner/step-replayer.js.map +1 -1
  23. package/dist/core/live-scanner/types.d.ts +3 -2
  24. package/dist/core/live-scanner/types.d.ts.map +1 -1
  25. package/dist/generators/scaffold-generator/index.d.ts +1 -0
  26. package/dist/generators/scaffold-generator/index.d.ts.map +1 -1
  27. package/dist/generators/scaffold-generator/index.js +37 -9
  28. package/dist/generators/scaffold-generator/index.js.map +1 -1
  29. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/fill-editor-action.hbs +3 -0
  30. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/label-value-assertion.hbs +2 -0
  31. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/locator.hbs +1 -1
  32. package/dist/generators/test-generator/patterns/assertion-patterns.d.ts.map +1 -1
  33. package/dist/generators/test-generator/patterns/assertion-patterns.js +31 -8
  34. package/dist/generators/test-generator/patterns/assertion-patterns.js.map +1 -1
  35. package/dist/generators/test-generator/patterns/form-patterns.d.ts.map +1 -1
  36. package/dist/generators/test-generator/patterns/form-patterns.js +8 -2
  37. package/dist/generators/test-generator/patterns/form-patterns.js.map +1 -1
  38. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  39. package/dist/generators/test-generator/step-mapper.js +4 -2
  40. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  41. package/dist/generators/test-generator/utils/selector-resolver.d.ts +1 -0
  42. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  43. package/dist/generators/test-generator/utils/selector-resolver.js +21 -17
  44. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/cli/index.ts +1 -1
  47. package/src/core/live-scanner/element-finder.ts +198 -118
  48. package/src/core/live-scanner/matrix-reader.ts +1 -0
  49. package/src/core/live-scanner/matrix-writer.ts +1 -0
  50. package/src/core/live-scanner/role-fallback.ts +1 -0
  51. package/src/core/live-scanner/scanner.ts +76 -9
  52. package/src/core/live-scanner/step-replayer.ts +204 -6
  53. package/src/core/live-scanner/types.ts +3 -2
  54. package/src/generators/scaffold-generator/index.ts +39 -9
  55. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/fill-editor-action.hbs +3 -0
  56. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/label-value-assertion.hbs +2 -0
  57. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/locator.hbs +1 -1
  58. package/src/generators/test-generator/patterns/assertion-patterns.ts +37 -8
  59. package/src/generators/test-generator/patterns/form-patterns.ts +9 -2
  60. package/src/generators/test-generator/step-mapper.ts +4 -2
  61. 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 — exact string first, then regex fallback
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 exactLocator = page.getByRole(role as any, { name: gherkinRef, exact: true });
187
- const exactCount = await exactLocator.count();
188
- if (exactCount > 0) {
189
- const target = nth > 0 ? exactLocator.nth(nth) : exactLocator.first();
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
- } catch {
202
- // Exact match not found, continue to regex
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
- // Step 2: Regex fallback (substring match)
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 — exact string first, then regex fallback
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 > 0) {
260
- const target = nth > 0 ? locator.nth(nth) : locator.first();
261
- if (await target.isVisible({ timeout: 3000 })) {
262
- return {
263
- locator: target,
264
- selectorType: 'label',
265
- selectorValue: '', // Will be filled from attrs
266
- role: '',
267
- matchMethod: 'label',
268
- warning: null,
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 — exact string first, then regex fallback
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 > 0) {
307
- const target = nth > 0 ? locator.nth(nth) : locator.first();
308
- if (await target.isVisible({ timeout: 3000 })) {
309
- const rawPlaceholder = await target.getAttribute('placeholder');
310
- return {
311
- locator: target,
312
- selectorType: 'placeholder',
313
- selectorValue: sanitizeText(rawPlaceholder || ''),
314
- role: '',
315
- matchMethod: 'placeholder',
316
- warning: null,
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
- // ⑥ getByText — exact string first, then regex fallback
327
- async function tryText(page: Page, gherkinRef: string, namePattern: RegExp, nth: number): Promise<FindResult | null> {
328
- // Step 1: Exact string match
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
- const exactLocator = page.getByText(gherkinRef, { exact: true });
331
- const exactCount = await exactLocator.count();
332
- if (exactCount > 0) {
333
- const target = nth > 0 ? exactLocator.nth(nth) : exactLocator.first();
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: 'text',
338
- selectorValue: '', // Will be filled from attrs
339
- role: '',
340
- matchMethod: 'text_only',
341
- warning: null,
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
- // Exact match not found, continue to regex
391
+ // Not found
347
392
  }
393
+ return null;
394
+ }
348
395
 
349
- // Step 2: Regex fallback
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 > 0) {
354
- const target = nth > 0 ? locator.nth(nth) : locator.first();
355
- if (await target.isVisible({ timeout: 3000 })) {
356
- return {
357
- locator: target,
358
- selectorType: 'text',
359
- selectorValue: '', // Will be filled from attrs
360
- role: '',
361
- matchMethod: 'text_only',
362
- warning: null,
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
- ): Promise<LiveScanResult> {
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 || el.name}) (${el.warning})`);
292
+ console.log(` ⚠ [${el.gherkinRef}] ${el.gherkinType} → ${el.selectorType}(${el.selectorValue || displayName}) (${el.warning})`);
223
293
  } else {
224
- const locatorDesc = el.selectorType
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 {