donobu 2.31.1 → 2.32.1

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 (139) hide show
  1. package/dist/apis/GptConfigsApi.d.ts.map +1 -1
  2. package/dist/apis/GptConfigsApi.js +2 -0
  3. package/dist/apis/GptConfigsApi.js.map +1 -1
  4. package/dist/assets/generated/version +1 -1
  5. package/dist/assets/interactive-elements-tracker.js +89 -62
  6. package/dist/assets/page-interactions-tracker.js +15 -42
  7. package/dist/assets/smart-selector-generator.js +839 -259
  8. package/dist/bindings/PageInteractionTracker.d.ts +27 -1
  9. package/dist/bindings/PageInteractionTracker.d.ts.map +1 -1
  10. package/dist/bindings/PageInteractionTracker.js +172 -61
  11. package/dist/bindings/PageInteractionTracker.js.map +1 -1
  12. package/dist/esm/apis/GptConfigsApi.d.ts.map +1 -1
  13. package/dist/esm/apis/GptConfigsApi.js +2 -0
  14. package/dist/esm/apis/GptConfigsApi.js.map +1 -1
  15. package/dist/esm/assets/generated/version +1 -1
  16. package/dist/esm/assets/interactive-elements-tracker.js +89 -62
  17. package/dist/esm/assets/page-interactions-tracker.js +15 -42
  18. package/dist/esm/assets/smart-selector-generator.js +839 -259
  19. package/dist/esm/bindings/PageInteractionTracker.d.ts +27 -1
  20. package/dist/esm/bindings/PageInteractionTracker.d.ts.map +1 -1
  21. package/dist/esm/bindings/PageInteractionTracker.js +172 -61
  22. package/dist/esm/bindings/PageInteractionTracker.js.map +1 -1
  23. package/dist/esm/managers/AgentsManager.d.ts +9 -0
  24. package/dist/esm/managers/AgentsManager.d.ts.map +1 -1
  25. package/dist/esm/managers/AgentsManager.js +52 -0
  26. package/dist/esm/managers/AgentsManager.js.map +1 -1
  27. package/dist/esm/managers/CodeGenerator.d.ts.map +1 -1
  28. package/dist/esm/managers/CodeGenerator.js +1 -0
  29. package/dist/esm/managers/CodeGenerator.js.map +1 -1
  30. package/dist/esm/managers/DonobuFlow.d.ts +1 -0
  31. package/dist/esm/managers/DonobuFlow.d.ts.map +1 -1
  32. package/dist/esm/managers/DonobuFlow.js +23 -0
  33. package/dist/esm/managers/DonobuFlow.js.map +1 -1
  34. package/dist/esm/managers/InteractionVisualizer.d.ts +18 -2
  35. package/dist/esm/managers/InteractionVisualizer.d.ts.map +1 -1
  36. package/dist/esm/managers/InteractionVisualizer.js +33 -14
  37. package/dist/esm/managers/InteractionVisualizer.js.map +1 -1
  38. package/dist/esm/managers/PageInspector.d.ts +1 -0
  39. package/dist/esm/managers/PageInspector.d.ts.map +1 -1
  40. package/dist/esm/managers/PageInspector.js +90 -10
  41. package/dist/esm/managers/PageInspector.js.map +1 -1
  42. package/dist/esm/models/BrowserConfig.d.ts +56 -56
  43. package/dist/esm/models/CreateDonobuFlow.d.ts +48 -48
  44. package/dist/esm/models/FlowMetadata.d.ts +54 -54
  45. package/dist/esm/models/InteractableElement.d.ts +4 -3
  46. package/dist/esm/models/InteractableElement.d.ts.map +1 -1
  47. package/dist/esm/models/InteractableElement.js +9 -4
  48. package/dist/esm/models/InteractableElement.js.map +1 -1
  49. package/dist/esm/persistence/DonobuSqliteDb.d.ts.map +1 -1
  50. package/dist/esm/persistence/DonobuSqliteDb.js +44 -0
  51. package/dist/esm/persistence/DonobuSqliteDb.js.map +1 -1
  52. package/dist/esm/tools/ChooseSelectOptionTool.d.ts.map +1 -1
  53. package/dist/esm/tools/ChooseSelectOptionTool.js +2 -1
  54. package/dist/esm/tools/ChooseSelectOptionTool.js.map +1 -1
  55. package/dist/esm/tools/InputRandomizedEmailAddressTool.d.ts +6 -6
  56. package/dist/esm/tools/InputTextTool.d.ts +9 -0
  57. package/dist/esm/tools/InputTextTool.d.ts.map +1 -1
  58. package/dist/esm/tools/InputTextTool.js +8 -2
  59. package/dist/esm/tools/InputTextTool.js.map +1 -1
  60. package/dist/esm/tools/PressKeyTool.d.ts.map +1 -1
  61. package/dist/esm/tools/PressKeyTool.js +8 -3
  62. package/dist/esm/tools/PressKeyTool.js.map +1 -1
  63. package/dist/esm/tools/ReplayableInteraction.d.ts.map +1 -1
  64. package/dist/esm/tools/ReplayableInteraction.js +14 -7
  65. package/dist/esm/tools/ReplayableInteraction.js.map +1 -1
  66. package/dist/esm/tools/RunAccessibilityTestTool.d.ts +0 -8
  67. package/dist/esm/tools/RunAccessibilityTestTool.d.ts.map +1 -1
  68. package/dist/esm/tools/RunAccessibilityTestTool.js +20 -38
  69. package/dist/esm/tools/RunAccessibilityTestTool.js.map +1 -1
  70. package/dist/esm/tools/ScrollPageTool.d.ts +52 -11
  71. package/dist/esm/tools/ScrollPageTool.d.ts.map +1 -1
  72. package/dist/esm/tools/ScrollPageTool.js +63 -57
  73. package/dist/esm/tools/ScrollPageTool.js.map +1 -1
  74. package/dist/esm/tools/TriggerDonobuFlowTool.d.ts +116 -116
  75. package/dist/esm/utils/BrowserUtils.js +1 -1
  76. package/dist/esm/utils/BrowserUtils.js.map +1 -1
  77. package/dist/esm/utils/PlaywrightUtils.d.ts.map +1 -1
  78. package/dist/esm/utils/PlaywrightUtils.js +0 -2
  79. package/dist/esm/utils/PlaywrightUtils.js.map +1 -1
  80. package/dist/managers/AgentsManager.d.ts +9 -0
  81. package/dist/managers/AgentsManager.d.ts.map +1 -1
  82. package/dist/managers/AgentsManager.js +52 -0
  83. package/dist/managers/AgentsManager.js.map +1 -1
  84. package/dist/managers/CodeGenerator.d.ts.map +1 -1
  85. package/dist/managers/CodeGenerator.js +1 -0
  86. package/dist/managers/CodeGenerator.js.map +1 -1
  87. package/dist/managers/DonobuFlow.d.ts +1 -0
  88. package/dist/managers/DonobuFlow.d.ts.map +1 -1
  89. package/dist/managers/DonobuFlow.js +23 -0
  90. package/dist/managers/DonobuFlow.js.map +1 -1
  91. package/dist/managers/InteractionVisualizer.d.ts +18 -2
  92. package/dist/managers/InteractionVisualizer.d.ts.map +1 -1
  93. package/dist/managers/InteractionVisualizer.js +33 -14
  94. package/dist/managers/InteractionVisualizer.js.map +1 -1
  95. package/dist/managers/PageInspector.d.ts +1 -0
  96. package/dist/managers/PageInspector.d.ts.map +1 -1
  97. package/dist/managers/PageInspector.js +90 -10
  98. package/dist/managers/PageInspector.js.map +1 -1
  99. package/dist/models/BrowserConfig.d.ts +56 -56
  100. package/dist/models/CreateDonobuFlow.d.ts +48 -48
  101. package/dist/models/FlowMetadata.d.ts +54 -54
  102. package/dist/models/InteractableElement.d.ts +4 -3
  103. package/dist/models/InteractableElement.d.ts.map +1 -1
  104. package/dist/models/InteractableElement.js +9 -4
  105. package/dist/models/InteractableElement.js.map +1 -1
  106. package/dist/persistence/DonobuSqliteDb.d.ts.map +1 -1
  107. package/dist/persistence/DonobuSqliteDb.js +44 -0
  108. package/dist/persistence/DonobuSqliteDb.js.map +1 -1
  109. package/dist/tools/ChooseSelectOptionTool.d.ts.map +1 -1
  110. package/dist/tools/ChooseSelectOptionTool.js +2 -1
  111. package/dist/tools/ChooseSelectOptionTool.js.map +1 -1
  112. package/dist/tools/InputRandomizedEmailAddressTool.d.ts +6 -6
  113. package/dist/tools/InputTextTool.d.ts +9 -0
  114. package/dist/tools/InputTextTool.d.ts.map +1 -1
  115. package/dist/tools/InputTextTool.js +8 -2
  116. package/dist/tools/InputTextTool.js.map +1 -1
  117. package/dist/tools/PressKeyTool.d.ts.map +1 -1
  118. package/dist/tools/PressKeyTool.js +8 -3
  119. package/dist/tools/PressKeyTool.js.map +1 -1
  120. package/dist/tools/ReplayableInteraction.d.ts.map +1 -1
  121. package/dist/tools/ReplayableInteraction.js +14 -7
  122. package/dist/tools/ReplayableInteraction.js.map +1 -1
  123. package/dist/tools/RunAccessibilityTestTool.d.ts +0 -8
  124. package/dist/tools/RunAccessibilityTestTool.d.ts.map +1 -1
  125. package/dist/tools/RunAccessibilityTestTool.js +20 -38
  126. package/dist/tools/RunAccessibilityTestTool.js.map +1 -1
  127. package/dist/tools/ScrollPageTool.d.ts +52 -11
  128. package/dist/tools/ScrollPageTool.d.ts.map +1 -1
  129. package/dist/tools/ScrollPageTool.js +63 -57
  130. package/dist/tools/ScrollPageTool.js.map +1 -1
  131. package/dist/tools/TriggerDonobuFlowTool.d.ts +116 -116
  132. package/dist/utils/BrowserUtils.js +1 -1
  133. package/dist/utils/BrowserUtils.js.map +1 -1
  134. package/dist/utils/PlaywrightUtils.d.ts.map +1 -1
  135. package/dist/utils/PlaywrightUtils.js +0 -2
  136. package/dist/utils/PlaywrightUtils.js.map +1 -1
  137. package/package.json +2 -1
  138. package/dist/assets/axe.js +0 -47397
  139. package/dist/esm/assets/axe.js +0 -47397
@@ -1,348 +1,928 @@
1
+ /*
2
+ * Smart-selector generator: (c) 2025 Donobu
3
+ *
4
+ * This library generates CSS selectors and XPath expressions that can reliably
5
+ * locate DOM elements across page loads and updates. It's designed to run as
6
+ * a Playwright init script, creating selectors that are robust against:
7
+ * - Dynamic class names and IDs (hash-like values)
8
+ * - DOM structure changes
9
+ * - Shadow DOM boundaries
10
+ *
11
+ * The library prioritizes semantic selectors (aria-label, data-testid, text content)
12
+ * over brittle positional selectors, making test automation more maintainable.
13
+ */
1
14
  (() => {
2
- if (!window.__donobu) {
3
- Object.defineProperty(window, '__donobu', {
4
- value: {},
15
+ // ---------------------------------------------------------------------------
16
+ // Setup
17
+ // ---------------------------------------------------------------------------
18
+ const DONOBU_KEY = '__donobu';
19
+
20
+ if (!window[DONOBU_KEY]) {
21
+ Object.defineProperty(window, DONOBU_KEY, {
22
+ value: Object.create(null),
23
+ writable: false,
5
24
  enumerable: false,
6
- writable: true,
7
25
  configurable: false,
8
26
  });
9
27
  }
10
28
 
11
- if (!window.__donobu.cssEscape) {
12
- // Prevent sneaky websites from undefining the CSS escape function.
13
- // We run first and we can save the CSS escape function.
14
- Object.defineProperty(window.__donobu, 'cssEscape', {
15
- value: CSS.escape,
16
- enumerable: false,
17
- writable: false,
18
- configurable: false,
19
- });
20
- }
29
+ const escapeCss =
30
+ typeof CSS !== 'undefined' && CSS.escape
31
+ ? CSS.escape
32
+ : (s) => s.replace(/[^\w-]/g, (c) => '\\' + c);
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Tiny utils
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Properly quotes an attribute value for use in CSS selectors.
40
+ * Handles escaping of backslashes and single quotes.
41
+ * Example: quoteCssAttr("user's name") → "'user\\'s name'"
42
+ */
43
+ const quoteCssAttr = (v) =>
44
+ `'${
45
+ String(v)
46
+ .replace(/\\/g, '\\\\') // escape backslashes
47
+ .replace(/'/g, "\\'") // escape single quotes
48
+ .replace(/\r?\n/g, '\\A ') // escape newlines as CSS \A (newline)
49
+ }'`;
50
+
51
+ /**
52
+ * Counts how many elements match a CSS selector within a given scope.
53
+ * Handles invalid selectors gracefully by returning 0 and logging warnings.
54
+ *
55
+ * @param {string} sel - CSS selector to test
56
+ * @param {Document|ShadowRoot|Element} scope - Root element to search within
57
+ * @returns {number} Number of matching elements
58
+ */
59
+ const countMatchesCSS = (sel, scope) => {
60
+ if (!sel || !scope || typeof scope.querySelectorAll !== 'function') {
61
+ return 0;
62
+ }
21
63
 
22
- class SelectorToCount {
23
- constructor(selector) {
24
- this.selector = selector;
25
-
26
- if (selector.startsWith('//')) {
27
- // Handle XPath selector
28
- const xpathResult = document.evaluate(
29
- selector,
30
- document,
31
- null,
32
- XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
33
- null,
34
- );
35
- this.count = xpathResult.snapshotLength;
64
+ try {
65
+ return scope.querySelectorAll(sel).length;
66
+ } catch (e) {
67
+ console.warn('[Donobu] Invalid CSS selector:', sel, e.message);
68
+ return 0;
69
+ }
70
+ };
71
+
72
+ /**
73
+ * Counts how many elements match an XPath expression within a given scope.
74
+ * XPath is more powerful than CSS for text-based matching.
75
+ *
76
+ * @param {string} xp - XPath expression to evaluate
77
+ * @param {Document|ShadowRoot|Element} scope - Root element to search within
78
+ * @returns {number} Number of matching elements
79
+ */
80
+ const countMatchesXPath = (xp, scope) => {
81
+ if (!xp || !scope) {
82
+ return 0;
83
+ }
84
+
85
+ try {
86
+ const doc = getDocumentForScope(scope);
87
+
88
+ if (!doc) {
89
+ return 0;
90
+ }
91
+
92
+ return doc.evaluate(
93
+ xp,
94
+ scope,
95
+ null,
96
+ XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
97
+ null,
98
+ ).snapshotLength;
99
+ } catch (e) {
100
+ console.warn('[Donobu] Invalid XPath expression:', xp, e.message);
101
+ return 0;
102
+ }
103
+ };
104
+
105
+ /**
106
+ * Gets the appropriate Document object for XPath evaluation.
107
+ * Shadow roots need their host's document, regular elements use their ownerDocument.
108
+ */
109
+ const getDocumentForScope = (scope) => {
110
+ if (scope instanceof Document) {
111
+ return scope;
112
+ } else if (scope instanceof ShadowRoot) {
113
+ return scope.host?.ownerDocument || document;
114
+ } else {
115
+ return scope?.ownerDocument || document;
116
+ }
117
+ };
118
+
119
+ /* --------------------------------------------------------------- */
120
+ /* Collect all (open) shadow hosts between element and document */
121
+ /* --------------------------------------------------------------- */
122
+
123
+ /**
124
+ * Collects all shadow hosts in the path from an element to the document root.
125
+ * This is essential for generating selectors that work across shadow boundaries.
126
+ *
127
+ * Modern web apps heavily use Shadow DOM (web components, React portals, etc.)
128
+ * and selectors must account for these boundaries.
129
+ *
130
+ * @param {Element} el - Target element
131
+ * @returns {Array<{host: Element, open: boolean}>} Chain of shadow hosts, nearest-to-document first
132
+ */
133
+ function gatherShadowChain(el) {
134
+ const chain = []; // [{ host, open }, …] nearest-to-document first
135
+ let node = el;
136
+ const visited = new WeakSet(); // Prevent infinite loops
137
+
138
+ while (node && node !== document && !visited.has(node)) {
139
+ visited.add(node);
140
+ const root = node.getRootNode?.();
141
+
142
+ if (root instanceof ShadowRoot) {
143
+ chain.unshift({ host: root.host, open: root.mode === 'open' });
144
+ node = root.host;
36
145
  } else {
37
- // Handle CSS selector
38
- this.count = document.querySelectorAll(selector).length;
146
+ node = node.parentNode;
39
147
  }
40
148
  }
149
+ return chain;
41
150
  }
42
151
 
43
- // A helper function to safely escape any string for use in an XPath expression:
44
- function xpathLiteral(value) {
45
- // If the string has no single quotes, wrap it in single quotes
46
- if (!value.includes("'")) {
47
- return `'${value}'`;
152
+ /**
153
+ * Detects machine-generated identifiers that are likely to change.
154
+ * These heuristics help avoid creating brittle selectors based on:
155
+ * - Webpack hash IDs
156
+ * - UUID-style identifiers
157
+ * - Long hexadecimal strings
158
+ *
159
+ * @param {string|SVGAnimatedString} raw - ID or class name to test
160
+ * @returns {boolean} True if the value looks machine-generated
161
+ */
162
+ const isHashLike = (raw) => {
163
+ if (raw == null) return false;
164
+ const str =
165
+ typeof raw === 'string'
166
+ ? raw
167
+ : typeof raw.baseVal === 'string' // SVGAnimatedString
168
+ ? raw.baseVal
169
+ : String(raw);
170
+
171
+ /* Heuristics */
172
+ return (
173
+ // 1. Pure 6+ hex digits
174
+ /^[a-f0-9]{6,}$/i.test(str) ||
175
+ // 2. UUID v4 style
176
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
177
+ str,
178
+ ) ||
179
+ // 3. ≥2 long hex-ish segments joined with - or _
180
+ str.split(/[-_]/).filter((s) => /^[a-f0-9]{6,}$/i.test(s)).length >= 2
181
+ );
182
+ };
183
+
184
+ // Semantic HTML5 elements that are likely to be unique and stable.
185
+ const LANDMARK_TAGS = ['header', 'nav', 'main', 'footer', 'form'];
186
+
187
+ /**
188
+ * Safely escapes text for use in XPath expressions.
189
+ * XPath has complex quoting rules, especially when text contains both single and double quotes.
190
+ * This function handles all edge cases including control characters and Unicode.
191
+ *
192
+ * @param {string} txt - Text to escape for XPath
193
+ * @returns {string} Properly escaped XPath string literal
194
+ */
195
+ const safeXpath = (txt) => {
196
+ // Remove control characters but preserve valid Unicode including surrogate pairs
197
+ const cleaned = String(txt)
198
+ .replace(/[\u0000-\u001F\u007F-\u009F]/g, '')
199
+ .replace(/\\/g, '\\\\');
200
+ // If no quotes at all, use single quotes (simplest case)
201
+ if (!cleaned.includes("'") && !cleaned.includes('"')) {
202
+ return `'${cleaned}'`;
48
203
  }
49
204
 
50
- // If the string has no double quotes, wrap in double quotes
51
- if (!value.includes('"')) {
52
- return `"${value}"`;
205
+ // If only double quotes, use single quotes
206
+ if (!cleaned.includes("'")) {
207
+ return `'${cleaned}'`;
53
208
  }
54
209
 
55
- // Otherwise, the string has both single and double quotes.
56
- // Split on single quotes and use concat(...) with "'".
57
- let parts = value.split("'");
58
- // Each part is wrapped in single quotes; join with ,"'", so the final expression is valid.
59
- return `concat('${parts.join("', \"'\", '")}')`;
60
- }
210
+ // If only single quotes, use double quotes
211
+ if (!cleaned.includes('"')) {
212
+ return `"${cleaned}"`;
213
+ }
61
214
 
62
- class SelectorGenerator {
63
- constructor(element) {
64
- this.element = element;
215
+ // Both types of quotes present - need to use concat()
216
+ const parts = cleaned.split("'");
217
+ const concatParts = [];
218
+
219
+ for (let i = 0; i < parts.length; i++) {
220
+ if (i > 0) {
221
+ // Add the single quote that was removed by split
222
+ concatParts.push('"\'"');
223
+ }
224
+
225
+ if (parts[i]) {
226
+ // If this part contains double quotes, escape them
227
+ const part = parts[i].includes('"')
228
+ ? `'${parts[i]}'` // Safe to use single quotes
229
+ : `"${parts[i]}"`; // Use double quotes for variety/readability
230
+ concatParts.push(part);
231
+ }
232
+ }
233
+
234
+ return `concat(${concatParts.join(', ')})`;
235
+ };
236
+
237
+ /**
238
+ * Safely extracts className from both regular DOM and SVG elements.
239
+ * SVG elements have className as an SVGAnimatedString object, not a string.
240
+ *
241
+ * @param {Element} el - Element to get class value from
242
+ * @returns {string} Class value as a string
243
+ */
244
+ const getClassValue = (el) => {
245
+ if (!el.className) {
246
+ return '';
247
+ }
248
+
249
+ if (typeof el.className === 'string') {
250
+ // Regular DOM element
251
+ return el.className;
252
+ }
253
+
254
+ // SVG element with SVGAnimatedString
255
+ if (
256
+ typeof el.className === 'object' &&
257
+ el.className?.baseVal !== undefined
258
+ ) {
259
+ return el.className.baseVal;
260
+ }
261
+
262
+ // Fallback for unknown className types
263
+ return String(el.className);
264
+ };
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Core
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /**
271
+ * The main class that generates smart selectors for a given element.
272
+ *
273
+ * Strategy overview:
274
+ * 1. Try semantic anchors first (ID, ARIA, data attributes, text content).
275
+ * 2. Fall back to positional selectors when semantic ones aren't unique.
276
+ * 3. Rank results by uniqueness, then by semantic value, then by length.
277
+ *
278
+ * Weight system (higher = higher priority):
279
+ * - 100: Unique, human-readable ID
280
+ * - 95: data-testid, data-test attributes
281
+ * - 90: aria-label
282
+ * - 88: Label associations
283
+ * - 85: Unique text content
284
+ * - 80: name attribute
285
+ * - 70: title attribute
286
+ * - 60: role attribute
287
+ * - 50: href attribute
288
+ * - 40: other data-* attributes
289
+ * - 35: stable class names
290
+ * - 20: positional with stable ancestors
291
+ * - 1: full DOM path (last resort)
292
+ */
293
+ class SelectorBuilder {
294
+ constructor(el) {
295
+ this.el = el;
296
+ this.tag = el.tagName.toLowerCase();
297
+ this.root = el.getRootNode?.() || document;
298
+ this.list = new Map(); // selector → {cnt, weight}
65
299
  }
66
300
 
67
- generate() {
68
- const selectors = new Set();
69
-
70
- // Generate ID-based selector
71
- this.getIdSelector().forEach((selector) => selectors.add(selector));
72
-
73
- // Generate class-based selector
74
- this.getClassSelector().forEach((selector) => selectors.add(selector));
75
-
76
- // Generate attribute-based selectors
77
- this.getAttributeSelectors().forEach((selector) =>
78
- selectors.add(selector),
79
- );
80
-
81
- // Generate text-based selectors
82
- this.getTextBasedSelectors().forEach((selector) =>
83
- selectors.add(selector),
84
- );
85
-
86
- // Generate nth-child and nth-of-type selectors
87
- this.getNthChildSelectors().forEach((selector) =>
88
- selectors.add(selector),
89
- );
90
-
91
- // Generate tag-based selector
92
- selectors.add(this.getTagSelector());
93
-
94
- // Generate placeholder-based selector
95
- this.getPlaceholderTextSelector().forEach((selector) =>
96
- selectors.add(selector),
97
- );
98
-
99
- // Generate aria-label-based selector
100
- this.getAriaLabelSelector().forEach((selector) =>
101
- selectors.add(selector),
102
- );
103
-
104
- // Generate label-based selectors for input elements
105
- this.getLabelBasedSelectors().forEach((selector) =>
106
- selectors.add(selector),
107
- );
108
-
109
- // Generate DOM position-based selectors
110
- this.getDOMPositionSelectors().forEach((selector) =>
111
- selectors.add(selector),
112
- );
113
-
114
- // Combine selectors for robustness
115
- this.getCombinedSelector().forEach((selector) => selectors.add(selector));
116
-
117
- function selectorTiebreakerPriority(sel) {
118
- // highest-to-lowest: aria-label, placeholder, text-based XPath, DOM position, id, other
119
- if (/\[aria-label\s*=/.test(sel) || /@aria-label/.test(sel)) return 0;
120
- if (/\[placeholder\s*=/.test(sel) || /@placeholder/.test(sel)) return 1;
121
- if (sel.startsWith('//')) return 2;
122
- if (sel.includes(' > ')) return 3; // DOM position selector
123
- if (/^#/.test(sel)) return 4;
124
- return 5;
301
+ /**
302
+ * Main entry point - generates and ranks all possible selectors.
303
+ * @returns {Array<string>} Ordered array of selectors, best first.
304
+ */
305
+ build() {
306
+ /* 1. Semantic anchors */
307
+ this.idAnchor(); // #my-button
308
+ this.ariaAnchor(); // [aria-label="Submit form"]
309
+ this.attrAnchors(); // [data-testid="login-btn"]
310
+ this.placeholderAnchor(); // [placeholder="Enter email"]
311
+ this.textAnchor(); // .//button[text()="Click me"]
312
+ this.labelAnchor(); // .//label[text()="Email"]/input
313
+ this.classAnchor(); // button.primary-btn
314
+
315
+ /* 2. Positional fall-backs - used when semantic anchors aren't unique */
316
+ this.stableAncestorAnchors(); // #header > nav > button:nth-of-type(2)
317
+ this.fullDomPath(); // html > body > div:nth-of-type(3) > button
318
+
319
+ /* 3. Rank by uniqueness (lower count = better), then weight, then length */
320
+ const results = [...this.list.entries()]
321
+ .filter(([, m]) => m.cnt > 0) // valid only
322
+ .sort((a, b) => {
323
+ const da = a[1],
324
+ db = b[1];
325
+ if (da.cnt !== db.cnt) return da.cnt - db.cnt;
326
+ if (da.weight !== db.weight) return db.weight - da.weight; // Higher weight = higher priority
327
+ return a[0].length - b[0].length;
328
+ })
329
+ .map(([sel]) => sel);
330
+
331
+ // Clean up to prevent memory leaks
332
+ this.list.clear();
333
+ return results;
334
+ }
335
+
336
+ /* ---------------------------------------------------------------------- */
337
+ /* Utils */
338
+ /* ---------------------------------------------------------------------- */
339
+
340
+ /**
341
+ * Adds a selector to the candidate list if it actually matches the target element.
342
+ * Supports both CSS selectors and XPath expressions.
343
+ *
344
+ * @param {string} sel - Selector to test
345
+ * @param {number} weight - Priority weight (higher = more preferred)
346
+ */
347
+ push(sel, weight) {
348
+ if (!sel || typeof sel !== 'string') {
349
+ return;
125
350
  }
126
351
 
127
- const rankedSelectors = Array.from(selectors)
128
- .map((selector) => {
129
- try {
130
- return new SelectorToCount(selector);
131
- } catch (e) {
132
- console.warn(`Failed to create selector: ${selector}`, e);
133
- return null;
352
+ let cnt = 0;
353
+ let matchesOurElement = false;
354
+ const isXPath =
355
+ sel.startsWith('/') || sel.startsWith('.//') || sel.startsWith('(');
356
+
357
+ try {
358
+ if (isXPath) {
359
+ const doc = getDocumentForScope(this.root);
360
+
361
+ if (!doc) {
362
+ return;
134
363
  }
135
- })
136
- .filter((a) => a !== null)
137
- .filter((a) => a.count !== 0)
138
- .sort((a, b) => {
139
- // 1. fewer matches -> better
140
- if (a.count !== b.count) return a.count - b.count;
141
- // 2. tie-break on priority class
142
- return (
143
- selectorTiebreakerPriority(a.selector) -
144
- selectorTiebreakerPriority(b.selector)
364
+
365
+ const snap = doc.evaluate(
366
+ sel,
367
+ this.root,
368
+ null,
369
+ XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
370
+ null,
145
371
  );
146
- })
147
- .map((a) => a.selector);
372
+ cnt = snap.snapshotLength;
373
+
374
+ if (cnt > 0) {
375
+ for (let i = 0; i < cnt; i++) {
376
+ if (snap.snapshotItem(i) === this.el) {
377
+ matchesOurElement = true;
378
+ break;
379
+ }
380
+ }
381
+ }
382
+ } else {
383
+ // CSS Selector
384
+ const results = this.root.querySelectorAll(sel);
385
+ cnt = results.length;
386
+
387
+ if (cnt > 0) {
388
+ matchesOurElement = [...results].includes(this.el);
389
+ }
390
+ }
391
+ } catch (e) {
392
+ // Log for debugging but don't throw
393
+ console.warn('[Donobu] Invalid selector:', sel, e.message);
394
+ return;
395
+ }
148
396
 
149
- return rankedSelectors;
397
+ if (matchesOurElement) {
398
+ this.list.set(sel, { cnt, weight });
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Adds a base selector and tries to make it unique by adding parent context.
404
+ * This is key to the "smart" behavior - we start with semantic selectors
405
+ * and add just enough context to make them unique.
406
+ *
407
+ * Example: button.submit → #form > button.submit → #header > #form > button.submit
408
+ *
409
+ * @param {string} baseSel - Base CSS selector
410
+ * @param {number} weight - Priority weight
411
+ * @param {number} maxDepth - Maximum ancestor levels to try
412
+ */
413
+ scopedUntilUnique(baseSel, weight, maxDepth = 5) {
414
+ this.push(baseSel, weight - 10); // keep raw anchor (lower priority)
415
+
416
+ if (countMatchesCSS(baseSel, this.root) === 1) {
417
+ this.push(baseSel, weight); // High priority if unique
418
+ return;
419
+ }
420
+
421
+ let cur = this.el;
422
+ let depth = 0;
423
+ let sel = baseSel;
424
+ const visited = new WeakSet(); // Prevent infinite loops
425
+
426
+ while (cur.parentElement && depth < maxDepth && !visited.has(cur)) {
427
+ visited.add(cur);
428
+
429
+ const nextElem = cur.parentElement;
430
+ const tag = nextElem.tagName.toLowerCase();
431
+ const siblings = nextElem.parentElement
432
+ ? [...nextElem.parentElement.children]
433
+ : [];
434
+ const siblingsSame = siblings.filter(
435
+ (c) => c.tagName.toLowerCase() === tag,
436
+ );
437
+ const parentSeg =
438
+ siblingsSame.length > 1
439
+ ? `${tag}:nth-of-type(${siblingsSame.indexOf(nextElem) + 1})`
440
+ : tag;
441
+
442
+ sel = `${parentSeg} > ${sel}`;
443
+
444
+ if (countMatchesCSS(sel, this.root) === 1) {
445
+ this.push(sel, weight); // unique variant
446
+ return;
447
+ }
448
+
449
+ cur = nextElem;
450
+ depth += 1;
451
+ }
150
452
  }
151
453
 
152
- getIdSelector() {
153
- const id = this.element.id;
154
- return id ? [`#${this.escapeCss(id)}`] : [];
454
+ /* ---------------------------------------------------------------------- */
455
+ /* Anchors – High weight = high priority */
456
+ /* ---------------------------------------------------------------------- */
457
+
458
+ /**
459
+ * Generates ID-based selectors, but penalizes machine-generated IDs.
460
+ * Machine-generated IDs (hashes, UUIDs) are likely to change between builds.
461
+ */
462
+ idAnchor() {
463
+ const id = this.el.id;
464
+
465
+ if (!id) {
466
+ return;
467
+ }
468
+
469
+ const dynamic = isHashLike(id) || id.length > 24;
470
+ this.push(`#${escapeCss(id)}`, dynamic ? 20 : 100);
155
471
  }
156
472
 
157
- getClassSelector() {
158
- const className =
159
- typeof this.element.className === 'string'
160
- ? this.element.className
161
- : this.element.className.baseVal;
162
- return className
163
- ? [`.${this.escapeCss(className.trim().replace(/\s+/g, '.'))}`]
164
- : [];
473
+ /**
474
+ * Generates ARIA label selectors - these are excellent for accessibility
475
+ * and tend to be stable since they're user-facing.
476
+ */
477
+ ariaAnchor() {
478
+ const aria = this.el.getAttribute('aria-label');
479
+
480
+ if (aria) {
481
+ this.scopedUntilUnique(`[aria-label=${quoteCssAttr(aria)}]`, 90);
482
+ }
165
483
  }
166
484
 
167
- getAttributeSelectors() {
168
- const selectors = [];
169
- const tagName = this.element.tagName.toLowerCase();
170
- const attributes = this.element.attributes;
171
-
172
- for (let i = 0; i < attributes.length; i++) {
173
- const name = this.escapeCss(attributes[i].name);
174
- const value = this.escapeCss(attributes[i].value);
175
- // Filter out Donobu attributes since they're non-deterministic.
176
- if (
177
- (!name.startsWith('data-donobu-') && name.startsWith('data-')) ||
178
- name === 'href'
179
- ) {
180
- // Escape single quotes in attribute values
181
- const escapedValue = value.replace(/'/g, "\\'");
182
- selectors.push(`[${name}='${escapedValue}']`);
183
- selectors.push(`${tagName}[${name}='${escapedValue}']`);
485
+ /**
486
+ * Generates selectors for various attributes, prioritizing test-specific ones.
487
+ * data-testid and data-test are specifically added for testing and are very stable.
488
+ */
489
+ attrAnchors() {
490
+ const ATTRS = {
491
+ 'data-testid': 95,
492
+ 'data-test': 95,
493
+ name: 80,
494
+ title: 70,
495
+ role: 60,
496
+ href: 50,
497
+ };
498
+
499
+ for (const { name, value } of [...this.el.attributes]) {
500
+ if (!Object.keys(ATTRS).includes(name) && !name.startsWith('data-')) {
501
+ continue;
502
+ } else if (name.startsWith('data-donobu-')) {
503
+ continue;
184
504
  }
185
- }
186
505
 
187
- return selectors;
506
+ const sel = `[${name}=${quoteCssAttr(value)}]`;
507
+ this.scopedUntilUnique(sel, ATTRS[name] ?? 40);
508
+ }
188
509
  }
189
510
 
190
- getTextBasedSelectors() {
191
- const selectors = [];
192
- const tagName = this.element.tagName.toLowerCase();
511
+ /**
512
+ * Generates selectors based on placeholder text.
513
+ * Placeholders are user-visible and typically stable.
514
+ */
515
+ placeholderAnchor() {
516
+ const ph = this.el.getAttribute('placeholder');
193
517
 
194
- // Skip text-based selectors for large containers
195
- if (tagName === 'body' || tagName === 'html') {
196
- return selectors;
518
+ if (ph) {
519
+ this.scopedUntilUnique(`[placeholder=${quoteCssAttr(ph)}]`, 75);
197
520
  }
521
+ }
198
522
 
199
- const textContent = this.element.textContent.trim();
523
+ /**
524
+ * Generates XPath selectors based on text content.
525
+ * Text-based selectors are very semantic but can be brittle if text changes.
526
+ * Uses XPath because CSS can't match on text content directly.
527
+ */
528
+ textAnchor() {
529
+ const raw = this.el.textContent ?? '';
530
+ const text = raw.trim();
531
+
532
+ if (!text || ['body', 'html'].includes(this.tag) || text.length > 100) {
533
+ return;
534
+ }
200
535
 
201
- if (textContent) {
202
- // Use xpathLiteral() to escape any quotes in textContent
203
- const safeText = xpathLiteral(textContent);
204
- selectors.push(`//${tagName}[normalize-space(.)=${safeText}]`);
536
+ const normalizedText = text.replace(/\s+/g, ' ').trim();
537
+ const baseXP = `.//${this.tag}[normalize-space(.)=${safeXpath(normalizedText)}]`;
538
+ const cnt = countMatchesXPath(baseXP, this.root);
539
+
540
+ if (cnt === 0) {
541
+ return;
205
542
  }
206
543
 
207
- return selectors;
544
+ this.push(baseXP, cnt === 1 ? 85 : 30); // Semantic anchor is valuable even if not unique
545
+
546
+ if (cnt > 1) {
547
+ const doc = getDocumentForScope(this.root);
548
+
549
+ if (!doc) {
550
+ return;
551
+ }
552
+
553
+ try {
554
+ const snap = doc.evaluate(
555
+ baseXP,
556
+ this.root,
557
+ null,
558
+ XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
559
+ null,
560
+ );
561
+ let idx = -1;
562
+
563
+ for (let i = 0; i < cnt; i += 1) {
564
+ if (snap.snapshotItem(i) === this.el) {
565
+ idx = i + 1;
566
+ break;
567
+ }
568
+ }
569
+
570
+ if (idx > 0) {
571
+ const uniqueXP = `(${baseXP})[${idx}]`;
572
+ this.push(uniqueXP, 85); // same weight as a unique semantic anchor
573
+ }
574
+ } catch (e) {
575
+ console.warn('[Donobu] XPath evaluation failed:', baseXP, e.message);
576
+ }
577
+ }
208
578
  }
209
579
 
210
- getNthChildSelectors() {
211
- const selectors = [];
212
- const tagName = this.element.tagName.toLowerCase();
213
- const parent = this.element.parentElement;
580
+ /**
581
+ * Generates selectors for form inputs based on their associated labels.
582
+ * This creates very semantic selectors that mirror how users think about forms.
583
+ * Handles both for/id associations and wrapping label elements.
584
+ */
585
+ labelAnchor() {
586
+ if (!['input', 'textarea', 'select'].includes(this.tag)) {
587
+ return;
588
+ }
589
+
590
+ // --- Helper for <label for> and wrapping <label> ---
591
+ // This helper assumes a close structural relationship (sibling/descendant)
592
+ const addForLabel = (lab) => {
593
+ const txt = (lab.textContent || '').trim();
594
+
595
+ if (!txt) {
596
+ return;
597
+ }
214
598
 
215
- if (parent) {
216
- const index = Array.from(parent.children).indexOf(this.element) + 1;
217
- selectors.push(
218
- `${parent.tagName.toLowerCase()} > :nth-child(${index})`,
599
+ const xp = safeXpath(txt);
600
+ // Assumes input is a descendant
601
+ this.push(`.//label[normalize-space()=${xp}]//${this.tag}`, 88);
602
+ // Assumes input is a following sibling
603
+ this.push(
604
+ `.//label[normalize-space()=${xp}]/following-sibling::${this.tag}`,
605
+ 88,
219
606
  );
607
+ };
220
608
 
221
- const typeIndex =
222
- Array.from(parent.children)
223
- .filter((child) => child.tagName.toLowerCase() === tagName)
224
- .indexOf(this.element) + 1;
225
- selectors.push(
226
- `${parent.tagName.toLowerCase()} > ${tagName}:nth-of-type(${typeIndex})`,
609
+ // --- Pattern 1: <label for="..."> ---
610
+ if (this.el.id) {
611
+ const lab = this.root.querySelector(
612
+ `label[for=${quoteCssAttr(this.el.id)}]`,
227
613
  );
614
+
615
+ if (lab) {
616
+ addForLabel(lab);
617
+ }
228
618
  }
229
619
 
230
- return selectors;
231
- }
620
+ // --- Pattern 2: Wrapping <label> ---
621
+ const wrapLab = this.el.closest('label');
232
622
 
233
- getPlaceholderTextSelector() {
234
- const placeholder = this.element.getAttribute('placeholder');
235
- return placeholder
236
- ? [`[placeholder='${this.escapeCss(placeholder)}']`]
237
- : [];
238
- }
623
+ if (wrapLab) {
624
+ addForLabel(wrapLab);
625
+ }
239
626
 
240
- getAriaLabelSelector() {
241
- const ariaLabel = this.element.getAttribute('aria-label');
242
- return ariaLabel ? [`[aria-label='${this.escapeCss(ariaLabel)}']`] : [];
243
- }
627
+ // --- Pattern 3: aria-labelledby ---
628
+ const labelledby = this.el.getAttribute('aria-labelledby');
244
629
 
245
- getLabelBasedSelectors() {
246
- const tagName = this.element.tagName.toLowerCase();
247
-
248
- if (['input', 'textarea', 'select'].includes(tagName)) {
249
- // Check for 'for' attribute
250
- const id = this.element.id;
251
- if (id) {
252
- const label = document.querySelector(`label[for='${id}']`);
253
- if (label) {
254
- const labelText = label.textContent?.trim();
255
- if (labelText) {
256
- const safeLabelText = xpathLiteral(labelText);
257
- return [
258
- `//label[text()=${safeLabelText}]/following-sibling::${tagName}`,
259
- `//label[text()=${safeLabelText}]/${tagName}`,
260
- ];
261
- }
630
+ if (labelledby) {
631
+ const labelIds = labelledby.split(/\s+/);
632
+
633
+ for (const labelId of labelIds) {
634
+ if (!labelId) {
635
+ continue;
262
636
  }
263
- }
264
637
 
265
- // Check for wrapping label
266
- const wrappingLabel = this.element.closest('label');
638
+ const lab = this.root.querySelector(`#${escapeCss(labelId)}`);
267
639
 
268
- if (wrappingLabel) {
269
- // Remove the element's own value from the label's text if it appears,
270
- // then trim again
271
- const labelText = wrappingLabel.textContent
272
- .trim()
273
- .replace(this.element.value || '', '')
274
- .trim();
640
+ if (lab) {
641
+ const txt = (lab.textContent || '').trim();
275
642
 
276
- if (labelText) {
277
- const safeLabelText = xpathLiteral(labelText);
278
- return [`//label[contains(text(), ${safeLabelText})]/${tagName}`];
643
+ if (txt) {
644
+ const textXp = safeXpath(txt);
645
+ const labelTag = lab.tagName.toLowerCase();
646
+ // This is the key: a robust XPath that finds the input by matching
647
+ // its aria-labelledby attribute to the ID of a label found by its text.
648
+ // It works regardless of where the label and input are in the DOM.
649
+ // The `contains(concat(' ',...` part safely checks for a word in a space-separated list.
650
+ const robustXp = `//${this.tag}[contains(concat(' ', normalize-space(@aria-labelledby), ' '), concat(' ', //${labelTag}[normalize-space()=${textXp}]/@id, ' '))]`;
651
+ this.push(robustXp, 90); // Give it a high weight, similar to aria-label
652
+ }
279
653
  }
280
654
  }
281
655
  }
656
+ }
657
+
658
+ /**
659
+ * Generates class-based selectors, but only for human-readable class names.
660
+ * Avoids CSS-in-JS generated classes and other machine-generated names.
661
+ */
662
+ classAnchor() {
663
+ const classValue = getClassValue(this.el);
664
+
665
+ if (!classValue) {
666
+ return;
667
+ }
668
+
669
+ const clsToken = classValue.split(/\s+/).find((c) => c && !isHashLike(c));
282
670
 
283
- return [];
671
+ if (!clsToken) {
672
+ return;
673
+ }
674
+
675
+ this.scopedUntilUnique(`${this.tag}.${escapeCss(clsToken)}`, 35);
284
676
  }
285
677
 
286
- getDOMPositionSelectors() {
287
- const selectors = [];
678
+ /* -------------------------- Positional --------------------------------- */
288
679
 
289
- // Generate full DOM position-based selector without depth limit
290
- let currentElement = this.element;
291
- let path = [];
680
+ /**
681
+ * Generates selectors by finding stable ancestors and creating relative paths.
682
+ * This creates selectors like: #header > nav > button:nth-of-type(2)
683
+ *
684
+ * The key insight is that while the target element might not have good identifiers,
685
+ * its ancestors might, and we can create a stable path from those ancestors.
686
+ */
687
+ stableAncestorAnchors() {
688
+ let anc = this.el.parentElement;
689
+ let depth = 0;
690
+ const visited = new WeakSet(); // Prevent infinite loops
292
691
 
293
- // Build the complete path from the element up to html
294
- while (currentElement && currentElement !== document) {
295
- let tagName = currentElement.tagName.toLowerCase();
296
- const parent = currentElement.parentElement;
692
+ while (anc && depth < 7 && !visited.has(anc)) {
693
+ visited.add(anc);
297
694
 
298
- if (parent) {
299
- const siblings = Array.from(parent.children).filter(
300
- (child) => child.tagName.toLowerCase() === tagName,
301
- );
302
- if (siblings.length > 1) {
303
- const index = siblings.indexOf(currentElement) + 1;
304
- tagName += `:nth-of-type(${index})`;
695
+ const sel = this.bestStableSelector(anc);
696
+
697
+ if (sel) {
698
+ const rel = this.relPath(anc, this.el);
699
+
700
+ if (rel) {
701
+ this.push(`${sel} > ${rel}`, 20);
305
702
  }
306
703
  }
307
704
 
308
- path.unshift(tagName);
309
- currentElement = currentElement.parentElement;
705
+ anc = anc.parentElement;
706
+ depth += 1;
310
707
  }
708
+ }
709
+
710
+ /**
711
+ * Generates a full DOM path selector as absolute last resort.
712
+ * These are very brittle but will always work for the current page state.
713
+ * Example: html > body > div:nth-of-type(3) > section > button:nth-of-type(1)
714
+ */
715
+ fullDomPath() {
716
+ let cur = this.el;
717
+ const segs = [];
718
+ const visited = new WeakSet(); // Prevent infinite loops
719
+
720
+ while (
721
+ cur &&
722
+ cur.parentElement &&
723
+ cur !== this.root &&
724
+ !visited.has(cur)
725
+ ) {
726
+ visited.add(cur);
727
+
728
+ let part = cur.tagName.toLowerCase();
729
+ const p = cur.parentElement;
730
+ const like = [...p.children].filter(
731
+ (c) => c.tagName.toLowerCase() === part,
732
+ );
733
+
734
+ if (like.length > 1) {
735
+ let i = like.indexOf(cur) + 1;
736
+ part += `:nth-of-type(${i})`;
737
+ }
311
738
 
312
- // Create the full selector string with ">" separators
313
- if (path.length > 1) {
314
- selectors.push(path.join(' > '));
739
+ segs.unshift(part);
740
+ cur = p;
315
741
  }
316
742
 
317
- return selectors;
743
+ if (segs.length) {
744
+ // Lowest priority
745
+ this.push(segs.join(' > '), 1);
746
+ }
318
747
  }
319
748
 
320
- getCombinedSelector() {
321
- const classSelector = this.getClassSelector()[0];
322
- return classSelector ? [`${this.getTagSelector()}${classSelector}`] : [];
749
+ /* ---------------------------------------------------------------------- */
750
+ /* Helpers */
751
+ /* ---------------------------------------------------------------------- */
752
+
753
+ /**
754
+ * Finds the best stable selector for a given element.
755
+ * "Stable" means likely to survive page updates and not be machine-generated.
756
+ *
757
+ * Priority order:
758
+ * 1. Human-readable unique ID.
759
+ * 2. Unique data attributes (especially test-related).
760
+ * 3. Unique tag + stable class combination.
761
+ * 4. Unique landmark tags.
762
+ *
763
+ * @param {Element} el - Element to find selector for
764
+ * @returns {string|null} Best stable selector, or null if none found
765
+ */
766
+ bestStableSelector(el) {
767
+ const scope = el.getRootNode();
768
+
769
+ if (el.id && !isHashLike(el.id) && el.id.length <= 24) {
770
+ if (countMatchesCSS(`#${escapeCss(el.id)}`, scope) === 1) {
771
+ return `#${escapeCss(el.id)}`;
772
+ }
773
+ }
774
+
775
+ const ATTRS = [
776
+ 'data-testid',
777
+ 'data-test',
778
+ 'name',
779
+ 'title',
780
+ 'role',
781
+ 'aria-label',
782
+ ];
783
+
784
+ for (const a of ATTRS) {
785
+ const v = el.getAttribute(a);
786
+
787
+ if (v && countMatchesCSS(`[${a}=${quoteCssAttr(v)}]`, scope) === 1) {
788
+ return `[${a}=${quoteCssAttr(v)}]`;
789
+ }
790
+ }
791
+
792
+ const tag = el.tagName.toLowerCase();
793
+ const classValue = getClassValue(el);
794
+
795
+ if (classValue) {
796
+ const cls = classValue.split(/\s+/).find((c) => c && !isHashLike(c));
797
+
798
+ if (cls && countMatchesCSS(`${tag}.${escapeCss(cls)}`, scope) === 1) {
799
+ return `${tag}.${escapeCss(cls)}`;
800
+ }
801
+ }
802
+
803
+ if (LANDMARK_TAGS.includes(tag) && countMatchesCSS(tag, scope) === 1) {
804
+ return tag;
805
+ }
806
+
807
+ return null;
323
808
  }
324
809
 
325
- getTagSelector() {
326
- return this.element.tagName.toLowerCase();
810
+ /**
811
+ * Creates a relative path between an ancestor and target element.
812
+ * Used to build selectors like: ancestor > child:nth-of-type(2) > target
813
+ *
814
+ * @param {Element} anc - Ancestor element
815
+ * @param {Element} tgt - Target element
816
+ * @returns {string} Relative path selector
817
+ */
818
+ relPath(anc, tgt) {
819
+ const bits = [];
820
+ let cur = tgt;
821
+ const visited = new WeakSet(); // Prevent infinite loops
822
+
823
+ while (cur && cur !== anc && !visited.has(cur)) {
824
+ visited.add(cur);
825
+ let seg = cur.tagName.toLowerCase();
826
+ const p = cur.parentElement;
827
+
828
+ if (p) {
829
+ const like = [...p.children].filter(
830
+ (c) => c.tagName.toLowerCase() === seg,
831
+ );
832
+
833
+ if (like.length > 1) {
834
+ seg += `:nth-of-type(${like.indexOf(cur) + 1})`;
835
+ }
836
+ }
837
+
838
+ bits.unshift(seg);
839
+ cur = p;
840
+ }
841
+
842
+ return bits.join(' > ');
327
843
  }
844
+ }
845
+
846
+ /* --------------------------------------------------------------- */
847
+ /* Public: returns Array<Array<string>> */
848
+ /* --------------------------------------------------------------- */
849
+
850
+ /**
851
+ * Generates selectors that work across shadow DOM boundaries.
852
+ *
853
+ * Modern web applications often use shadow DOM for encapsulation (web components,
854
+ * React portals, design systems). A single element might be nested within multiple
855
+ * shadow roots, each requiring separate selectors.
856
+ *
857
+ * This function returns an array of selector arrays - one for each shadow boundary
858
+ * that needs to be crossed to reach the target element.
859
+ *
860
+ * Example output for an element deep in shadow DOM:
861
+ * [
862
+ * ['#app'], // Selector for outermost shadow host
863
+ * ['my-component'], // Selector for middle shadow host
864
+ * ['button.primary', '#btn1'] // Selectors for target element
865
+ * ]
866
+ *
867
+ * @param {Element} el - Target element (possibly inside shadow DOM)
868
+ * @returns {Array<Array<string>>} Array of selector layers
869
+ */
870
+ function generateSmartSelectorLayers(el) {
871
+ const chain = gatherShadowChain(el);
872
+ const layers = [];
873
+
874
+ for (const { host, open } of chain) {
875
+ if (!open) {
876
+ layers.push(['✖︎ closed shadow-host']);
877
+ continue;
878
+ }
328
879
 
329
- escapeCss(str) {
330
- return window.__donobu.cssEscape(str);
880
+ layers.push(window[DONOBU_KEY].generateSmartSelectors(host));
331
881
  }
882
+
883
+ layers.push(window[DONOBU_KEY].generateSmartSelectors(el));
884
+ return layers;
332
885
  }
333
886
 
334
- if (!window.__donobu.generateSmartSelectors) {
335
- Object.defineProperty(window.__donobu, 'generateSmartSelectors', {
336
- value: (element) => {
887
+ // ---------------------------------------------------------------------------
888
+ // Public API
889
+ // ---------------------------------------------------------------------------
890
+ if (!window[DONOBU_KEY].generateSmartSelectors) {
891
+ Object.defineProperty(window[DONOBU_KEY], 'generateSmartSelectors', {
892
+ value: (el) => {
337
893
  try {
338
- return new SelectorGenerator(element).generate();
339
- } catch (e) {
340
- console.warn('Exception while generating selector', e);
894
+ if (!el || typeof el.tagName !== 'string') {
895
+ return [];
896
+ }
897
+
898
+ return new SelectorBuilder(el).build();
899
+ } catch (err) {
900
+ console.warn('[Donobu] selector generation failed:', err);
341
901
  return [];
342
902
  }
343
903
  },
904
+ writable: false,
344
905
  enumerable: false,
906
+ configurable: false,
907
+ });
908
+ }
909
+
910
+ if (!window[DONOBU_KEY].generateSmartSelectorLayers) {
911
+ Object.defineProperty(window[DONOBU_KEY], 'generateSmartSelectorLayers', {
912
+ value: (el) => {
913
+ try {
914
+ if (!el || typeof el.tagName !== 'string') {
915
+ return [];
916
+ }
917
+
918
+ return generateSmartSelectorLayers(el);
919
+ } catch (err) {
920
+ console.warn('[Donobu] selector layer generation failed:', err);
921
+ return [];
922
+ }
923
+ },
345
924
  writable: false,
925
+ enumerable: false,
346
926
  configurable: false,
347
927
  });
348
928
  }