playwright-mimic 0.1.1 → 0.1.2
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 +134 -72
- package/dist/index.d.ts +1 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -4
- package/dist/index.js.map +1 -1
- package/dist/mimic/annotations.d.ts +2 -1
- package/dist/mimic/annotations.d.ts.map +1 -1
- package/dist/mimic/annotations.js +10 -4
- package/dist/mimic/annotations.js.map +1 -1
- package/dist/mimic/cli.js +1 -1
- package/dist/mimic/cli.js.map +1 -1
- package/dist/mimic/click.d.ts +4 -4
- package/dist/mimic/click.d.ts.map +1 -1
- package/dist/mimic/click.js +233 -118
- package/dist/mimic/click.js.map +1 -1
- package/dist/mimic/forms.d.ts +11 -6
- package/dist/mimic/forms.d.ts.map +1 -1
- package/dist/mimic/forms.js +371 -124
- package/dist/mimic/forms.js.map +1 -1
- package/dist/mimic/markers.d.ts +133 -0
- package/dist/mimic/markers.d.ts.map +1 -0
- package/dist/mimic/markers.js +589 -0
- package/dist/mimic/markers.js.map +1 -0
- package/dist/mimic/navigation.d.ts.map +1 -1
- package/dist/mimic/navigation.js +29 -10
- package/dist/mimic/navigation.js.map +1 -1
- package/dist/mimic/playwrightCodeGenerator.d.ts +55 -0
- package/dist/mimic/playwrightCodeGenerator.d.ts.map +1 -0
- package/dist/mimic/playwrightCodeGenerator.js +270 -0
- package/dist/mimic/playwrightCodeGenerator.js.map +1 -0
- package/dist/mimic/replay.d.ts.map +1 -1
- package/dist/mimic/replay.js +45 -36
- package/dist/mimic/replay.js.map +1 -1
- package/dist/mimic/schema/action.d.ts +26 -26
- package/dist/mimic/schema/action.d.ts.map +1 -1
- package/dist/mimic/schema/action.js +13 -31
- package/dist/mimic/schema/action.js.map +1 -1
- package/dist/mimic/selector.d.ts +6 -2
- package/dist/mimic/selector.d.ts.map +1 -1
- package/dist/mimic/selector.js +681 -269
- package/dist/mimic/selector.js.map +1 -1
- package/dist/mimic/selectorDescriptor.d.ts +15 -3
- package/dist/mimic/selectorDescriptor.d.ts.map +1 -1
- package/dist/mimic/selectorDescriptor.js +25 -2
- package/dist/mimic/selectorDescriptor.js.map +1 -1
- package/dist/mimic/selectorSerialization.d.ts +5 -17
- package/dist/mimic/selectorSerialization.d.ts.map +1 -1
- package/dist/mimic/selectorSerialization.js +4 -142
- package/dist/mimic/selectorSerialization.js.map +1 -1
- package/dist/mimic/selectorTypes.d.ts +24 -102
- package/dist/mimic/selectorTypes.d.ts.map +1 -1
- package/dist/mimic/selectorUtils.d.ts +33 -7
- package/dist/mimic/selectorUtils.d.ts.map +1 -1
- package/dist/mimic/selectorUtils.js +159 -52
- package/dist/mimic/selectorUtils.js.map +1 -1
- package/dist/mimic/storage.d.ts +43 -8
- package/dist/mimic/storage.d.ts.map +1 -1
- package/dist/mimic/storage.js +258 -46
- package/dist/mimic/storage.js.map +1 -1
- package/dist/mimic/types.d.ts +38 -16
- package/dist/mimic/types.d.ts.map +1 -1
- package/dist/mimic.d.ts +1 -0
- package/dist/mimic.d.ts.map +1 -1
- package/dist/mimic.js +240 -84
- package/dist/mimic.js.map +1 -1
- package/package.json +27 -6
package/dist/mimic/selector.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { verifySelectorUniqueness } from './
|
|
1
|
+
import { verifySelectorUniqueness, getMimicIdFromLocator } from './selectorUtils.js';
|
|
2
2
|
;
|
|
3
3
|
/**
|
|
4
4
|
* Capture target elements from the page
|
|
@@ -196,12 +196,20 @@ export async function captureTargets(page, options = {}) {
|
|
|
196
196
|
}
|
|
197
197
|
/**
|
|
198
198
|
* Get all data-* attributes
|
|
199
|
+
*
|
|
200
|
+
* @param element - Element to extract data attributes from
|
|
201
|
+
* @returns Record of camelCase data attribute keys to their values
|
|
199
202
|
*/
|
|
200
203
|
function getDataset(element) {
|
|
201
204
|
const dataset = {};
|
|
202
205
|
for (const attr of element.attributes) {
|
|
203
206
|
if (attr.name.startsWith('data-')) {
|
|
204
|
-
|
|
207
|
+
// Convert data-test-id to testId (camelCase)
|
|
208
|
+
// Note: No type annotations in arrow function to avoid serialization issues in page.evaluate
|
|
209
|
+
// @param {string} _match - Full match string (unused, but required by replace callback)
|
|
210
|
+
// @param {string} letter - Captured letter group to uppercase
|
|
211
|
+
// @ts-expect-error - Type annotations removed to prevent serialization issues in browser context
|
|
212
|
+
const key = attr.name.replace(/^data-/, '').replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
|
|
205
213
|
dataset[key] = attr.value;
|
|
206
214
|
}
|
|
207
215
|
}
|
|
@@ -468,7 +476,12 @@ async function scoreElementMatch(elementIndex, locator, target) {
|
|
|
468
476
|
const dataset = {};
|
|
469
477
|
for (const attr of el.attributes) {
|
|
470
478
|
if (attr.name.startsWith('data-')) {
|
|
471
|
-
|
|
479
|
+
// Convert data-test-id to testId (camelCase)
|
|
480
|
+
// Note: No type annotations in arrow function to avoid serialization issues in locator.evaluate
|
|
481
|
+
// @param {string} _match - Full match string (unused, but required by replace callback)
|
|
482
|
+
// @param {string} letter - Captured letter group to uppercase
|
|
483
|
+
// @ts-expect-error - Type annotations removed to prevent serialization issues in browser context
|
|
484
|
+
const key = attr.name.replace(/^data-/, '').replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
|
|
472
485
|
dataset[key] = attr.value;
|
|
473
486
|
}
|
|
474
487
|
}
|
|
@@ -707,132 +720,466 @@ export async function buildSelectorForTarget(page, target) {
|
|
|
707
720
|
* 8. CSS fallback (ID, name, tag + nth-of-type)
|
|
708
721
|
*
|
|
709
722
|
* @param locator - Playwright Locator pointing to the target element
|
|
723
|
+
* @param options - Optional configuration
|
|
724
|
+
* @param options.timeout - Timeout in milliseconds for element operations (default: 30000 = 30 seconds)
|
|
710
725
|
* @returns Promise resolving to SelectorDescriptor that uniquely identifies the element
|
|
711
726
|
*/
|
|
712
|
-
export async function generateBestSelectorForElement(locator) {
|
|
727
|
+
export async function generateBestSelectorForElement(locator, options) {
|
|
713
728
|
const page = locator.page();
|
|
714
|
-
|
|
729
|
+
// Default to 30 seconds (30000ms) for selector generation
|
|
730
|
+
// This provides reasonable timeout while avoiding excessive waits
|
|
731
|
+
const timeout = options?.timeout ?? 30000;
|
|
732
|
+
// Check if page is closed before attempting any operations
|
|
733
|
+
if (page.isClosed()) {
|
|
734
|
+
throw new Error('Cannot generate selector: page, context or browser has been closed. This may happen if a previous action closed the page unexpectedly.');
|
|
735
|
+
}
|
|
736
|
+
// Check if locator matches any elements before attempting to get element handle
|
|
737
|
+
// This provides faster failure when element doesn't exist (avoids waiting for evaluate timeouts)
|
|
738
|
+
let count;
|
|
739
|
+
try {
|
|
740
|
+
count = await locator.count();
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
// If page closed, throw a more descriptive error
|
|
744
|
+
if (page.isClosed() || (error?.message && error.message.includes('closed'))) {
|
|
745
|
+
throw new Error('Cannot get element count: page, context or browser has been closed. This may happen if a previous action closed the page unexpectedly.');
|
|
746
|
+
}
|
|
747
|
+
throw error;
|
|
748
|
+
}
|
|
749
|
+
if (count === 0) {
|
|
750
|
+
throw new Error('Cannot generate selector: element not found. The locator does not match any elements on the page.');
|
|
751
|
+
}
|
|
752
|
+
// Get mimic ID from the locator using the markers system
|
|
753
|
+
// This replaces the old targetElementHandle approach for verification
|
|
754
|
+
// Only call this after we've verified the element exists (count > 0)
|
|
755
|
+
const targetMimicId = await getMimicIdFromLocator(locator);
|
|
756
|
+
// If no mimic ID found, we can't verify uniqueness with markers
|
|
757
|
+
// This could happen if markers haven't been installed yet
|
|
758
|
+
// We'll still try to generate a selector, but verification will be less strict
|
|
759
|
+
// Get element handle from locator for page.evaluate() calls
|
|
760
|
+
// We still need this for browser context evaluation
|
|
761
|
+
let targetElementHandle;
|
|
762
|
+
try {
|
|
763
|
+
targetElementHandle = await locator.elementHandle({ timeout });
|
|
764
|
+
}
|
|
765
|
+
catch (error) {
|
|
766
|
+
// Check if page closed during element handle retrieval
|
|
767
|
+
if (page.isClosed() || (error?.message && error.message.includes('closed'))) {
|
|
768
|
+
throw new Error('Cannot get element handle: page, context or browser has been closed. This may happen if a previous action closed the page unexpectedly.');
|
|
769
|
+
}
|
|
770
|
+
throw error;
|
|
771
|
+
}
|
|
715
772
|
if (!targetElementHandle) {
|
|
716
773
|
throw new Error('Cannot get element handle from locator');
|
|
717
774
|
}
|
|
775
|
+
// Double-check page is still open before evaluate
|
|
776
|
+
if (page.isClosed()) {
|
|
777
|
+
throw new Error('Page closed before evaluation: page, context or browser has been closed. This may happen if a previous action closed the page unexpectedly.');
|
|
778
|
+
}
|
|
718
779
|
// Get element information in browser context
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
const style = win.getComputedStyle(
|
|
729
|
-
|
|
730
|
-
|
|
780
|
+
let elementInfo;
|
|
781
|
+
try {
|
|
782
|
+
elementInfo = await page.evaluate((element) => {
|
|
783
|
+
// @ts-ignore - window is available in browser context
|
|
784
|
+
const win = window;
|
|
785
|
+
// @ts-ignore - document is available in browser context
|
|
786
|
+
const doc = document;
|
|
787
|
+
// Inline all logic to avoid nested functions that get transformed by toolchain
|
|
788
|
+
// Get visible text - inline logic
|
|
789
|
+
const style = win.getComputedStyle(element);
|
|
790
|
+
const isVisible = style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
791
|
+
const text = isVisible ? (element.textContent || '').trim().replace(/\s+/g, ' ') : '';
|
|
792
|
+
// Get label - inline logic
|
|
793
|
+
let label = null;
|
|
794
|
+
const ariaLabelAttr = element.getAttribute('aria-label');
|
|
795
|
+
if (ariaLabelAttr) {
|
|
796
|
+
label = ariaLabelAttr.trim();
|
|
731
797
|
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
if (
|
|
745
|
-
|
|
798
|
+
else {
|
|
799
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
800
|
+
if (labelledBy) {
|
|
801
|
+
const labelEl = doc.getElementById(labelledBy);
|
|
802
|
+
if (labelEl)
|
|
803
|
+
label = (labelEl.textContent || '').trim();
|
|
804
|
+
}
|
|
805
|
+
if (!label && element.id) {
|
|
806
|
+
const labelFor = doc.querySelector('label[for="' + element.id + '"]');
|
|
807
|
+
if (labelFor)
|
|
808
|
+
label = (labelFor.textContent || '').trim();
|
|
809
|
+
}
|
|
810
|
+
if (!label) {
|
|
811
|
+
const parentLabel = element.closest('label');
|
|
812
|
+
if (parentLabel)
|
|
813
|
+
label = (parentLabel.textContent || '').trim();
|
|
814
|
+
}
|
|
746
815
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
816
|
+
// Infer role - inline logic
|
|
817
|
+
let role = element.getAttribute('role');
|
|
818
|
+
if (!role) {
|
|
819
|
+
const tag = element.tagName.toLowerCase();
|
|
820
|
+
if (tag === 'button') {
|
|
821
|
+
role = 'button';
|
|
822
|
+
}
|
|
823
|
+
else if (tag === 'a') {
|
|
824
|
+
role = 'link';
|
|
825
|
+
}
|
|
826
|
+
else if (tag === 'input') {
|
|
827
|
+
const inputType = element.type;
|
|
828
|
+
if (inputType === 'button' || inputType === 'submit' || inputType === 'reset') {
|
|
829
|
+
role = 'button';
|
|
830
|
+
}
|
|
831
|
+
else if (inputType === 'checkbox') {
|
|
832
|
+
role = 'checkbox';
|
|
833
|
+
}
|
|
834
|
+
else if (inputType === 'radio') {
|
|
835
|
+
role = 'radio';
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
role = 'textbox';
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
else if (tag === 'select') {
|
|
842
|
+
role = 'combobox';
|
|
843
|
+
}
|
|
844
|
+
else if (tag === 'textarea') {
|
|
845
|
+
role = 'textbox';
|
|
846
|
+
}
|
|
847
|
+
else if (tag === 'img') {
|
|
848
|
+
role = 'img';
|
|
849
|
+
}
|
|
751
850
|
}
|
|
752
|
-
|
|
753
|
-
if (parentLabel)
|
|
754
|
-
return (parentLabel.textContent || '').trim();
|
|
755
|
-
return null;
|
|
756
|
-
}
|
|
757
|
-
/**
|
|
758
|
-
* Infer ARIA role from element
|
|
759
|
-
*/
|
|
760
|
-
function inferRole(el) {
|
|
761
|
-
const explicitRole = el.getAttribute('role');
|
|
762
|
-
if (explicitRole)
|
|
763
|
-
return explicitRole;
|
|
764
|
-
const tag = el.tagName.toLowerCase();
|
|
765
|
-
const roleMap = {
|
|
766
|
-
'button': 'button',
|
|
767
|
-
'a': 'link',
|
|
768
|
-
'input': el.type === 'button' || el.type === 'submit' || el.type === 'reset' ? 'button' :
|
|
769
|
-
el.type === 'checkbox' ? 'checkbox' :
|
|
770
|
-
el.type === 'radio' ? 'radio' : 'textbox',
|
|
771
|
-
'select': 'combobox',
|
|
772
|
-
'textarea': 'textbox',
|
|
773
|
-
'img': 'img',
|
|
774
|
-
};
|
|
775
|
-
return roleMap[tag] || null;
|
|
776
|
-
}
|
|
777
|
-
/**
|
|
778
|
-
* Get dataset (data-* attributes)
|
|
779
|
-
*/
|
|
780
|
-
function getDataset(el) {
|
|
851
|
+
// Get dataset - inline logic with simple replace (no arrow function)
|
|
781
852
|
const dataset = {};
|
|
782
|
-
for (
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
853
|
+
for (let i = 0; i < element.attributes.length; i++) {
|
|
854
|
+
const attr = element.attributes[i];
|
|
855
|
+
if (attr && attr.name && attr.name.startsWith('data-')) {
|
|
856
|
+
// Convert data-test-id to testId (camelCase) - using simple string manipulation
|
|
857
|
+
let key = attr.name.replace(/^data-/, '');
|
|
858
|
+
// Replace -x with X (camelCase conversion)
|
|
859
|
+
let result = '';
|
|
860
|
+
let capitalizeNext = false;
|
|
861
|
+
for (let j = 0; j < key.length; j++) {
|
|
862
|
+
const char = key[j];
|
|
863
|
+
if (char) {
|
|
864
|
+
if (char === '-') {
|
|
865
|
+
capitalizeNext = true;
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
result += capitalizeNext ? char.toUpperCase() : char;
|
|
869
|
+
capitalizeNext = false;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
dataset[result] = attr.value;
|
|
786
874
|
}
|
|
787
875
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
*/
|
|
793
|
-
function getNthOfType(el) {
|
|
794
|
-
const tag = el.tagName;
|
|
795
|
-
let index = 1;
|
|
796
|
-
let sibling = el.previousElementSibling;
|
|
876
|
+
// Get nth-of-type - inline logic
|
|
877
|
+
const tagName = element.tagName;
|
|
878
|
+
let nthOfType = 1;
|
|
879
|
+
let sibling = element.previousElementSibling;
|
|
797
880
|
while (sibling) {
|
|
798
|
-
if (sibling.tagName ===
|
|
799
|
-
|
|
881
|
+
if (sibling.tagName === tagName) {
|
|
882
|
+
nthOfType++;
|
|
800
883
|
}
|
|
801
884
|
sibling = sibling.previousElementSibling;
|
|
802
885
|
}
|
|
803
|
-
return
|
|
886
|
+
return {
|
|
887
|
+
tag: element.tagName.toLowerCase(),
|
|
888
|
+
text: text,
|
|
889
|
+
id: element.id || null,
|
|
890
|
+
role: role,
|
|
891
|
+
label: label,
|
|
892
|
+
ariaLabel: element.getAttribute('aria-label') || null,
|
|
893
|
+
placeholder: element.getAttribute('placeholder') || null,
|
|
894
|
+
alt: element.getAttribute('alt') || null,
|
|
895
|
+
title: element.getAttribute('title') || null,
|
|
896
|
+
typeAttr: element.type || null,
|
|
897
|
+
nameAttr: element.getAttribute('name') || null,
|
|
898
|
+
dataset: dataset,
|
|
899
|
+
nthOfType: nthOfType,
|
|
900
|
+
};
|
|
901
|
+
}, targetElementHandle);
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
// Enhanced error reporting to help identify the exact location of the issue
|
|
905
|
+
const errorMessage = error?.message || String(error);
|
|
906
|
+
const errorStack = error?.stack || '';
|
|
907
|
+
// Check if page is closed - this is a common issue
|
|
908
|
+
if (page.isClosed() || errorMessage.includes('closed') || errorMessage.includes('Target page')) {
|
|
909
|
+
throw new Error(`Cannot generate selector: page, context or browser has been closed. ` +
|
|
910
|
+
`This may happen if a previous action (like form submission or navigation) closed the page unexpectedly. ` +
|
|
911
|
+
`Original error: ${errorMessage}`);
|
|
804
912
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
913
|
+
// Check if this is the __name error
|
|
914
|
+
if (errorMessage.includes('__name') || errorStack.includes('__name')) {
|
|
915
|
+
console.error('Error in generateBestSelectorForElement - page.evaluate failed');
|
|
916
|
+
console.error('Error message:', errorMessage);
|
|
917
|
+
console.error('Error stack:', errorStack);
|
|
918
|
+
console.error('This error typically occurs when TypeScript type annotations are used in arrow functions within page.evaluate');
|
|
919
|
+
console.error('Please check for any remaining type annotations in the evaluate function');
|
|
920
|
+
}
|
|
921
|
+
throw new Error(`Failed to evaluate element in browser context: ${errorMessage}. ` +
|
|
922
|
+
`This may be caused by TypeScript type annotations in the evaluate function. ` +
|
|
923
|
+
`Original error: ${errorStack}`);
|
|
924
|
+
}
|
|
925
|
+
// Find the best parent/ancestor selector by checking multiple levels and selector types
|
|
926
|
+
// This handles cases where the locator was created from a parent chain
|
|
927
|
+
// Returns the best parent selector found, or null if none found
|
|
928
|
+
const findBestParentSelector = async () => {
|
|
929
|
+
// Check if page is still open before parent evaluation
|
|
930
|
+
if (page.isClosed()) {
|
|
931
|
+
throw new Error('Page closed during parent selector search: page, context or browser has been closed.');
|
|
932
|
+
}
|
|
933
|
+
let parentCandidates;
|
|
934
|
+
try {
|
|
935
|
+
parentCandidates = await page.evaluate((element) => {
|
|
936
|
+
const candidates = [];
|
|
937
|
+
let current = element.parentElement;
|
|
938
|
+
let depth = 0;
|
|
939
|
+
const maxDepth = 10; // Limit depth to avoid going too far up
|
|
940
|
+
while (current && current.tagName !== 'BODY' && current.tagName !== 'HTML' && depth < maxDepth) {
|
|
941
|
+
// Get all relevant information about this ancestor
|
|
942
|
+
const testId = current.getAttribute('data-testid');
|
|
943
|
+
const role = current.getAttribute('role');
|
|
944
|
+
const id = current.id;
|
|
945
|
+
const ariaLabel = current.getAttribute('aria-label');
|
|
946
|
+
const label = current.getAttribute('aria-labelledby');
|
|
947
|
+
// Get visible text for role/text matching
|
|
948
|
+
const style = window.getComputedStyle(current);
|
|
949
|
+
const isVisible = style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
950
|
+
const text = isVisible ? (current.textContent || '').trim().replace(/\s+/g, ' ').substring(0, 100) : '';
|
|
951
|
+
// Infer role if not explicitly set
|
|
952
|
+
let inferredRole = role;
|
|
953
|
+
if (!inferredRole) {
|
|
954
|
+
const tag = current.tagName.toLowerCase();
|
|
955
|
+
if (tag === 'section' || tag === 'article' || tag === 'aside' || tag === 'nav' || tag === 'main' || tag === 'header' || tag === 'footer') {
|
|
956
|
+
inferredRole = tag;
|
|
957
|
+
}
|
|
958
|
+
else if (tag === 'form') {
|
|
959
|
+
inferredRole = 'form';
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
// Get dataset
|
|
963
|
+
const dataset = {};
|
|
964
|
+
for (let i = 0; i < current.attributes.length; i++) {
|
|
965
|
+
const attr = current.attributes[i];
|
|
966
|
+
if (attr && attr.name && attr.name.startsWith('data-')) {
|
|
967
|
+
let key = attr.name.replace(/^data-/, '');
|
|
968
|
+
let result = '';
|
|
969
|
+
let capitalizeNext = false;
|
|
970
|
+
for (let j = 0; j < key.length; j++) {
|
|
971
|
+
const char = key[j];
|
|
972
|
+
if (char) {
|
|
973
|
+
if (char === '-') {
|
|
974
|
+
capitalizeNext = true;
|
|
975
|
+
}
|
|
976
|
+
else {
|
|
977
|
+
result += capitalizeNext ? char.toUpperCase() : char;
|
|
978
|
+
capitalizeNext = false;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
dataset[result] = attr.value;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
candidates.push({
|
|
986
|
+
depth,
|
|
987
|
+
testId,
|
|
988
|
+
role: inferredRole,
|
|
989
|
+
id,
|
|
990
|
+
ariaLabel,
|
|
991
|
+
label,
|
|
992
|
+
text,
|
|
993
|
+
dataset,
|
|
994
|
+
tag: current.tagName.toLowerCase(),
|
|
995
|
+
});
|
|
996
|
+
current = current.parentElement;
|
|
997
|
+
depth++;
|
|
998
|
+
}
|
|
999
|
+
return candidates;
|
|
1000
|
+
}, targetElementHandle);
|
|
1001
|
+
}
|
|
1002
|
+
catch (error) {
|
|
1003
|
+
// If page closed during parent evaluation, just return null (parent selector is optional)
|
|
1004
|
+
const errorMessage = error?.message || String(error);
|
|
1005
|
+
if (page.isClosed() || errorMessage.includes('closed') || errorMessage.includes('Target page')) {
|
|
1006
|
+
console.warn('Page closed during parent selector evaluation, skipping parent selector');
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
// For other errors, log but don't fail - parent selector is optional
|
|
1010
|
+
console.warn('Error evaluating parent candidates:', errorMessage);
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
// Safety check: ensure parentCandidates is valid
|
|
1014
|
+
if (!parentCandidates || !Array.isArray(parentCandidates)) {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
// Try to find the best parent selector, prioritizing:
|
|
1018
|
+
// 1. testid (unique)
|
|
1019
|
+
// 2. role with name/text (unique)
|
|
1020
|
+
// 3. role alone (unique)
|
|
1021
|
+
// 4. label (unique)
|
|
1022
|
+
// 5. id (CSS fallback - only if no unique selector found after searching all candidates)
|
|
1023
|
+
// Continue searching through all candidates to find the best unique selector
|
|
1024
|
+
// Track the best non-unique non-CSS selector as fallback (only if no unique selector found)
|
|
1025
|
+
let bestFallbackSelector = null;
|
|
1026
|
+
// Track CSS selectors separately - only use as last resort after exhausting all candidates
|
|
1027
|
+
let bestCssFallbackSelector = null;
|
|
1028
|
+
for (const candidate of parentCandidates) {
|
|
1029
|
+
// Priority 1: testid
|
|
1030
|
+
const dataset = candidate.dataset;
|
|
1031
|
+
if (dataset && dataset.testid) {
|
|
1032
|
+
const parentDescriptor = {
|
|
1033
|
+
type: 'testid',
|
|
1034
|
+
value: dataset.testid,
|
|
1035
|
+
};
|
|
1036
|
+
// Check if this parent selector is unique
|
|
1037
|
+
const parentLocator = page.getByTestId(dataset.testid);
|
|
1038
|
+
const count = await parentLocator.count();
|
|
1039
|
+
if (count === 1) {
|
|
1040
|
+
return parentDescriptor;
|
|
1041
|
+
}
|
|
1042
|
+
// Store as fallback if we don't have one yet
|
|
1043
|
+
if (!bestFallbackSelector) {
|
|
1044
|
+
bestFallbackSelector = parentDescriptor;
|
|
1045
|
+
}
|
|
1046
|
+
// Continue searching for a unique selector
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
// Priority 2: role with name/text
|
|
1050
|
+
if (candidate.role) {
|
|
1051
|
+
const name = candidate.ariaLabel || candidate.text;
|
|
1052
|
+
if (name && name.trim() && name.length < 100) {
|
|
1053
|
+
const parentDescriptor = {
|
|
1054
|
+
type: 'role',
|
|
1055
|
+
role: candidate.role,
|
|
1056
|
+
name: name.trim(),
|
|
1057
|
+
exact: false,
|
|
1058
|
+
};
|
|
1059
|
+
const parentLocator = page.getByRole(candidate.role, { name: name.trim() });
|
|
1060
|
+
const count = await parentLocator.count();
|
|
1061
|
+
if (count === 1) {
|
|
1062
|
+
return parentDescriptor;
|
|
1063
|
+
}
|
|
1064
|
+
// Try with exact match
|
|
1065
|
+
const parentDescriptorExact = {
|
|
1066
|
+
type: 'role',
|
|
1067
|
+
role: candidate.role,
|
|
1068
|
+
name: name.trim(),
|
|
1069
|
+
exact: true,
|
|
1070
|
+
};
|
|
1071
|
+
const parentLocatorExact = page.getByRole(candidate.role, { name: name.trim(), exact: true });
|
|
1072
|
+
const countExact = await parentLocatorExact.count();
|
|
1073
|
+
if (countExact === 1) {
|
|
1074
|
+
return parentDescriptorExact;
|
|
1075
|
+
}
|
|
1076
|
+
// Store as fallback if we don't have one yet
|
|
1077
|
+
if (!bestFallbackSelector) {
|
|
1078
|
+
bestFallbackSelector = parentDescriptor;
|
|
1079
|
+
}
|
|
1080
|
+
// Continue searching for a unique selector
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
// Role without name - check if unique
|
|
1084
|
+
const parentDescriptor = {
|
|
1085
|
+
type: 'role',
|
|
1086
|
+
role: candidate.role,
|
|
1087
|
+
};
|
|
1088
|
+
const parentLocator = page.getByRole(candidate.role);
|
|
1089
|
+
const count = await parentLocator.count();
|
|
1090
|
+
// If unique, return it
|
|
1091
|
+
if (count === 1) {
|
|
1092
|
+
return parentDescriptor;
|
|
1093
|
+
}
|
|
1094
|
+
// Store as fallback if we don't have one yet
|
|
1095
|
+
if (!bestFallbackSelector) {
|
|
1096
|
+
bestFallbackSelector = parentDescriptor;
|
|
1097
|
+
}
|
|
1098
|
+
// Continue searching for a unique selector
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
// Priority 3: label (for form elements)
|
|
1102
|
+
if (candidate.label) {
|
|
1103
|
+
const parentDescriptor = {
|
|
1104
|
+
type: 'label',
|
|
1105
|
+
value: candidate.label,
|
|
1106
|
+
exact: false,
|
|
1107
|
+
};
|
|
1108
|
+
const parentLocator = page.getByLabel(candidate.label);
|
|
1109
|
+
const count = await parentLocator.count();
|
|
1110
|
+
if (count === 1) {
|
|
1111
|
+
return parentDescriptor;
|
|
1112
|
+
}
|
|
1113
|
+
// Store as fallback if we don't have one yet
|
|
1114
|
+
if (!bestFallbackSelector) {
|
|
1115
|
+
bestFallbackSelector = parentDescriptor;
|
|
1116
|
+
}
|
|
1117
|
+
// Continue searching for a unique selector
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
// Priority 4: id (CSS - only store as fallback, don't return until we've checked all candidates)
|
|
1121
|
+
if (candidate.id) {
|
|
1122
|
+
const parentDescriptor = {
|
|
1123
|
+
type: 'css',
|
|
1124
|
+
selector: `#${candidate.id}`,
|
|
1125
|
+
};
|
|
1126
|
+
const parentLocator = page.locator(`#${candidate.id}`);
|
|
1127
|
+
const count = await parentLocator.count();
|
|
1128
|
+
if (count === 1) {
|
|
1129
|
+
return parentDescriptor;
|
|
1130
|
+
}
|
|
1131
|
+
// Store CSS selector as fallback only if we don't have one yet
|
|
1132
|
+
// CSS selectors are only used if we've exhausted all candidates
|
|
1133
|
+
if (!bestCssFallbackSelector) {
|
|
1134
|
+
bestCssFallbackSelector = parentDescriptor;
|
|
1135
|
+
}
|
|
1136
|
+
// Continue searching for a unique selector
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
// If we found a unique selector, it would have been returned above
|
|
1141
|
+
// First try non-CSS fallback (preferred over CSS)
|
|
1142
|
+
if (bestFallbackSelector) {
|
|
1143
|
+
return bestFallbackSelector;
|
|
1144
|
+
}
|
|
1145
|
+
// Only use CSS selector as last resort if we've exhausted all candidates and found no unique selector
|
|
1146
|
+
if (bestCssFallbackSelector) {
|
|
1147
|
+
return bestCssFallbackSelector;
|
|
1148
|
+
}
|
|
1149
|
+
// No suitable parent selector found
|
|
1150
|
+
return null;
|
|
1151
|
+
};
|
|
1152
|
+
const bestParentSelector = await findBestParentSelector();
|
|
821
1153
|
// Try to find selector on element itself first
|
|
822
1154
|
const tryElementSelector = async () => {
|
|
823
1155
|
// Priority 1: data-testid
|
|
824
|
-
|
|
1156
|
+
const dataset = elementInfo.dataset;
|
|
1157
|
+
if (dataset && dataset.testid) {
|
|
825
1158
|
const descriptor = {
|
|
826
1159
|
type: 'testid',
|
|
827
|
-
value:
|
|
1160
|
+
value: dataset.testid,
|
|
828
1161
|
};
|
|
829
|
-
|
|
1162
|
+
const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
|
|
1163
|
+
if (verification.unique) {
|
|
830
1164
|
return descriptor;
|
|
831
1165
|
}
|
|
832
1166
|
}
|
|
833
|
-
// Priority 2: role + name
|
|
834
|
-
|
|
835
|
-
|
|
1167
|
+
// Priority 2: role + name (when we have label/ariaLabel - prefer role+label over placeholder)
|
|
1168
|
+
// Check role first when we have a label, as role+label is preferred over placeholder
|
|
1169
|
+
if (elementInfo.role && (elementInfo.label || elementInfo.ariaLabel)) {
|
|
1170
|
+
// Prioritize label for form elements (textbox, combobox, checkbox, radio)
|
|
1171
|
+
// Label text is more reliable than aria-label or text content for form inputs
|
|
1172
|
+
const isFormElement = ['textbox', 'combobox', 'checkbox', 'radio'].includes(elementInfo.role);
|
|
1173
|
+
// For form elements, prefer label > ariaLabel > text (don't use placeholder here)
|
|
1174
|
+
// For other elements, prefer ariaLabel > label > text
|
|
1175
|
+
let name = null;
|
|
1176
|
+
if (isFormElement) {
|
|
1177
|
+
name = elementInfo.label?.trim() || elementInfo.ariaLabel?.trim() || elementInfo.text?.trim() || null;
|
|
1178
|
+
}
|
|
1179
|
+
else {
|
|
1180
|
+
// For images and other elements, use ariaLabel, label, or text
|
|
1181
|
+
name = elementInfo.ariaLabel?.trim() || elementInfo.label?.trim() || elementInfo.text?.trim() || null;
|
|
1182
|
+
}
|
|
836
1183
|
if (name && name.trim()) {
|
|
837
1184
|
// Try exact match first
|
|
838
1185
|
const descriptorExact = {
|
|
@@ -841,9 +1188,14 @@ export async function generateBestSelectorForElement(locator) {
|
|
|
841
1188
|
name: name.trim(),
|
|
842
1189
|
exact: true,
|
|
843
1190
|
};
|
|
844
|
-
|
|
1191
|
+
const verificationExact = await verifySelectorUniqueness(page, descriptorExact, targetMimicId ?? null);
|
|
1192
|
+
if (verificationExact.unique) {
|
|
845
1193
|
return descriptorExact;
|
|
846
1194
|
}
|
|
1195
|
+
// If not unique but we have an index, use nth() for focused selector
|
|
1196
|
+
if (verificationExact.index !== undefined && verificationExact.count && verificationExact.count > 1) {
|
|
1197
|
+
return { ...descriptorExact, nth: verificationExact.index };
|
|
1198
|
+
}
|
|
847
1199
|
// Try non-exact match
|
|
848
1200
|
const descriptor = {
|
|
849
1201
|
type: 'role',
|
|
@@ -851,71 +1203,143 @@ export async function generateBestSelectorForElement(locator) {
|
|
|
851
1203
|
name: name.trim(),
|
|
852
1204
|
exact: false,
|
|
853
1205
|
};
|
|
854
|
-
|
|
1206
|
+
const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
|
|
1207
|
+
if (verification.unique) {
|
|
855
1208
|
return descriptor;
|
|
856
1209
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
role: elementInfo.role,
|
|
862
|
-
};
|
|
863
|
-
if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
|
|
864
|
-
return descriptor;
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
// Priority 3: label
|
|
868
|
-
if (elementInfo.label) {
|
|
869
|
-
const descriptorExact = {
|
|
870
|
-
type: 'label',
|
|
871
|
-
value: elementInfo.label.trim(),
|
|
872
|
-
exact: true,
|
|
873
|
-
};
|
|
874
|
-
if (await verifySelectorUniqueness(page, descriptorExact, targetElementHandle)) {
|
|
875
|
-
return descriptorExact;
|
|
876
|
-
}
|
|
877
|
-
const descriptor = {
|
|
878
|
-
type: 'label',
|
|
879
|
-
value: elementInfo.label.trim(),
|
|
880
|
-
exact: false,
|
|
881
|
-
};
|
|
882
|
-
if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
|
|
883
|
-
return descriptor;
|
|
1210
|
+
// If not unique but we have an index, use nth() for focused selector
|
|
1211
|
+
if (verification.index !== undefined && verification.count && verification.count > 1) {
|
|
1212
|
+
return { ...descriptor, nth: verification.index };
|
|
1213
|
+
}
|
|
884
1214
|
}
|
|
885
1215
|
}
|
|
886
|
-
// Priority
|
|
887
|
-
|
|
1216
|
+
// Priority 3: placeholder (only if no label/ariaLabel - prefer direct attribute selectors)
|
|
1217
|
+
// Placeholder is more specific than role+name for inputs without labels
|
|
1218
|
+
if (elementInfo.placeholder && !elementInfo.label && !elementInfo.ariaLabel) {
|
|
888
1219
|
const descriptor = {
|
|
889
1220
|
type: 'placeholder',
|
|
890
1221
|
value: elementInfo.placeholder.trim(),
|
|
891
1222
|
exact: false,
|
|
892
1223
|
};
|
|
893
|
-
|
|
1224
|
+
const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
|
|
1225
|
+
if (verification.unique) {
|
|
894
1226
|
return descriptor;
|
|
895
1227
|
}
|
|
896
1228
|
}
|
|
897
|
-
// Priority
|
|
898
|
-
|
|
1229
|
+
// Priority 4: alt (only if no label/ariaLabel - prefer direct attribute selectors)
|
|
1230
|
+
// Alt is more specific than role+name for images without labels
|
|
1231
|
+
if (elementInfo.alt && !elementInfo.label && !elementInfo.ariaLabel) {
|
|
899
1232
|
const descriptor = {
|
|
900
1233
|
type: 'alt',
|
|
901
1234
|
value: elementInfo.alt.trim(),
|
|
902
1235
|
exact: false,
|
|
903
1236
|
};
|
|
904
|
-
|
|
1237
|
+
const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
|
|
1238
|
+
if (verification.unique) {
|
|
905
1239
|
return descriptor;
|
|
906
1240
|
}
|
|
907
1241
|
}
|
|
908
|
-
// Priority
|
|
909
|
-
|
|
1242
|
+
// Priority 5: title (only if no label/ariaLabel - prefer direct attribute selectors)
|
|
1243
|
+
// Title is more specific than role+name for elements with title attributes but no labels
|
|
1244
|
+
if (elementInfo.title && !elementInfo.label && !elementInfo.ariaLabel) {
|
|
910
1245
|
const descriptor = {
|
|
911
1246
|
type: 'title',
|
|
912
1247
|
value: elementInfo.title.trim(),
|
|
913
1248
|
exact: false,
|
|
914
1249
|
};
|
|
915
|
-
|
|
1250
|
+
const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
|
|
1251
|
+
if (verification.unique) {
|
|
916
1252
|
return descriptor;
|
|
917
1253
|
}
|
|
918
1254
|
}
|
|
1255
|
+
// Priority 6: role + name (fallback when no label but we have placeholder/alt/title)
|
|
1256
|
+
// Use placeholder/alt/title as name for role selector when no label available
|
|
1257
|
+
if (elementInfo.role && !elementInfo.label && !elementInfo.ariaLabel) {
|
|
1258
|
+
const isFormElement = ['textbox', 'combobox', 'checkbox', 'radio'].includes(elementInfo.role);
|
|
1259
|
+
// For form elements without labels, use placeholder > text
|
|
1260
|
+
// For other elements without labels, use alt > title > text
|
|
1261
|
+
let name = null;
|
|
1262
|
+
if (isFormElement) {
|
|
1263
|
+
name = elementInfo.placeholder?.trim() || elementInfo.text?.trim() || null;
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
// For images and other elements, use alt, title, or text
|
|
1267
|
+
name = elementInfo.alt?.trim() || elementInfo.title?.trim() || elementInfo.text?.trim() || null;
|
|
1268
|
+
}
|
|
1269
|
+
if (name && name.trim()) {
|
|
1270
|
+
// Try exact match first
|
|
1271
|
+
const descriptorExact = {
|
|
1272
|
+
type: 'role',
|
|
1273
|
+
role: elementInfo.role,
|
|
1274
|
+
name: name.trim(),
|
|
1275
|
+
exact: true,
|
|
1276
|
+
};
|
|
1277
|
+
const verificationExact = await verifySelectorUniqueness(page, descriptorExact, targetMimicId ?? null);
|
|
1278
|
+
if (verificationExact.unique) {
|
|
1279
|
+
return descriptorExact;
|
|
1280
|
+
}
|
|
1281
|
+
// If not unique but we have an index, use nth() for focused selector
|
|
1282
|
+
if (verificationExact.index !== undefined && verificationExact.count && verificationExact.count > 1) {
|
|
1283
|
+
return { ...descriptorExact, nth: verificationExact.index };
|
|
1284
|
+
}
|
|
1285
|
+
// Try non-exact match
|
|
1286
|
+
const descriptor = {
|
|
1287
|
+
type: 'role',
|
|
1288
|
+
role: elementInfo.role,
|
|
1289
|
+
name: name.trim(),
|
|
1290
|
+
exact: false,
|
|
1291
|
+
};
|
|
1292
|
+
const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
|
|
1293
|
+
if (verification.unique) {
|
|
1294
|
+
return descriptor;
|
|
1295
|
+
}
|
|
1296
|
+
// If not unique but we have an index, use nth() for focused selector
|
|
1297
|
+
if (verification.index !== undefined && verification.count && verification.count > 1) {
|
|
1298
|
+
return { ...descriptor, nth: verification.index };
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
// Role without name (only if we don't have a label for form elements)
|
|
1302
|
+
if (!isFormElement) {
|
|
1303
|
+
const descriptor = {
|
|
1304
|
+
type: 'role',
|
|
1305
|
+
role: elementInfo.role,
|
|
1306
|
+
};
|
|
1307
|
+
const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
|
|
1308
|
+
if (verification.unique) {
|
|
1309
|
+
return descriptor;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
// Priority 7: label (getByLabel - especially good for form elements)
|
|
1314
|
+
// This should be unique for form inputs with proper label associations
|
|
1315
|
+
if (elementInfo.label) {
|
|
1316
|
+
const descriptorExact = {
|
|
1317
|
+
type: 'label',
|
|
1318
|
+
value: elementInfo.label.trim(),
|
|
1319
|
+
exact: true,
|
|
1320
|
+
};
|
|
1321
|
+
const verificationExact = await verifySelectorUniqueness(page, descriptorExact, targetMimicId ?? null);
|
|
1322
|
+
if (verificationExact.unique) {
|
|
1323
|
+
return descriptorExact;
|
|
1324
|
+
}
|
|
1325
|
+
// If not unique but we have an index, use nth() for focused selector
|
|
1326
|
+
if (verificationExact.index !== undefined && verificationExact.count && verificationExact.count > 1) {
|
|
1327
|
+
return { ...descriptorExact, nth: verificationExact.index };
|
|
1328
|
+
}
|
|
1329
|
+
const descriptor = {
|
|
1330
|
+
type: 'label',
|
|
1331
|
+
value: elementInfo.label.trim(),
|
|
1332
|
+
exact: false,
|
|
1333
|
+
};
|
|
1334
|
+
const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
|
|
1335
|
+
if (verification.unique) {
|
|
1336
|
+
return descriptor;
|
|
1337
|
+
}
|
|
1338
|
+
// If not unique but we have an index, use nth() for focused selector
|
|
1339
|
+
if (verification.index !== undefined && verification.count && verification.count > 1) {
|
|
1340
|
+
return { ...descriptor, nth: verification.index };
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
919
1343
|
// Priority 7: text
|
|
920
1344
|
if (elementInfo.text && elementInfo.text.trim()) {
|
|
921
1345
|
const trimmedText = elementInfo.text.trim();
|
|
@@ -926,7 +1350,8 @@ export async function generateBestSelectorForElement(locator) {
|
|
|
926
1350
|
value: trimmedText,
|
|
927
1351
|
exact: true,
|
|
928
1352
|
};
|
|
929
|
-
|
|
1353
|
+
const verificationExact = await verifySelectorUniqueness(page, descriptorExact, targetMimicId ?? null);
|
|
1354
|
+
if (verificationExact.unique) {
|
|
930
1355
|
return descriptorExact;
|
|
931
1356
|
}
|
|
932
1357
|
}
|
|
@@ -935,165 +1360,152 @@ export async function generateBestSelectorForElement(locator) {
|
|
|
935
1360
|
value: trimmedText,
|
|
936
1361
|
exact: false,
|
|
937
1362
|
};
|
|
938
|
-
|
|
1363
|
+
const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
|
|
1364
|
+
if (verification.unique) {
|
|
939
1365
|
return descriptor;
|
|
940
1366
|
}
|
|
941
1367
|
}
|
|
942
|
-
// Priority 8: CSS
|
|
943
|
-
|
|
1368
|
+
// Priority 8: name attribute (for form elements - better than CSS)
|
|
1369
|
+
// Name attributes are more stable than CSS nth-of-type selectors
|
|
1370
|
+
if (elementInfo.nameAttr && elementInfo.nameAttr.trim()) {
|
|
1371
|
+
const nameValue = elementInfo.nameAttr.trim();
|
|
1372
|
+
// Try exact name match
|
|
944
1373
|
const descriptor = {
|
|
945
1374
|
type: 'css',
|
|
946
|
-
selector:
|
|
1375
|
+
selector: `[name="${nameValue}"]`,
|
|
947
1376
|
};
|
|
948
|
-
|
|
1377
|
+
const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
|
|
1378
|
+
if (verification.unique) {
|
|
949
1379
|
return descriptor;
|
|
950
1380
|
}
|
|
951
1381
|
}
|
|
952
|
-
|
|
1382
|
+
// Priority 9: id attribute (better than nth-of-type CSS)
|
|
1383
|
+
// ID selectors are more stable than nth-of-type, even though they're still CSS
|
|
1384
|
+
if (elementInfo.id && elementInfo.id.trim()) {
|
|
1385
|
+
const idValue = elementInfo.id.trim();
|
|
953
1386
|
const descriptor = {
|
|
954
1387
|
type: 'css',
|
|
955
|
-
selector:
|
|
1388
|
+
selector: `#${idValue}`,
|
|
956
1389
|
};
|
|
957
|
-
|
|
1390
|
+
const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
|
|
1391
|
+
if (verification.unique) {
|
|
958
1392
|
return descriptor;
|
|
959
1393
|
}
|
|
960
1394
|
}
|
|
961
|
-
//
|
|
962
|
-
|
|
963
|
-
type: 'css',
|
|
964
|
-
selector: `${elementInfo.tag}:nth-of-type(${elementInfo.nthOfType})`,
|
|
965
|
-
};
|
|
966
|
-
if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
|
|
967
|
-
return descriptor;
|
|
968
|
-
}
|
|
1395
|
+
// Don't use nth-of-type CSS here - only as absolute last resort after trying parent selectors
|
|
1396
|
+
// CSS selectors with nth-of-type are less stable and harder to maintain
|
|
969
1397
|
return null;
|
|
970
1398
|
};
|
|
971
|
-
//
|
|
1399
|
+
// Helper function to create nested selector
|
|
1400
|
+
const createNestedSelector = (parent, child) => {
|
|
1401
|
+
// Clone parent and add child
|
|
1402
|
+
const nested = { ...parent };
|
|
1403
|
+
nested.child = child;
|
|
1404
|
+
return nested;
|
|
1405
|
+
};
|
|
1406
|
+
// Try element itself first - always check if child selector is unique before nesting
|
|
1407
|
+
// This ensures we prefer direct selectors (like testid) over nested ones when possible
|
|
972
1408
|
const elementSelector = await tryElementSelector();
|
|
973
1409
|
if (elementSelector) {
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
return null;
|
|
1410
|
+
// Verify child selector is unique
|
|
1411
|
+
const childVerification = await verifySelectorUniqueness(page, elementSelector, targetMimicId ?? null);
|
|
1412
|
+
if (childVerification.unique) {
|
|
1413
|
+
// Child selector is unique - use it directly (don't nest)
|
|
1414
|
+
// This is more stable than nested selectors and avoids unnecessary parent dependencies
|
|
1415
|
+
return elementSelector;
|
|
981
1416
|
}
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1417
|
+
}
|
|
1418
|
+
// If we found a good parent selector AND child selector isn't unique, create nested selector
|
|
1419
|
+
// This handles cases where the locator was created from a parent chain like:
|
|
1420
|
+
// page.getByTestId('parent').getByRole('button', { name: 'Add to Cart' })
|
|
1421
|
+
// or page.getByRole('section').getByRole('button', { name: 'Add to Cart' })
|
|
1422
|
+
// Only use nested when child alone isn't unique
|
|
1423
|
+
if (bestParentSelector && elementSelector) {
|
|
1424
|
+
// Create nested selector since child alone isn't unique
|
|
1425
|
+
const nestedDescriptor = createNestedSelector(bestParentSelector, elementSelector);
|
|
1426
|
+
// Verify the nested selector is unique
|
|
1427
|
+
const verification = await verifySelectorUniqueness(page, nestedDescriptor, targetMimicId ?? null);
|
|
1428
|
+
if (verification.unique) {
|
|
1429
|
+
return nestedDescriptor;
|
|
992
1430
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1431
|
+
}
|
|
1432
|
+
// If we found a good parent selector but child selector generation failed, try with basic selectors
|
|
1433
|
+
if (bestParentSelector && !elementSelector) {
|
|
1434
|
+
// Even if child selector generation failed, try with a basic role/text selector
|
|
1435
|
+
// This handles cases where the element has a role but tryElementSelector didn't find it unique
|
|
1436
|
+
if (elementInfo.role) {
|
|
1437
|
+
const name = elementInfo.ariaLabel || elementInfo.label || elementInfo.text;
|
|
1438
|
+
if (name && name.trim()) {
|
|
1439
|
+
const childSelector = {
|
|
1440
|
+
type: 'role',
|
|
1441
|
+
role: elementInfo.role,
|
|
1442
|
+
name: name.trim(),
|
|
1443
|
+
exact: false,
|
|
1444
|
+
};
|
|
1445
|
+
const nestedDescriptor = createNestedSelector(bestParentSelector, childSelector);
|
|
1446
|
+
const verification = await verifySelectorUniqueness(page, nestedDescriptor, targetMimicId ?? null);
|
|
1447
|
+
if (verification.unique) {
|
|
1448
|
+
return nestedDescriptor;
|
|
1449
|
+
}
|
|
1002
1450
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1451
|
+
// Try role without name
|
|
1452
|
+
const childSelector = {
|
|
1453
|
+
type: 'role',
|
|
1454
|
+
role: elementInfo.role,
|
|
1455
|
+
};
|
|
1456
|
+
const nestedDescriptor = createNestedSelector(bestParentSelector, childSelector);
|
|
1457
|
+
const verification = await verifySelectorUniqueness(page, nestedDescriptor, targetMimicId ?? null);
|
|
1458
|
+
if (verification.unique) {
|
|
1459
|
+
return nestedDescriptor;
|
|
1007
1460
|
}
|
|
1008
|
-
const parentLabel = el.closest('label');
|
|
1009
|
-
if (parentLabel)
|
|
1010
|
-
return (parentLabel.textContent || '').trim();
|
|
1011
|
-
return null;
|
|
1012
1461
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
'button': 'button',
|
|
1020
|
-
'a': 'link',
|
|
1021
|
-
'input': el.type === 'button' || el.type === 'submit' || el.type === 'reset' ? 'button' :
|
|
1022
|
-
el.type === 'checkbox' ? 'checkbox' :
|
|
1023
|
-
el.type === 'radio' ? 'radio' : 'textbox',
|
|
1024
|
-
'select': 'combobox',
|
|
1025
|
-
'textarea': 'textbox',
|
|
1026
|
-
'img': 'img',
|
|
1462
|
+
// Try with text selector if available
|
|
1463
|
+
if (elementInfo.text && elementInfo.text.trim() && elementInfo.text.length < 50) {
|
|
1464
|
+
const childSelector = {
|
|
1465
|
+
type: 'text',
|
|
1466
|
+
value: elementInfo.text.trim(),
|
|
1467
|
+
exact: false,
|
|
1027
1468
|
};
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
for (const attr of el.attributes) {
|
|
1033
|
-
if (attr.name.startsWith('data-')) {
|
|
1034
|
-
const key = attr.name.replace(/^data-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
1035
|
-
dataset[key] = attr.value;
|
|
1036
|
-
}
|
|
1469
|
+
const nestedDescriptor = createNestedSelector(bestParentSelector, childSelector);
|
|
1470
|
+
const verification = await verifySelectorUniqueness(page, nestedDescriptor, targetMimicId ?? null);
|
|
1471
|
+
if (verification.unique) {
|
|
1472
|
+
return nestedDescriptor;
|
|
1037
1473
|
}
|
|
1038
|
-
return dataset;
|
|
1039
1474
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1475
|
+
}
|
|
1476
|
+
// If we get here, either:
|
|
1477
|
+
// 1. No parent selector found and element selector isn't unique, OR
|
|
1478
|
+
// 2. Parent selector found but nested selector isn't unique, OR
|
|
1479
|
+
// 3. No element selector could be generated
|
|
1480
|
+
// In these cases, we'll fall through to CSS fallback below
|
|
1481
|
+
// Final fallback: CSS selector with nth-of-type (only if nothing else works)
|
|
1482
|
+
// This is the absolute last resort - all better selector methods have been exhausted
|
|
1483
|
+
// Try name attribute first (if available) before nth-of-type
|
|
1484
|
+
if (elementInfo.nameAttr && elementInfo.nameAttr.trim()) {
|
|
1485
|
+
const nameValue = elementInfo.nameAttr.trim();
|
|
1486
|
+
const nameSelector = {
|
|
1487
|
+
type: 'css',
|
|
1488
|
+
selector: `[name="${nameValue}"]`,
|
|
1048
1489
|
};
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
const childSelector = await tryElementSelector();
|
|
1061
|
-
if (childSelector) {
|
|
1062
|
-
parentDescriptor.child = childSelector;
|
|
1063
|
-
if (await verifySelectorUniqueness(page, parentDescriptor, targetElementHandle)) {
|
|
1064
|
-
return parentDescriptor;
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
// Parent testid alone might be unique enough
|
|
1068
|
-
if (await verifySelectorUniqueness(page, parentDescriptor, targetElementHandle)) {
|
|
1069
|
-
return parentDescriptor;
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
// Check parent for role
|
|
1073
|
-
if (parentInfo.role) {
|
|
1074
|
-
const name = parentInfo.ariaLabel || parentInfo.label || parentInfo.text;
|
|
1075
|
-
const parentDescriptor = {
|
|
1076
|
-
type: 'role',
|
|
1077
|
-
role: parentInfo.role,
|
|
1078
|
-
name: name?.trim(),
|
|
1079
|
-
exact: false,
|
|
1080
|
-
};
|
|
1081
|
-
const childSelector = await tryElementSelector();
|
|
1082
|
-
if (childSelector) {
|
|
1083
|
-
parentDescriptor.child = childSelector;
|
|
1084
|
-
if (await verifySelectorUniqueness(page, parentDescriptor, targetElementHandle)) {
|
|
1085
|
-
return parentDescriptor;
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
return null;
|
|
1490
|
+
const verification = await verifySelectorUniqueness(page, nameSelector, targetMimicId ?? null, timeout);
|
|
1491
|
+
if (verification.unique) {
|
|
1492
|
+
return nameSelector;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
// Try ID selector before nth-of-type
|
|
1496
|
+
if (elementInfo.id && elementInfo.id.trim()) {
|
|
1497
|
+
const idValue = elementInfo.id.trim();
|
|
1498
|
+
const idSelector = {
|
|
1499
|
+
type: 'css',
|
|
1500
|
+
selector: `#${idValue}`,
|
|
1090
1501
|
};
|
|
1091
|
-
const
|
|
1092
|
-
if (
|
|
1093
|
-
return
|
|
1502
|
+
const verification = await verifySelectorUniqueness(page, idSelector, targetMimicId ?? null, timeout);
|
|
1503
|
+
if (verification.unique) {
|
|
1504
|
+
return idSelector;
|
|
1094
1505
|
}
|
|
1095
1506
|
}
|
|
1096
|
-
//
|
|
1507
|
+
// Absolute last resort: nth-of-type CSS selector
|
|
1508
|
+
// This is the least stable selector type, only used when all else fails
|
|
1097
1509
|
const fallback = {
|
|
1098
1510
|
type: 'css',
|
|
1099
1511
|
selector: `${elementInfo.tag}:nth-of-type(${elementInfo.nthOfType})`,
|