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.
Files changed (66) hide show
  1. package/README.md +134 -72
  2. package/dist/index.d.ts +1 -4
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -4
  5. package/dist/index.js.map +1 -1
  6. package/dist/mimic/annotations.d.ts +2 -1
  7. package/dist/mimic/annotations.d.ts.map +1 -1
  8. package/dist/mimic/annotations.js +10 -4
  9. package/dist/mimic/annotations.js.map +1 -1
  10. package/dist/mimic/cli.js +1 -1
  11. package/dist/mimic/cli.js.map +1 -1
  12. package/dist/mimic/click.d.ts +4 -4
  13. package/dist/mimic/click.d.ts.map +1 -1
  14. package/dist/mimic/click.js +233 -118
  15. package/dist/mimic/click.js.map +1 -1
  16. package/dist/mimic/forms.d.ts +11 -6
  17. package/dist/mimic/forms.d.ts.map +1 -1
  18. package/dist/mimic/forms.js +371 -124
  19. package/dist/mimic/forms.js.map +1 -1
  20. package/dist/mimic/markers.d.ts +133 -0
  21. package/dist/mimic/markers.d.ts.map +1 -0
  22. package/dist/mimic/markers.js +589 -0
  23. package/dist/mimic/markers.js.map +1 -0
  24. package/dist/mimic/navigation.d.ts.map +1 -1
  25. package/dist/mimic/navigation.js +29 -10
  26. package/dist/mimic/navigation.js.map +1 -1
  27. package/dist/mimic/playwrightCodeGenerator.d.ts +55 -0
  28. package/dist/mimic/playwrightCodeGenerator.d.ts.map +1 -0
  29. package/dist/mimic/playwrightCodeGenerator.js +270 -0
  30. package/dist/mimic/playwrightCodeGenerator.js.map +1 -0
  31. package/dist/mimic/replay.d.ts.map +1 -1
  32. package/dist/mimic/replay.js +45 -36
  33. package/dist/mimic/replay.js.map +1 -1
  34. package/dist/mimic/schema/action.d.ts +26 -26
  35. package/dist/mimic/schema/action.d.ts.map +1 -1
  36. package/dist/mimic/schema/action.js +13 -31
  37. package/dist/mimic/schema/action.js.map +1 -1
  38. package/dist/mimic/selector.d.ts +6 -2
  39. package/dist/mimic/selector.d.ts.map +1 -1
  40. package/dist/mimic/selector.js +681 -269
  41. package/dist/mimic/selector.js.map +1 -1
  42. package/dist/mimic/selectorDescriptor.d.ts +15 -3
  43. package/dist/mimic/selectorDescriptor.d.ts.map +1 -1
  44. package/dist/mimic/selectorDescriptor.js +25 -2
  45. package/dist/mimic/selectorDescriptor.js.map +1 -1
  46. package/dist/mimic/selectorSerialization.d.ts +5 -17
  47. package/dist/mimic/selectorSerialization.d.ts.map +1 -1
  48. package/dist/mimic/selectorSerialization.js +4 -142
  49. package/dist/mimic/selectorSerialization.js.map +1 -1
  50. package/dist/mimic/selectorTypes.d.ts +24 -102
  51. package/dist/mimic/selectorTypes.d.ts.map +1 -1
  52. package/dist/mimic/selectorUtils.d.ts +33 -7
  53. package/dist/mimic/selectorUtils.d.ts.map +1 -1
  54. package/dist/mimic/selectorUtils.js +159 -52
  55. package/dist/mimic/selectorUtils.js.map +1 -1
  56. package/dist/mimic/storage.d.ts +43 -8
  57. package/dist/mimic/storage.d.ts.map +1 -1
  58. package/dist/mimic/storage.js +258 -46
  59. package/dist/mimic/storage.js.map +1 -1
  60. package/dist/mimic/types.d.ts +38 -16
  61. package/dist/mimic/types.d.ts.map +1 -1
  62. package/dist/mimic.d.ts +1 -0
  63. package/dist/mimic.d.ts.map +1 -1
  64. package/dist/mimic.js +240 -84
  65. package/dist/mimic.js.map +1 -1
  66. package/package.json +27 -6
@@ -1,4 +1,4 @@
1
- import { verifySelectorUniqueness } from './selectorDescriptor.js';
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
- const key = attr.name.replace(/^data-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
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
- const key = attr.name.replace(/^data-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
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
- const targetElementHandle = await locator.elementHandle();
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
- const elementInfo = await page.evaluate((element) => {
720
- // @ts-ignore - window is available in browser context
721
- const win = window;
722
- // @ts-ignore - document is available in browser context
723
- const doc = document;
724
- /**
725
- * Get visible text content
726
- */
727
- function getVisibleText(el) {
728
- const style = win.getComputedStyle(el);
729
- if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
730
- return '';
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
- return (el.textContent || '').trim().replace(/\s+/g, ' ');
733
- }
734
- /**
735
- * Get label text via various methods
736
- */
737
- function getLabel(el) {
738
- const ariaLabel = el.getAttribute('aria-label');
739
- if (ariaLabel)
740
- return ariaLabel.trim();
741
- const labelledBy = el.getAttribute('aria-labelledby');
742
- if (labelledBy) {
743
- const labelEl = doc.getElementById(labelledBy);
744
- if (labelEl)
745
- return (labelEl.textContent || '').trim();
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
- if (el.id) {
748
- const label = doc.querySelector(`label[for="${el.id}"]`);
749
- if (label)
750
- return (label.textContent || '').trim();
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
- const parentLabel = el.closest('label');
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 (const attr of el.attributes) {
783
- if (attr.name.startsWith('data-')) {
784
- const key = attr.name.replace(/^data-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
785
- dataset[key] = attr.value;
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
- return dataset;
789
- }
790
- /**
791
- * Get nth-of-type index
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 === tag) {
799
- index++;
881
+ if (sibling.tagName === tagName) {
882
+ nthOfType++;
800
883
  }
801
884
  sibling = sibling.previousElementSibling;
802
885
  }
803
- return index;
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
- return {
806
- tag: element.tagName.toLowerCase(),
807
- text: getVisibleText(element),
808
- id: element.id || null,
809
- role: inferRole(element),
810
- label: getLabel(element),
811
- ariaLabel: element.getAttribute('aria-label') || null,
812
- placeholder: element.getAttribute('placeholder') || null,
813
- alt: element.getAttribute('alt') || null,
814
- title: element.getAttribute('title') || null,
815
- typeAttr: element.type || null,
816
- nameAttr: element.getAttribute('name') || null,
817
- dataset: getDataset(element),
818
- nthOfType: getNthOfType(element),
819
- };
820
- }, targetElementHandle);
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
- if (elementInfo.dataset.testid) {
1156
+ const dataset = elementInfo.dataset;
1157
+ if (dataset && dataset.testid) {
825
1158
  const descriptor = {
826
1159
  type: 'testid',
827
- value: elementInfo.dataset.testid,
1160
+ value: dataset.testid,
828
1161
  };
829
- if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
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
- if (elementInfo.role) {
835
- const name = elementInfo.ariaLabel || elementInfo.label || elementInfo.text;
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
- if (await verifySelectorUniqueness(page, descriptorExact, targetElementHandle)) {
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
- if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
1206
+ const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
1207
+ if (verification.unique) {
855
1208
  return descriptor;
856
1209
  }
857
- }
858
- // Role without name
859
- const descriptor = {
860
- type: 'role',
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 4: placeholder
887
- if (elementInfo.placeholder) {
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
- if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
1224
+ const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
1225
+ if (verification.unique) {
894
1226
  return descriptor;
895
1227
  }
896
1228
  }
897
- // Priority 5: alt
898
- if (elementInfo.alt) {
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
- if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
1237
+ const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
1238
+ if (verification.unique) {
905
1239
  return descriptor;
906
1240
  }
907
1241
  }
908
- // Priority 6: title
909
- if (elementInfo.title) {
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
- if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
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
- if (await verifySelectorUniqueness(page, descriptorExact, targetElementHandle)) {
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
- if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
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 fallback
943
- if (elementInfo.id) {
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: `#${elementInfo.id}`,
1375
+ selector: `[name="${nameValue}"]`,
947
1376
  };
948
- if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
1377
+ const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
1378
+ if (verification.unique) {
949
1379
  return descriptor;
950
1380
  }
951
1381
  }
952
- if (elementInfo.nameAttr) {
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: `[name="${elementInfo.nameAttr}"]`,
1388
+ selector: `#${idValue}`,
956
1389
  };
957
- if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
1390
+ const verification = await verifySelectorUniqueness(page, descriptor, targetMimicId ?? null, timeout);
1391
+ if (verification.unique) {
958
1392
  return descriptor;
959
1393
  }
960
1394
  }
961
- // Last resort: tag + nth-of-type
962
- const descriptor = {
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
- // Try element itself first
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
- return elementSelector;
975
- }
976
- // If nothing found on element, try parent
977
- const parentInfo = await page.evaluate((element) => {
978
- const parent = element.parentElement;
979
- if (!parent || parent.tagName === 'BODY' || parent.tagName === 'HTML') {
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
- // @ts-ignore - window is available in browser context
983
- const win = window;
984
- // @ts-ignore - document is available in browser context
985
- const doc = document;
986
- function getVisibleText(el) {
987
- const style = win.getComputedStyle(el);
988
- if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
989
- return '';
990
- }
991
- return (el.textContent || '').trim().replace(/\s+/g, ' ');
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
- function getLabel(el) {
994
- const ariaLabel = el.getAttribute('aria-label');
995
- if (ariaLabel)
996
- return ariaLabel.trim();
997
- const labelledBy = el.getAttribute('aria-labelledby');
998
- if (labelledBy) {
999
- const labelEl = doc.getElementById(labelledBy);
1000
- if (labelEl)
1001
- return (labelEl.textContent || '').trim();
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
- if (el.id) {
1004
- const label = doc.querySelector(`label[for="${el.id}"]`);
1005
- if (label)
1006
- return (label.textContent || '').trim();
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
- function inferRole(el) {
1014
- const explicitRole = el.getAttribute('role');
1015
- if (explicitRole)
1016
- return explicitRole;
1017
- const tag = el.tagName.toLowerCase();
1018
- const roleMap = {
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
- return roleMap[tag] || null;
1029
- }
1030
- function getDataset(el) {
1031
- const dataset = {};
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
- return {
1041
- tag: parent.tagName.toLowerCase(),
1042
- text: getVisibleText(parent),
1043
- id: parent.id || null,
1044
- role: inferRole(parent),
1045
- label: getLabel(parent),
1046
- ariaLabel: parent.getAttribute('aria-label') || null,
1047
- dataset: getDataset(parent),
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
- }, targetElementHandle);
1050
- if (parentInfo) {
1051
- // Try to find selector for parent
1052
- const tryParentSelector = async () => {
1053
- // Check parent for testid
1054
- if (parentInfo.dataset.testid) {
1055
- const parentDescriptor = {
1056
- type: 'testid',
1057
- value: parentInfo.dataset.testid,
1058
- };
1059
- // Try to find child selector
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 parentSelector = await tryParentSelector();
1092
- if (parentSelector) {
1093
- return parentSelector;
1502
+ const verification = await verifySelectorUniqueness(page, idSelector, targetMimicId ?? null, timeout);
1503
+ if (verification.unique) {
1504
+ return idSelector;
1094
1505
  }
1095
1506
  }
1096
- // Final fallback: CSS selector (should always work)
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})`,