@sun-asterisk/sungen 1.0.17 → 1.0.19

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 (108) hide show
  1. package/README.md +101 -601
  2. package/dist/cli/commands/live-scan-command.d.ts +9 -0
  3. package/dist/cli/commands/live-scan-command.d.ts.map +1 -0
  4. package/dist/cli/commands/live-scan-command.js +72 -0
  5. package/dist/cli/commands/live-scan-command.js.map +1 -0
  6. package/dist/cli/index.js +34 -5
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/core/live-scanner/config-reader.d.ts +10 -0
  9. package/dist/core/live-scanner/config-reader.d.ts.map +1 -0
  10. package/dist/core/live-scanner/config-reader.js +87 -0
  11. package/dist/core/live-scanner/config-reader.js.map +1 -0
  12. package/dist/core/live-scanner/element-finder.d.ts +20 -0
  13. package/dist/core/live-scanner/element-finder.d.ts.map +1 -0
  14. package/dist/core/live-scanner/element-finder.js +289 -0
  15. package/dist/core/live-scanner/element-finder.js.map +1 -0
  16. package/dist/core/live-scanner/index.d.ts +8 -0
  17. package/dist/core/live-scanner/index.d.ts.map +1 -0
  18. package/dist/core/live-scanner/index.js +33 -0
  19. package/dist/core/live-scanner/index.js.map +1 -0
  20. package/dist/core/live-scanner/matrix-reader.d.ts +17 -0
  21. package/dist/core/live-scanner/matrix-reader.d.ts.map +1 -0
  22. package/dist/core/live-scanner/matrix-reader.js +97 -0
  23. package/dist/core/live-scanner/matrix-reader.js.map +1 -0
  24. package/dist/core/live-scanner/matrix-writer.d.ts +7 -0
  25. package/dist/core/live-scanner/matrix-writer.d.ts.map +1 -0
  26. package/dist/core/live-scanner/matrix-writer.js +92 -0
  27. package/dist/core/live-scanner/matrix-writer.js.map +1 -0
  28. package/dist/core/live-scanner/role-fallback.d.ts +15 -0
  29. package/dist/core/live-scanner/role-fallback.d.ts.map +1 -0
  30. package/dist/core/live-scanner/role-fallback.js +45 -0
  31. package/dist/core/live-scanner/role-fallback.js.map +1 -0
  32. package/dist/core/live-scanner/scanner.d.ts +13 -0
  33. package/dist/core/live-scanner/scanner.d.ts.map +1 -0
  34. package/dist/core/live-scanner/scanner.js +225 -0
  35. package/dist/core/live-scanner/scanner.js.map +1 -0
  36. package/dist/core/live-scanner/step-replayer.d.ts +26 -0
  37. package/dist/core/live-scanner/step-replayer.d.ts.map +1 -0
  38. package/dist/core/live-scanner/step-replayer.js +184 -0
  39. package/dist/core/live-scanner/step-replayer.js.map +1 -0
  40. package/dist/core/live-scanner/types.d.ts +50 -0
  41. package/dist/core/live-scanner/types.d.ts.map +1 -0
  42. package/dist/core/live-scanner/types.js +14 -0
  43. package/dist/core/live-scanner/types.js.map +1 -0
  44. package/dist/generators/cli.js +1 -1
  45. package/dist/generators/scaffold-generator/index.d.ts +20 -2
  46. package/dist/generators/scaffold-generator/index.d.ts.map +1 -1
  47. package/dist/generators/scaffold-generator/index.js +170 -10
  48. package/dist/generators/scaffold-generator/index.js.map +1 -1
  49. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/radio-select-action.hbs +1 -1
  50. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +2 -2
  51. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +2 -2
  52. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-role-variable-assertion.hbs +6 -2
  53. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-variable-assertion.hbs +1 -1
  54. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/label.hbs +1 -1
  55. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/placeholder.hbs +1 -1
  56. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/role.hbs +1 -1
  57. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/text.hbs +1 -1
  58. package/dist/generators/test-generator/patterns/assertion-patterns.d.ts.map +1 -1
  59. package/dist/generators/test-generator/patterns/assertion-patterns.js +6 -3
  60. package/dist/generators/test-generator/patterns/assertion-patterns.js.map +1 -1
  61. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  62. package/dist/generators/test-generator/template-engine.js +3 -5
  63. package/dist/generators/test-generator/template-engine.js.map +1 -1
  64. package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
  65. package/dist/generators/test-generator/utils/data-resolver.js +18 -7
  66. package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
  67. package/dist/generators/test-generator/utils/selector-resolver.d.ts +1 -0
  68. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  69. package/dist/generators/test-generator/utils/selector-resolver.js +6 -0
  70. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  71. package/dist/input/cli-adapter.d.ts +4 -0
  72. package/dist/input/cli-adapter.d.ts.map +1 -1
  73. package/dist/input/cli-adapter.js +18 -3
  74. package/dist/input/cli-adapter.js.map +1 -1
  75. package/dist/orchestrator/project-initializer.d.ts +6 -1
  76. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  77. package/dist/orchestrator/project-initializer.js +34 -8
  78. package/dist/orchestrator/project-initializer.js.map +1 -1
  79. package/package.json +1 -1
  80. package/src/cli/commands/live-scan-command.ts +79 -0
  81. package/src/cli/index.ts +40 -7
  82. package/src/config/default.config.yaml +6 -0
  83. package/src/core/live-scanner/config-reader.ts +57 -0
  84. package/src/core/live-scanner/element-finder.ts +333 -0
  85. package/src/core/live-scanner/index.ts +7 -0
  86. package/src/core/live-scanner/matrix-reader.ts +70 -0
  87. package/src/core/live-scanner/matrix-writer.ts +66 -0
  88. package/src/core/live-scanner/role-fallback.ts +43 -0
  89. package/src/core/live-scanner/scanner.ts +231 -0
  90. package/src/core/live-scanner/step-replayer.ts +219 -0
  91. package/src/core/live-scanner/types.ts +56 -0
  92. package/src/generators/cli.ts +1 -1
  93. package/src/generators/scaffold-generator/index.ts +181 -14
  94. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/radio-select-action.hbs +1 -1
  95. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +2 -2
  96. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +2 -2
  97. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-role-variable-assertion.hbs +6 -2
  98. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-variable-assertion.hbs +1 -1
  99. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/label.hbs +1 -1
  100. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/placeholder.hbs +1 -1
  101. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/role.hbs +1 -1
  102. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/text.hbs +1 -1
  103. package/src/generators/test-generator/patterns/assertion-patterns.ts +6 -3
  104. package/src/generators/test-generator/template-engine.ts +3 -4
  105. package/src/generators/test-generator/utils/data-resolver.ts +20 -9
  106. package/src/generators/test-generator/utils/selector-resolver.ts +8 -0
  107. package/src/input/cli-adapter.ts +19 -3
  108. package/src/orchestrator/project-initializer.ts +37 -8
@@ -11,10 +11,11 @@ import { SelectorResolver } from '../test-generator/utils/selector-resolver';
11
11
 
12
12
  export interface ScaffoldElement {
13
13
  locator: string;
14
- type: 'placeholder' | 'role' | 'text' | 'label' | 'page';
14
+ type: 'placeholder' | 'role' | 'text' | 'label' | 'page' | 'testid';
15
15
  value: string;
16
- name?: string; // For role type (accessible name)
16
+ name?: string; // For role type (accessible name), or label text
17
17
  nth: number;
18
+ exact?: boolean; // Whether to use exact matching (default: false)
18
19
  }
19
20
 
20
21
  export interface ScaffoldResult {
@@ -31,8 +32,8 @@ export interface MapResult {
31
32
  testDataPath: string;
32
33
  elementCount: number;
33
34
  dataRefCount: number;
34
- selectorsSkipped?: boolean; // True if selectors file already existed
35
- testDataSkipped?: boolean; // True if test-data file already existed
35
+ selectorsSkipped?: boolean; // True if selectors file already existed (and force was not set)
36
+ testDataMerged?: boolean; // True if test-data file existed and was smart-merged
36
37
  }
37
38
 
38
39
  interface ExtractedDataRef {
@@ -178,6 +179,9 @@ export class ScaffoldGenerator {
178
179
  if (/^(logo|image|img|icon)\b/.test(lowerText) || /^\d*\s*(logo|image|img|icon)\b/.test(lowerText)) {
179
180
  return 'img';
180
181
  }
182
+ if (/^(dialog|modal)\b/.test(lowerText) || /^\d*\s*(dialog|modal)\b/.test(lowerText)) {
183
+ return 'dialog';
184
+ }
181
185
  if (/^(heading|header)\b/.test(lowerText) || /^\d*\s*(heading|header)\b/.test(lowerText)) {
182
186
  return 'heading';
183
187
  }
@@ -393,7 +397,7 @@ export class ScaffoldGenerator {
393
397
  case 'see':
394
398
  // For assertions - respect targetType for role-based elements
395
399
  if (targetType === 'img' || targetType === 'button' || targetType === 'link' ||
396
- targetType === 'checkbox' || targetType === 'radio' || targetType === 'heading') {
400
+ targetType === 'checkbox' || targetType === 'radio' || targetType === 'heading' || targetType === 'dialog') {
397
401
  const seeRoleValue = this.getRoleValue(targetType);
398
402
  return {
399
403
  locator: '',
@@ -463,6 +467,8 @@ export class ScaffoldGenerator {
463
467
  return 'img';
464
468
  case 'heading':
465
469
  return 'heading';
470
+ case 'dialog':
471
+ return 'dialog';
466
472
  default:
467
473
  return 'button'; // Default to button
468
474
  }
@@ -606,6 +612,124 @@ export class ScaffoldGenerator {
606
612
  return results;
607
613
  }
608
614
 
615
+ /**
616
+ * Enrich scaffold with live-scan data if a .live-scan.yaml file exists.
617
+ * Uses the Playwright locator strategy detected by live-scan:
618
+ * testid → type: testid, value: <testid>
619
+ * role → type: role, value: <aria-role>, name: <accessible-name>
620
+ * label → type: label, value: <label-text>
621
+ * placeholder → type: placeholder, value: <placeholder-text>
622
+ * text → type: text, value: <visible-text>
623
+ */
624
+ private enrichWithLiveScan(scaffold: ScaffoldResult, liveScanPath: string): ScaffoldResult {
625
+ if (!fs.existsSync(liveScanPath)) {
626
+ return scaffold;
627
+ }
628
+
629
+ let matrixData: any;
630
+ try {
631
+ const { readMatrix, mergeMatrixElements } = require('../../core/live-scanner/matrix-reader');
632
+ matrixData = readMatrix(liveScanPath);
633
+ if (!matrixData) return scaffold;
634
+
635
+ const elements = mergeMatrixElements(matrixData);
636
+ if (!elements || Object.keys(elements).length === 0) return scaffold;
637
+
638
+ console.log(` 🔗 Using live-scan data from ${path.basename(liveScanPath)}`);
639
+
640
+ let enrichedCount = 0;
641
+ let warningCount = 0;
642
+
643
+ const enriched: ScaffoldResult = { ...scaffold };
644
+
645
+ for (const [key, scaffoldElement] of Object.entries(enriched)) {
646
+ // Try exact key first, then base key without --N suffix or --type suffix
647
+ let liveElement = elements[key];
648
+ if (!liveElement && key.includes('--')) {
649
+ const baseKey = key.replace(/--.*$/, '');
650
+ liveElement = elements[baseKey];
651
+ }
652
+ if (!liveElement) {
653
+ continue;
654
+ }
655
+
656
+ // If live-scan couldn't find the element, clear the identifying field so user must fill manually
657
+ if (liveElement.matchMethod === 'unresolved') {
658
+ if (scaffoldElement.name !== undefined) {
659
+ enriched[key] = { ...scaffoldElement, name: '' };
660
+ } else {
661
+ enriched[key] = { ...scaffoldElement, value: '' };
662
+ }
663
+ continue;
664
+ }
665
+
666
+ // Use the Playwright locator strategy that live-scan detected
667
+ const selectorType = liveElement.selectorType;
668
+
669
+ // Only set exact when true (omit from YAML when false for cleaner output)
670
+ const exactField = liveElement.exact ? { exact: true } : {};
671
+
672
+ if (selectorType === 'testid' && liveElement.testid) {
673
+ // page.getByTestId('value')
674
+ enriched[key] = {
675
+ ...scaffoldElement,
676
+ type: 'testid' as any,
677
+ value: liveElement.testid,
678
+ };
679
+ } else if (selectorType === 'role' && liveElement.role) {
680
+ // page.getByRole('button', { name: 'Submit' })
681
+ enriched[key] = {
682
+ ...scaffoldElement,
683
+ type: 'role',
684
+ value: liveElement.role,
685
+ name: liveElement.name || liveElement.gherkinRef,
686
+ ...exactField,
687
+ };
688
+ } else if (selectorType === 'label' && liveElement.label) {
689
+ // page.getByLabel('Username')
690
+ enriched[key] = {
691
+ ...scaffoldElement,
692
+ type: 'label',
693
+ value: liveElement.label,
694
+ ...exactField,
695
+ };
696
+ } else if (selectorType === 'placeholder' && liveElement.placeholder) {
697
+ // page.getByPlaceholder('Enter email')
698
+ enriched[key] = {
699
+ ...scaffoldElement,
700
+ type: 'placeholder',
701
+ value: liveElement.placeholder,
702
+ ...exactField,
703
+ };
704
+ } else if (selectorType === 'text') {
705
+ // page.getByText('Welcome')
706
+ enriched[key] = {
707
+ ...scaffoldElement,
708
+ type: 'text',
709
+ value: liveElement.name || liveElement.gherkinRef,
710
+ ...exactField,
711
+ };
712
+ } else {
713
+ continue;
714
+ }
715
+
716
+ enrichedCount++;
717
+ if (liveElement.warning) {
718
+ warningCount++;
719
+ console.log(` ⚠️ ${key}: ${liveElement.warning}`);
720
+ }
721
+ }
722
+
723
+ const textOnlyCount = Object.keys(enriched).length - enrichedCount;
724
+ console.log(` 📊 Live-scan: ${enrichedCount} enriched, ${textOnlyCount} text-inferred${warningCount > 0 ? `, ${warningCount} warnings` : ''}`);
725
+
726
+ return enriched;
727
+ } catch (error: any) {
728
+ console.warn(` ⚠️ Failed to read live-scan data: ${error.message}`);
729
+ return scaffold;
730
+ }
731
+ }
732
+
609
733
  /**
610
734
  * Helper to capitalize first letter
611
735
  */
@@ -655,6 +779,10 @@ export class ScaffoldGenerator {
655
779
  const selectorScaffold = this.buildScaffold(elements);
656
780
  const testDataScaffold = this.buildTestData(dataRefs);
657
781
 
782
+ // Enrich scaffold with live-scan data if available
783
+ const liveScanPath = path.join(selectorsDir, `${filename}.live-scan.yaml`);
784
+ const liveScanEnriched = this.enrichWithLiveScan(selectorScaffold, liveScanPath);
785
+
658
786
  // Check if files exist
659
787
  const selectorsPath = path.join(selectorsDir, `${filename}.yaml`);
660
788
  const selectorsOverridePath = path.join(selectorsDir, `${filename}.override.yaml`);
@@ -666,12 +794,19 @@ export class ScaffoldGenerator {
666
794
 
667
795
  // Write selectors YAML (BASE ONLY - never touch override files)
668
796
  if (!selectorsExists || forceOverwrite) {
669
- this.writeSelectorsYaml(selectorScaffold, selectorsPath, filename);
797
+ this.writeSelectorsYaml(liveScanEnriched, selectorsPath, filename);
670
798
  }
671
799
  // Override file is NEVER touched by map command
672
800
 
673
- // Write test-data YAML (BASE ONLY - never touch override files)
674
- if (!testDataExists || forceOverwrite) {
801
+ // Write test-data YAML always smart-merge regardless of --force:
802
+ // existing keys keep their current value
803
+ // • new keys from features are added (empty string)
804
+ // • keys no longer referenced in features are removed
805
+ if (testDataExists) {
806
+ const existingRaw = yaml.parse(fs.readFileSync(testDataPath, 'utf-8')) as TestDataResult || {};
807
+ const merged = this.mergeTestData(existingRaw, testDataScaffold);
808
+ this.writeTestDataYaml(merged, testDataPath, filename);
809
+ } else {
675
810
  this.writeTestDataYaml(testDataScaffold, testDataPath, filename);
676
811
  }
677
812
  // Override file is NEVER touched by map command
@@ -680,10 +815,10 @@ export class ScaffoldGenerator {
680
815
  featureFile: filename,
681
816
  selectorsPath,
682
817
  testDataPath,
683
- elementCount: Object.keys(selectorScaffold).length,
818
+ elementCount: Object.keys(liveScanEnriched).length,
684
819
  dataRefCount: dataRefs.length,
685
820
  selectorsSkipped: selectorsExists && !forceOverwrite,
686
- testDataSkipped: testDataExists && !forceOverwrite,
821
+ testDataMerged: testDataExists,
687
822
  });
688
823
  }
689
824
 
@@ -786,14 +921,46 @@ export class ScaffoldGenerator {
786
921
  fs.writeFileSync(outputPath, header + yamlContent, 'utf-8');
787
922
  }
788
923
 
924
+ /**
925
+ * Smart-merge test-data objects:
926
+ * - Keys present in both: keep the existing value (user may have filled it in)
927
+ * - Keys only in fresh (new refs in features): add with empty value
928
+ * - Keys only in existing (removed from features): drop them
929
+ */
930
+ private mergeTestData(existing: TestDataResult, fresh: TestDataResult): TestDataResult {
931
+ const merged: TestDataResult = {};
932
+
933
+ for (const key of Object.keys(fresh)) {
934
+ if (key in existing) {
935
+ const existingVal = existing[key];
936
+ const freshVal = fresh[key];
937
+ if (typeof freshVal === 'object' && typeof existingVal === 'object') {
938
+ // Both are nested — recurse
939
+ merged[key] = this.mergeTestData(existingVal as TestDataResult, freshVal as TestDataResult);
940
+ } else {
941
+ // Keep the existing (user-supplied) value
942
+ merged[key] = existingVal;
943
+ }
944
+ } else {
945
+ // New key — add with the scaffold default (empty string or nested object)
946
+ merged[key] = fresh[key];
947
+ }
948
+ // Keys present in existing but absent from fresh are intentionally dropped (no longer referenced)
949
+ }
950
+
951
+ return merged;
952
+ }
953
+
789
954
  /**
790
955
  * Write test-data YAML file
791
956
  */
792
957
  private writeTestDataYaml(testData: TestDataResult, outputPath: string, screenName: string): void {
793
- const header = `# ${this.capitalizeFirst(screenName)} Screen Test Data (Auto-generated)
794
- # ⚠️ AUTO-GENERATED - Consider editing only the override file
795
- # To override values, copy to ${screenName}.override.yaml
796
- # Manual edits to this file will be lost on regeneration
958
+ const header = `# ${this.capitalizeFirst(screenName)} Screen Test Data
959
+ # Managed by: sungen map (smart-merge)
960
+ # New {{variable}} refs are added automatically (empty value)
961
+ # Removed refs are dropped on the next sungen map run
962
+ # • Your existing values are never overwritten
963
+ # To fix a value permanently, copy the key to ${screenName}.override.yaml
797
964
  #
798
965
  # Reference in features using {{variable}} syntax
799
966
 
@@ -1 +1 @@
1
- await {{pageRoot}}.getByRole('radio', { name: '{{selectValue}}'{{#if (shouldUseExact selectorRef)}}, exact: true{{/if}} }).check();
1
+ await {{pageRoot}}.getByRole('radio', { name: '{{selectValue}}'{{#if exact}}, exact: true{{/if}} }).check();
@@ -1,5 +1,5 @@
1
1
  {{#if name}}
2
- await expect(page.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if (shouldUseExact selectorRef)}}, exact: true{{/if}} }).filter({ hasText: '{{escapeQuotes dataValue}}' }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeDisabled();
2
+ await expect(page.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if exact}}, exact: true{{/if}} }).filter({ hasText: '{{escapeQuotes dataValue}}' }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeDisabled();
3
3
  {{else}}
4
- await expect(page.getByRole('{{role}}'{{#if (shouldUseExact selectorRef)}}, { exact: true }{{/if}}).filter({ hasText: '{{escapeQuotes dataValue}}' }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeDisabled();
4
+ await expect(page.getByRole('{{role}}'{{#if exact}}, { exact: true }{{/if}}).filter({ hasText: '{{escapeQuotes dataValue}}' }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeDisabled();
5
5
  {{/if}}
@@ -1,5 +1,5 @@
1
1
  {{#if name}}
2
- await expect(page.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if (shouldUseExact selectorRef)}}, exact: true{{/if}} }).filter({ hasText: '{{escapeQuotes dataValue}}' }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeHidden();
2
+ await expect(page.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if exact}}, exact: true{{/if}} }).filter({ hasText: '{{escapeQuotes dataValue}}' }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeHidden();
3
3
  {{else}}
4
- await expect(page.getByRole('{{role}}'{{#if (shouldUseExact selectorRef)}}, { exact: true }{{/if}}).filter({ hasText: '{{escapeQuotes dataValue}}' }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeHidden();
4
+ await expect(page.getByRole('{{role}}'{{#if exact}}, { exact: true }{{/if}}).filter({ hasText: '{{escapeQuotes dataValue}}' }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeHidden();
5
5
  {{/if}}
@@ -1,5 +1,9 @@
1
1
  {{#if name}}
2
- await expect(page.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if (shouldUseExact selectorRef)}}, exact: true{{/if}} }).filter({ hasText: '{{escapeQuotes dataValue}}' }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeVisible();
2
+ {{#if dataValue}}
3
+ await expect(page.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if exact}}, exact: true{{/if}} }).filter({ hasText: '{{escapeQuotes dataValue}}' }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeVisible();
3
4
  {{else}}
4
- await expect(page.getByRole('{{role}}'{{#if (shouldUseExact selectorRef)}}, { exact: true }{{/if}}).filter({ hasText: '{{escapeQuotes dataValue}}' }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeVisible();
5
+ await expect(page.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if exact}}, exact: true{{/if}} }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeVisible();
6
+ {{/if}}
7
+ {{else}}
8
+ await expect(page.getByRole('{{role}}'{{#if exact}}, { exact: true }{{/if}}).filter({ hasText: '{{escapeQuotes dataValue}}' }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeVisible();
5
9
  {{/if}}
@@ -1 +1 @@
1
- await expect(page.getByText('{{escapeQuotes selectorRef}}').filter({ hasText: {{#if (eq selectorValue "")}}/.*{{escapeRegex dataValue}}$/{{else}}'{{escapeQuotes dataValue}}'{{/if}} }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeVisible();
1
+ await expect(page.getByText('{{escapeQuotes selectorValue}}').filter({ hasText: /.*{{escapeRegex dataValue}}$/ }){{#if (gt nth 0)}}.nth({{subtract nth 1}}){{/if}}).toBeVisible();
@@ -1 +1 @@
1
- {{pageRoot}}.getByLabel('{{escapeQuotes value}}'{{#if (shouldUseExact selectorRef)}}, { exact: true }{{/if}})
1
+ {{pageRoot}}.getByLabel('{{escapeQuotes value}}'{{#if exact}}, { exact: true }{{/if}})
@@ -1 +1 @@
1
- {{pageRoot}}.getByPlaceholder('{{escapeQuotes value}}'{{#if (shouldUseExact selectorRef)}}, { exact: true }{{/if}})
1
+ {{pageRoot}}.getByPlaceholder('{{escapeQuotes value}}'{{#if exact}}, { exact: true }{{/if}})
@@ -1 +1 @@
1
- {{#if name}}{{pageRoot}}.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if (shouldUseExact selectorRef)}}, exact: true{{/if}} }){{else}}{{pageRoot}}.getByRole('{{role}}'{{#if (shouldUseExact selectorRef)}}, { exact: true }{{/if}}){{/if}}
1
+ {{#if name}}{{pageRoot}}.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if exact}}, exact: true{{/if}} }){{else}}{{pageRoot}}.getByRole('{{role}}'{{#if exact}}, { exact: true }{{/if}}){{/if}}
@@ -1 +1 @@
1
- {{pageRoot}}.getByText('{{escapeQuotes value}}'{{#if (shouldUseExact selectorRef)}}, { exact: true }{{/if}})
1
+ {{pageRoot}}.getByText('{{escapeQuotes value}}'{{#if exact}}, { exact: true }{{/if}})
@@ -223,11 +223,14 @@ export const assertionPatterns: StepPattern[] = [
223
223
 
224
224
  // Check if it's a role-based selector with data
225
225
  if (resolved.strategy === 'role' && resolved.role) {
226
- // Use role-based selector with filter for role types
226
+ const hasName = resolved.name && resolved.name.trim();
227
+ // For img role: images have no text content, so put dataValue in name instead of filter
228
+ const isImgRole = resolved.role === 'img';
227
229
  const code = context.templateEngine.renderStep('visible-with-role-variable-assertion', {
228
230
  role: resolved.role,
229
- name: resolved.name && resolved.name.trim() ? resolved.name : undefined,
230
- dataValue,
231
+ name: isImgRole ? (hasName ? resolved.name : dataValue) : (hasName ? resolved.name : undefined),
232
+ dataValue: isImgRole ? undefined : dataValue,
233
+ exact: resolved.exact || false,
231
234
  nth: resolved.nth || 0,
232
235
  });
233
236
  return {
@@ -80,11 +80,10 @@ export class TemplateEngine {
80
80
  });
81
81
 
82
82
  // Check if selector name should use exact: true
83
- // Condition: length < 6 chars AND does NOT contain space
83
+ // DEPRECATED: Use the `exact` field from selector YAML instead.
84
+ // Kept for backward compatibility with custom templates.
84
85
  Handlebars.registerHelper('shouldUseExact', function(selectorRef: string) {
85
- if (!selectorRef) return false;
86
- const selectorName = selectorRef.split('.')[0].trim();
87
- return selectorName.length < 6 && !selectorName.includes(' ');
86
+ return false;
88
87
  });
89
88
 
90
89
  // Switch/case helpers for cleaner strategy routing
@@ -95,20 +95,31 @@ export class DataResolver {
95
95
  return this.dataCache.get(fileName)!;
96
96
  }
97
97
 
98
- const filePath = this.findDataFilePath(fileName);
99
-
100
- if (!filePath) {
101
- const possiblePaths = this.getPossibleDataPaths(fileName);
98
+ // Merge base + override: base provides defaults, override wins on conflict
99
+ const possiblePaths = this.getPossibleDataPaths(fileName);
100
+ let merged: any = {};
101
+ let found = false;
102
+
103
+ // Load in reverse priority order (base first, then overrides on top)
104
+ for (const p of [...possiblePaths].reverse()) {
105
+ if (fs.existsSync(p)) {
106
+ const content = fs.readFileSync(p, 'utf-8');
107
+ const data = yaml.parse(content);
108
+ if (data && typeof data === 'object' && Object.keys(data).length > 0) {
109
+ merged = { ...merged, ...data };
110
+ found = true;
111
+ }
112
+ }
113
+ }
114
+
115
+ if (!found) {
102
116
  throw new Error(
103
117
  `Data file not found. Tried:\n${possiblePaths.join('\n')}`
104
118
  );
105
119
  }
106
120
 
107
- const content = fs.readFileSync(filePath, 'utf-8');
108
- const data = yaml.parse(content);
109
-
110
- this.dataCache.set(fileName, data);
111
- return data;
121
+ this.dataCache.set(fileName, merged);
122
+ return merged;
112
123
  }
113
124
 
114
125
  /**
@@ -194,6 +194,7 @@ interface SelectorEntry {
194
194
  value?: string; // Natural language value for the locator (e.g., 'button', 'link' for role)
195
195
  name?: string; // Accessible name for role-based selectors (e.g., 'Login', 'Submit')
196
196
  nth?: number; // Element index (0 = no index, 1+ = append .nth())
197
+ exact?: boolean; // Whether to use exact matching (default: false)
197
198
  }
198
199
 
199
200
  // New selector file structure: flat key-value pairs
@@ -219,6 +220,7 @@ export interface ResolvedSelector {
219
220
  name?: string; // For accessible name in role selectors
220
221
  locator?: string; // CSS locator for fallback
221
222
  nth?: number; // Index for .nth() if multiple matches exist
223
+ exact?: boolean; // Whether to use exact matching (default: false)
222
224
  }
223
225
 
224
226
  /**
@@ -512,6 +514,7 @@ export class SelectorResolver {
512
514
  // Check if name exists in entry (even if empty string), use it; otherwise use originalLabel
513
515
  const name = entry.name !== undefined && entry.name !== null ? entry.name : originalLabel;
514
516
  const nth = entry.nth || 0;
517
+ const exact = entry.exact === true;
515
518
 
516
519
  // If type is 'locator', use locator field (or value as fallback) as the CSS locator directly
517
520
  if (type === 'locator') {
@@ -541,6 +544,7 @@ export class SelectorResolver {
541
544
  strategy: 'placeholder',
542
545
  value,
543
546
  nth,
547
+ exact,
544
548
  };
545
549
 
546
550
  case 'role':
@@ -550,6 +554,7 @@ export class SelectorResolver {
550
554
  name: name, // Use the name field for accessible name
551
555
  value,
552
556
  nth,
557
+ exact,
553
558
  };
554
559
 
555
560
  case 'testid':
@@ -564,6 +569,7 @@ export class SelectorResolver {
564
569
  strategy: 'label',
565
570
  value,
566
571
  nth,
572
+ exact,
567
573
  };
568
574
 
569
575
  case 'text':
@@ -571,6 +577,7 @@ export class SelectorResolver {
571
577
  strategy: 'text',
572
578
  value,
573
579
  nth,
580
+ exact,
574
581
  };
575
582
 
576
583
  default:
@@ -579,6 +586,7 @@ export class SelectorResolver {
579
586
  strategy: 'placeholder',
580
587
  value,
581
588
  nth,
589
+ exact,
582
590
  };
583
591
  }
584
592
  }
@@ -36,7 +36,7 @@ export class CLIAdapter implements InputAdapter {
36
36
  program
37
37
  .name('sungen')
38
38
  .description('AI-Native E2E Test Generator - Generate Playwright tests from Gherkin features')
39
- .version('1.0.17');
39
+ .version('1.0.19');
40
40
 
41
41
  // Global options
42
42
  program
@@ -84,6 +84,9 @@ export class CLIAdapter implements InputAdapter {
84
84
  .option('--file <path>', 'Single feature file path (generates selectors next to features folder)')
85
85
  .option('-o, --output <dir>', 'Output directory for generated files')
86
86
  .option('-f, --force', 'Force overwrite existing YAML files')
87
+ .option('--scan-live', 'Run live-scan before mapping to detect real selectors')
88
+ .option('--headed', 'Launch browser in headed mode (only with --scan-live)', false)
89
+ .option('--auth-dir <path>', 'Auth storage state directory (only with --scan-live)', 'specs/.auth')
87
90
  .option('-v, --verbose', 'Detailed output');
88
91
  }
89
92
 
@@ -121,8 +124,8 @@ export class CLIAdapter implements InputAdapter {
121
124
  */
122
125
  addInitCommand(program: Command): Command {
123
126
  return program
124
- .command('init')
125
- .description('Initialize Sungen project (create essential directories and config)');
127
+ .command('init [projectName]')
128
+ .description('Initialize Sungen project (optionally create and initialize in a new project folder)');
126
129
  }
127
130
 
128
131
  /**
@@ -147,6 +150,19 @@ export class CLIAdapter implements InputAdapter {
147
150
  .option('-v, --verbose', 'Show detailed output');
148
151
  }
149
152
 
153
+ /**
154
+ * Add live-scan command
155
+ */
156
+ addLiveScanCommand(program: Command): Command {
157
+ return program
158
+ .command('live-scan')
159
+ .description('Scan a live site to discover real selectors for Gherkin element references')
160
+ .requiredOption('-s, --screen <name>', 'Screen name to scan')
161
+ .option('--url <baseUrl>', 'Base URL of the running site')
162
+ .option('--headed', 'Launch browser in headed (visible) mode')
163
+ .option('--auth-dir <path>', 'Directory containing auth storage state files (default: specs/.auth)');
164
+ }
165
+
150
166
  /**
151
167
  * Add add --screen command
152
168
  */
@@ -7,20 +7,33 @@ import * as fs from 'fs';
7
7
  import * as path from 'path';
8
8
 
9
9
  export class ProjectInitializer {
10
+ private baseCwd: string;
10
11
  private cwd: string;
11
12
  private createdItems: string[] = [];
12
13
  private skippedItems: string[] = [];
13
14
 
14
15
  constructor() {
15
- this.cwd = process.cwd();
16
+ this.baseCwd = process.cwd();
17
+ this.cwd = this.baseCwd;
16
18
  }
17
19
 
18
20
  /**
19
21
  * Initialize project with essential directories and config
20
22
  */
21
- async initialize(): Promise<void> {
23
+ async initialize(projectName?: string): Promise<void> {
24
+ const normalizedProjectName = projectName?.trim();
25
+ this.cwd = normalizedProjectName
26
+ ? path.resolve(this.baseCwd, normalizedProjectName)
27
+ : this.baseCwd;
28
+ this.createdItems = [];
29
+ this.skippedItems = [];
30
+
22
31
  console.log('🚀 Initializing Sungen project...\n');
23
32
 
33
+ if (normalizedProjectName) {
34
+ this.createProjectRoot(normalizedProjectName);
35
+ }
36
+
24
37
  // Create directories
25
38
  this.createDirectories();
26
39
 
@@ -34,7 +47,19 @@ export class ProjectInitializer {
34
47
  this.createReadme();
35
48
 
36
49
  // Display summary
37
- this.displaySummary();
50
+ this.displaySummary(normalizedProjectName);
51
+ }
52
+
53
+ /**
54
+ * Create project root directory when project name is provided
55
+ */
56
+ private createProjectRoot(projectName: string): void {
57
+ if (!fs.existsSync(this.cwd)) {
58
+ fs.mkdirSync(this.cwd, { recursive: true });
59
+ this.createdItems.push(`${projectName}/`);
60
+ } else {
61
+ this.skippedItems.push(`${projectName}/`);
62
+ }
38
63
  }
39
64
 
40
65
  /**
@@ -128,7 +153,7 @@ export class ProjectInitializer {
128
153
  /**
129
154
  * Display initialization summary
130
155
  */
131
- private displaySummary(): void {
156
+ private displaySummary(projectName?: string): void {
132
157
  console.log('✅ Sungen initialized successfully!\n');
133
158
 
134
159
  if (this.createdItems.length > 0) {
@@ -149,10 +174,14 @@ export class ProjectInitializer {
149
174
  this.checkDependencies();
150
175
 
151
176
  console.log('\nNext steps:');
152
- console.log(' 1. Review README.md for workflow overview');
153
- console.log(' 2. Create your first screen with: sungen add --screen <name>');
154
- console.log(' 3. Generate mapped tests with: sungen map --screen <name> | sungen map --all');
155
- console.log(' 4. Generate tests with: sungen generate --screen <name> | sungen generate --all\n');
177
+ let stepIndex = 1;
178
+ if (projectName) {
179
+ console.log(` ${stepIndex++}. cd ${projectName}`);
180
+ }
181
+ console.log(` ${stepIndex++}. Review README.md for workflow overview`);
182
+ console.log(` ${stepIndex++}. Create your first screen with: sungen add --screen <name>`);
183
+ console.log(` ${stepIndex++}. Generate mapped tests with: sungen map --screen <name> | sungen map --all`);
184
+ console.log(` ${stepIndex++}. Generate tests with: sungen generate --screen <name> | sungen generate --all\n`);
156
185
  }
157
186
 
158
187
  /**