@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.
- package/README.md +101 -601
- package/dist/cli/commands/live-scan-command.d.ts +9 -0
- package/dist/cli/commands/live-scan-command.d.ts.map +1 -0
- package/dist/cli/commands/live-scan-command.js +72 -0
- package/dist/cli/commands/live-scan-command.js.map +1 -0
- package/dist/cli/index.js +34 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/core/live-scanner/config-reader.d.ts +10 -0
- package/dist/core/live-scanner/config-reader.d.ts.map +1 -0
- package/dist/core/live-scanner/config-reader.js +87 -0
- package/dist/core/live-scanner/config-reader.js.map +1 -0
- package/dist/core/live-scanner/element-finder.d.ts +20 -0
- package/dist/core/live-scanner/element-finder.d.ts.map +1 -0
- package/dist/core/live-scanner/element-finder.js +289 -0
- package/dist/core/live-scanner/element-finder.js.map +1 -0
- package/dist/core/live-scanner/index.d.ts +8 -0
- package/dist/core/live-scanner/index.d.ts.map +1 -0
- package/dist/core/live-scanner/index.js +33 -0
- package/dist/core/live-scanner/index.js.map +1 -0
- package/dist/core/live-scanner/matrix-reader.d.ts +17 -0
- package/dist/core/live-scanner/matrix-reader.d.ts.map +1 -0
- package/dist/core/live-scanner/matrix-reader.js +97 -0
- package/dist/core/live-scanner/matrix-reader.js.map +1 -0
- package/dist/core/live-scanner/matrix-writer.d.ts +7 -0
- package/dist/core/live-scanner/matrix-writer.d.ts.map +1 -0
- package/dist/core/live-scanner/matrix-writer.js +92 -0
- package/dist/core/live-scanner/matrix-writer.js.map +1 -0
- package/dist/core/live-scanner/role-fallback.d.ts +15 -0
- package/dist/core/live-scanner/role-fallback.d.ts.map +1 -0
- package/dist/core/live-scanner/role-fallback.js +45 -0
- package/dist/core/live-scanner/role-fallback.js.map +1 -0
- package/dist/core/live-scanner/scanner.d.ts +13 -0
- package/dist/core/live-scanner/scanner.d.ts.map +1 -0
- package/dist/core/live-scanner/scanner.js +225 -0
- package/dist/core/live-scanner/scanner.js.map +1 -0
- package/dist/core/live-scanner/step-replayer.d.ts +26 -0
- package/dist/core/live-scanner/step-replayer.d.ts.map +1 -0
- package/dist/core/live-scanner/step-replayer.js +184 -0
- package/dist/core/live-scanner/step-replayer.js.map +1 -0
- package/dist/core/live-scanner/types.d.ts +50 -0
- package/dist/core/live-scanner/types.d.ts.map +1 -0
- package/dist/core/live-scanner/types.js +14 -0
- package/dist/core/live-scanner/types.js.map +1 -0
- package/dist/generators/cli.js +1 -1
- package/dist/generators/scaffold-generator/index.d.ts +20 -2
- package/dist/generators/scaffold-generator/index.d.ts.map +1 -1
- package/dist/generators/scaffold-generator/index.js +170 -10
- package/dist/generators/scaffold-generator/index.js.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/radio-select-action.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +2 -2
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +2 -2
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-role-variable-assertion.hbs +6 -2
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-variable-assertion.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/label.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/placeholder.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/role.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/text.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 +6 -3
- package/dist/generators/test-generator/patterns/assertion-patterns.js.map +1 -1
- package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
- package/dist/generators/test-generator/template-engine.js +3 -5
- package/dist/generators/test-generator/template-engine.js.map +1 -1
- package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/data-resolver.js +18 -7
- package/dist/generators/test-generator/utils/data-resolver.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 +6 -0
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/dist/input/cli-adapter.d.ts +4 -0
- package/dist/input/cli-adapter.d.ts.map +1 -1
- package/dist/input/cli-adapter.js +18 -3
- package/dist/input/cli-adapter.js.map +1 -1
- package/dist/orchestrator/project-initializer.d.ts +6 -1
- package/dist/orchestrator/project-initializer.d.ts.map +1 -1
- package/dist/orchestrator/project-initializer.js +34 -8
- package/dist/orchestrator/project-initializer.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/live-scan-command.ts +79 -0
- package/src/cli/index.ts +40 -7
- package/src/config/default.config.yaml +6 -0
- package/src/core/live-scanner/config-reader.ts +57 -0
- package/src/core/live-scanner/element-finder.ts +333 -0
- package/src/core/live-scanner/index.ts +7 -0
- package/src/core/live-scanner/matrix-reader.ts +70 -0
- package/src/core/live-scanner/matrix-writer.ts +66 -0
- package/src/core/live-scanner/role-fallback.ts +43 -0
- package/src/core/live-scanner/scanner.ts +231 -0
- package/src/core/live-scanner/step-replayer.ts +219 -0
- package/src/core/live-scanner/types.ts +56 -0
- package/src/generators/cli.ts +1 -1
- package/src/generators/scaffold-generator/index.ts +181 -14
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/radio-select-action.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +2 -2
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +2 -2
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-role-variable-assertion.hbs +6 -2
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-variable-assertion.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/label.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/placeholder.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/role.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/text.hbs +1 -1
- package/src/generators/test-generator/patterns/assertion-patterns.ts +6 -3
- package/src/generators/test-generator/template-engine.ts +3 -4
- package/src/generators/test-generator/utils/data-resolver.ts +20 -9
- package/src/generators/test-generator/utils/selector-resolver.ts +8 -0
- package/src/input/cli-adapter.ts +19 -3
- 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
|
-
|
|
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(
|
|
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
|
|
674
|
-
|
|
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(
|
|
818
|
+
elementCount: Object.keys(liveScanEnriched).length,
|
|
684
819
|
dataRefCount: dataRefs.length,
|
|
685
820
|
selectorsSkipped: selectorsExists && !forceOverwrite,
|
|
686
|
-
|
|
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
|
|
794
|
-
#
|
|
795
|
-
#
|
|
796
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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}}'
|
|
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
|
|
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
|
|
1
|
+
{{pageRoot}}.getByLabel('{{escapeQuotes value}}'{{#if exact}}, { exact: true }{{/if}})
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{{pageRoot}}.getByPlaceholder('{{escapeQuotes value}}'{{#if
|
|
1
|
+
{{pageRoot}}.getByPlaceholder('{{escapeQuotes value}}'{{#if exact}}, { exact: true }{{/if}})
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{{#if name}}{{pageRoot}}.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
}
|
package/src/input/cli-adapter.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
/**
|