@xylentis/testgen 0.0.0-stage → 1.1.0

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.
@@ -0,0 +1,426 @@
1
+ // testgen locator engine.
2
+ //
3
+ // testgen (@xylentis/testgen), Copyright (C) 2026 Xylentis. Licensed under AGPL-3.0-only with an additional
4
+ // permission: as part of tests generated by testgen, this file may be used, modified and distributed under
5
+ // terms of your choice (see NOTICE in @xylentis/testgen).
6
+ //
7
+ // Runs inside the page through WebDriver's executeScript and resolves Playwright-style locator
8
+ // steps (role, text, label, test id, CSS, XPath, filters) to elements, so that Selenium tests
9
+ // find elements the way the Playwright recorder chose them. Shared by the Selenium support files
10
+ // of every language: it must stay one self-contained function.
11
+ //
12
+ // A step is one of:
13
+ // { t: 'css' | 'xpath', v } { t: 'role', role, name?, exact?, attrs? }
14
+ // { t: 'text' | 'label' | 'placeholder' | 'alt' | 'title', v, exact? }
15
+ // { t: 'testid', attr, v } { t: 'hasText', v, exact?, not? }
16
+ // { t: 'has', steps, not? } { t: 'visible', v }
17
+ // { t: 'nth', v } { t: 'and' | 'or', steps }
18
+ // where a text value v is a string or { source, flags } for a regular expression.
19
+ function testgenResolve(stepsJson) {
20
+ const steps = typeof stepsJson === 'string' ? JSON.parse(stepsJson) : stepsJson;
21
+ const skipText = new Set(['script', 'style', 'noscript', 'template', 'head']);
22
+ const textCache = new Map();
23
+
24
+ const isElement = node => !!node && node.nodeType === 1;
25
+ const normalize = text => String(text == null ? '' : text).replace(/​/g, '').trim().replace(/\s+/g, ' ');
26
+ const unique = list => [...new Set(list)];
27
+ const parentOf = el => el.parentElement || (el.parentNode && el.parentNode.host) || null;
28
+
29
+ // Playwright semantics: strings match case-insensitively as substrings unless exact.
30
+ const matches = (value, expected, exact) => {
31
+ const text = normalize(value);
32
+ if (expected && typeof expected === 'object')
33
+ return new RegExp(expected.source, expected.flags.replace(/[gy]/g, '')).test(text);
34
+ const wanted = normalize(expected);
35
+ return exact ? text === wanted : text.toLowerCase().includes(wanted.toLowerCase());
36
+ };
37
+
38
+ // Elements below `root` in document order, including open shadow roots.
39
+ const descendants = root => {
40
+ const result = [];
41
+ const visit = node => {
42
+ for (let child = node.firstElementChild; child; child = child.nextElementSibling) {
43
+ result.push(child);
44
+ if (child.shadowRoot)
45
+ visit(child.shadowRoot);
46
+ visit(child);
47
+ }
48
+ };
49
+ visit(root);
50
+ return result;
51
+ };
52
+
53
+ const childrenOf = el => [...el.children, ...(el.shadowRoot ? el.shadowRoot.children : [])];
54
+
55
+ const elementText = el => {
56
+ if (textCache.has(el))
57
+ return textCache.get(el);
58
+ let text = '';
59
+ if (el.localName === 'input' && ['button', 'submit', 'reset'].includes(el.type)) {
60
+ text = el.value;
61
+ } else {
62
+ const add = node => {
63
+ for (let child = node.firstChild; child; child = child.nextSibling) {
64
+ if (child.nodeType === 3)
65
+ text += child.data;
66
+ else if (isElement(child) && !skipText.has(child.localName))
67
+ text += elementText(child);
68
+ }
69
+ };
70
+ if (el.shadowRoot)
71
+ add(el.shadowRoot);
72
+ add(el);
73
+ }
74
+ textCache.set(el, text);
75
+ return text;
76
+ };
77
+
78
+ const isVisible = el => {
79
+ const target = /^(option|optgroup)$/.test(el.localName) ? (el.closest('select') || el) : el;
80
+ const style = getComputedStyle(target);
81
+ if (style.visibility === 'hidden')
82
+ return false;
83
+ if (style.display === 'contents')
84
+ return childrenOf(target).some(isVisible);
85
+ const rect = target.getBoundingClientRect();
86
+ return rect.width > 0 && rect.height > 0;
87
+ };
88
+
89
+ // Hidden from assistive technology: getByRole skips these unless includeHidden is set.
90
+ const isHiddenForAria = el => {
91
+ for (let node = el; node; node = parentOf(node)) {
92
+ if (node.getAttribute('aria-hidden') === 'true')
93
+ return true;
94
+ }
95
+ const target = /^(option|optgroup)$/.test(el.localName) ? (el.closest('select') || el) : el;
96
+ const style = getComputedStyle(target);
97
+ if (style.display === 'contents')
98
+ return false;
99
+ if (typeof target.checkVisibility === 'function')
100
+ return !target.checkVisibility({ visibilityProperty: true });
101
+ return style.display === 'none' || style.visibility === 'hidden';
102
+ };
103
+
104
+ const inputRole = el => {
105
+ const type = (el.getAttribute('type') || 'text').toLowerCase();
106
+ if (type === 'hidden')
107
+ return null;
108
+ if (type === 'search')
109
+ return el.hasAttribute('list') ? 'combobox' : 'searchbox';
110
+ if (['email', 'tel', 'text', 'url', ''].includes(type))
111
+ return el.hasAttribute('list') ? 'combobox' : 'textbox';
112
+ if (type === 'file')
113
+ return 'button';
114
+ const roles = { button: 'button', checkbox: 'checkbox', image: 'button', number: 'spinbutton', radio: 'radio', range: 'slider', reset: 'button', submit: 'button' };
115
+ return roles[type] || 'textbox';
116
+ };
117
+
118
+ const hasExplicitName = el => !!(normalize(el.getAttribute('aria-label')) || el.getAttribute('aria-labelledby') || normalize(el.getAttribute('title')));
119
+ const sectioningAncestor = el => !!(parentOf(el) && parentOf(el).closest('article, aside, main, nav, section'));
120
+
121
+ const implicitRole = el => {
122
+ const tag = el.localName;
123
+ switch (tag) {
124
+ case 'a': case 'area': return el.hasAttribute('href') ? 'link' : null;
125
+ case 'article': return 'article';
126
+ case 'aside': return 'complementary';
127
+ case 'blockquote': return 'blockquote';
128
+ case 'button': return 'button';
129
+ case 'caption': return 'caption';
130
+ case 'code': return 'code';
131
+ case 'datalist': return 'listbox';
132
+ case 'dd': return 'definition';
133
+ case 'del': return 'deletion';
134
+ case 'details': return 'group';
135
+ case 'dfn': case 'dt': return 'term';
136
+ case 'dialog': return 'dialog';
137
+ case 'em': return 'emphasis';
138
+ case 'fieldset': return 'group';
139
+ case 'figure': return 'figure';
140
+ case 'footer': return sectioningAncestor(el) ? null : 'contentinfo';
141
+ case 'form': return hasExplicitName(el) ? 'form' : null;
142
+ case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return 'heading';
143
+ case 'header': return sectioningAncestor(el) ? null : 'banner';
144
+ case 'hr': return 'separator';
145
+ case 'html': return 'document';
146
+ case 'img': return el.getAttribute('alt') === '' && !el.getAttribute('title') ? 'presentation' : 'img';
147
+ case 'input': return inputRole(el);
148
+ case 'ins': return 'insertion';
149
+ case 'li': return 'listitem';
150
+ case 'main': return 'main';
151
+ case 'mark': return 'mark';
152
+ case 'math': return 'math';
153
+ case 'menu': case 'ol': case 'ul': return 'list';
154
+ case 'meter': return 'meter';
155
+ case 'nav': return 'navigation';
156
+ case 'optgroup': return 'group';
157
+ case 'option': return 'option';
158
+ case 'output': return 'status';
159
+ case 'p': return 'paragraph';
160
+ case 'progress': return 'progressbar';
161
+ case 'search': return 'search';
162
+ case 'section': return hasExplicitName(el) ? 'region' : null;
163
+ case 'select': return el.multiple || el.size > 1 ? 'listbox' : 'combobox';
164
+ case 'strong': return 'strong';
165
+ case 'sub': return 'subscript';
166
+ case 'sup': return 'superscript';
167
+ case 'svg': return 'img';
168
+ case 'table': return 'table';
169
+ case 'tbody': case 'tfoot': case 'thead': return 'rowgroup';
170
+ case 'td': {
171
+ const tableRole = el.closest('table') && el.closest('table').getAttribute('role');
172
+ return tableRole === 'grid' || tableRole === 'treegrid' ? 'gridcell' : 'cell';
173
+ }
174
+ case 'textarea': return 'textbox';
175
+ case 'th': return el.getAttribute('scope') === 'row' ? 'rowheader' : 'columnheader';
176
+ case 'time': return 'time';
177
+ case 'tr': return 'row';
178
+ }
179
+ return null;
180
+ };
181
+
182
+ const roleOf = el => {
183
+ const explicit = (el.getAttribute('role') || '').trim().split(/\s+/)[0].toLowerCase();
184
+ if (explicit)
185
+ return explicit === 'none' ? 'presentation' : explicit;
186
+ return implicitRole(el);
187
+ };
188
+
189
+ const textOfIds = (el, ids) => ids.trim().split(/\s+/).map(id => {
190
+ const root = el.getRootNode();
191
+ const target = (root.getElementById && root.getElementById(id)) || document.getElementById(id);
192
+ return target ? elementText(target) : '';
193
+ }).join(' ');
194
+
195
+ // Text of a <label>, without the labelled control itself.
196
+ const labelText = (label, control) => {
197
+ let text = '';
198
+ const visit = node => {
199
+ for (let child = node.firstChild; child; child = child.nextSibling) {
200
+ if (child === control)
201
+ continue;
202
+ if (child.nodeType === 3)
203
+ text += child.data;
204
+ else if (isElement(child) && !skipText.has(child.localName))
205
+ visit(child);
206
+ }
207
+ };
208
+ visit(label);
209
+ return text;
210
+ };
211
+
212
+ // Name computed from content (buttons, links, headings, ...): descendant text, alt texts and
213
+ // aria-labels, skipping hidden parts.
214
+ const contentText = el => {
215
+ let text = '';
216
+ const visit = node => {
217
+ for (let child = node.firstChild; child; child = child.nextSibling) {
218
+ if (child.nodeType === 3) {
219
+ text += child.data;
220
+ continue;
221
+ }
222
+ if (!isElement(child) || skipText.has(child.localName) || child.getAttribute('aria-hidden') === 'true')
223
+ continue;
224
+ const style = getComputedStyle(child);
225
+ if (style.display === 'none' || style.visibility === 'hidden')
226
+ continue;
227
+ const pad = style.display.startsWith('inline') || style.display === 'contents' ? '' : ' ';
228
+ const label = normalize(child.getAttribute('aria-label'));
229
+ if (label)
230
+ text += ' ' + label + ' ';
231
+ else if (child.localName === 'img')
232
+ text += ' ' + (child.getAttribute('alt') || '') + ' ';
233
+ else if (child.localName === 'input' && ['button', 'submit', 'reset'].includes(child.type))
234
+ text += ' ' + child.value + ' ';
235
+ else {
236
+ text += pad;
237
+ visit(child.shadowRoot || child);
238
+ text += pad;
239
+ }
240
+ }
241
+ };
242
+ visit(el.shadowRoot || el);
243
+ return text;
244
+ };
245
+
246
+ const nameFromContent = new Set(['button', 'cell', 'checkbox', 'columnheader', 'gridcell', 'heading', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'row', 'rowheader', 'switch', 'tab', 'tooltip', 'treeitem']);
247
+
248
+ const accessibleName = (el, role) => {
249
+ const labelledBy = el.getAttribute('aria-labelledby');
250
+ if (labelledBy) {
251
+ const text = normalize(textOfIds(el, labelledBy));
252
+ if (text)
253
+ return text;
254
+ }
255
+ const ariaLabel = normalize(el.getAttribute('aria-label'));
256
+ if (ariaLabel)
257
+ return ariaLabel;
258
+ const tag = el.localName;
259
+ if (tag === 'input' && ['button', 'submit', 'reset'].includes(el.type))
260
+ return normalize(el.value) || { submit: 'Submit', reset: 'Reset' }[el.type] || '';
261
+ if (tag === 'input' && el.type === 'image')
262
+ return normalize(el.getAttribute('alt')) || normalize(el.value) || 'Submit';
263
+ if (el.labels && el.labels.length) {
264
+ const text = normalize([...el.labels].map(label => labelText(label, el)).join(' '));
265
+ if (text)
266
+ return text;
267
+ }
268
+ if (tag === 'img' || tag === 'area') {
269
+ const alt = normalize(el.getAttribute('alt'));
270
+ if (alt)
271
+ return alt;
272
+ }
273
+ const captionTag = { fieldset: 'legend', table: 'caption', figure: 'figcaption' }[tag];
274
+ if (captionTag) {
275
+ const caption = [...el.children].find(child => child.localName === captionTag);
276
+ const text = caption ? normalize(contentText(caption)) : '';
277
+ if (text)
278
+ return text;
279
+ }
280
+ if (nameFromContent.has(role)) {
281
+ const text = normalize(contentText(el));
282
+ if (text)
283
+ return text;
284
+ }
285
+ const title = normalize(el.getAttribute('title'));
286
+ if (title)
287
+ return title;
288
+ if (tag === 'input' || tag === 'textarea')
289
+ return normalize(el.getAttribute('placeholder'));
290
+ return '';
291
+ };
292
+
293
+ const roleStates = {
294
+ checked: el => el.localName === 'input' && ['checkbox', 'radio'].includes(el.type) ? el.checked : (el.getAttribute('aria-checked') === 'mixed' ? 'mixed' : el.getAttribute('aria-checked') === 'true'),
295
+ disabled: el => !!el.disabled || !!el.closest('[aria-disabled=true]'),
296
+ expanded: el => el.getAttribute('aria-expanded') === 'true',
297
+ // Native heading levels win over aria-level, as in Playwright.
298
+ level: el => /^h[1-6]$/.test(el.localName) ? Number(el.localName[1]) : (Number(el.getAttribute('aria-level')) || 0),
299
+ pressed: el => el.getAttribute('aria-pressed') === 'mixed' ? 'mixed' : el.getAttribute('aria-pressed') === 'true',
300
+ selected: el => el.localName === 'option' ? el.selected : el.getAttribute('aria-selected') === 'true',
301
+ };
302
+
303
+ const byRole = (root, step) => {
304
+ const attrs = step.attrs || {};
305
+ return descendants(root).filter(el => {
306
+ if (roleOf(el) !== step.role)
307
+ return false;
308
+ if (!attrs.includeHidden && isHiddenForAria(el))
309
+ return false;
310
+ for (const [name, value] of Object.entries(attrs)) {
311
+ if (roleStates[name] && roleStates[name](el) !== value)
312
+ return false;
313
+ }
314
+ return step.name === undefined || matches(accessibleName(el, step.role), step.name, step.exact);
315
+ });
316
+ };
317
+
318
+ // getByText picks the smallest elements whose text matches.
319
+ const byText = (root, expected, exact) => {
320
+ const found = descendants(root).filter(el => !skipText.has(el.localName) && !el.closest('head') && matches(elementText(el), expected, exact));
321
+ const set = new Set(found);
322
+ return found.filter(el => !childrenOf(el).some(child => set.has(child)));
323
+ };
324
+
325
+ const labelsOf = el => {
326
+ const texts = [];
327
+ if (el.labels) {
328
+ for (const label of el.labels)
329
+ texts.push(labelText(label, el));
330
+ }
331
+ const labelledBy = el.getAttribute('aria-labelledby');
332
+ if (labelledBy)
333
+ texts.push(textOfIds(el, labelledBy));
334
+ const ariaLabel = el.getAttribute('aria-label');
335
+ if (ariaLabel)
336
+ texts.push(ariaLabel);
337
+ return texts;
338
+ };
339
+
340
+ const byCss = (root, selector) => {
341
+ const roots = [root, ...descendants(root).filter(el => el.shadowRoot).map(el => el.shadowRoot)];
342
+ return roots.flatMap(scope => [...scope.querySelectorAll(selector)]);
343
+ };
344
+
345
+ const byXPath = (root, expression) => {
346
+ if (isElement(root) && expression.startsWith('/'))
347
+ expression = '.' + expression;
348
+ const result = [];
349
+ const snapshot = document.evaluate(expression, root, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
350
+ for (let i = 0; i < snapshot.snapshotLength; i++) {
351
+ if (isElement(snapshot.snapshotItem(i)))
352
+ result.push(snapshot.snapshotItem(i));
353
+ }
354
+ return result;
355
+ };
356
+
357
+ const byAttribute = (root, name, expected, exact) => descendants(root).filter(el => el.hasAttribute(name) && matches(el.getAttribute(name), expected, exact));
358
+
359
+ const search = (root, step) => {
360
+ switch (step.t) {
361
+ case 'css': return byCss(root, step.v);
362
+ case 'xpath': return byXPath(root, step.v);
363
+ case 'role': return byRole(root, step);
364
+ case 'text': return byText(root, step.v, step.exact);
365
+ case 'label': return descendants(root).filter(el => labelsOf(el).some(text => matches(text, step.v, step.exact)));
366
+ case 'placeholder': return byAttribute(root, 'placeholder', step.v, step.exact);
367
+ case 'alt': return byAttribute(root, 'alt', step.v, step.exact);
368
+ case 'title': return byAttribute(root, 'title', step.v, step.exact);
369
+ case 'testid': return descendants(root).filter(el => {
370
+ const value = el.getAttribute(step.attr);
371
+ if (value === null)
372
+ return false;
373
+ return typeof step.v === 'object' ? new RegExp(step.v.source, step.v.flags).test(value) : value === step.v;
374
+ });
375
+ }
376
+ return undefined;
377
+ };
378
+
379
+ const searchKinds = new Set(['css', 'xpath', 'role', 'text', 'label', 'placeholder', 'alt', 'title', 'testid']);
380
+ const inDocumentOrder = list => list.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) ? -1 : 1);
381
+
382
+ // Search steps look below the current elements; filters apply to the current elements themselves
383
+ // (at the start that is the root, so a leading filter matches nothing, as in Playwright).
384
+ const resolve = (list, roots) => {
385
+ let current = roots;
386
+ let scopes = roots;
387
+ for (const step of list) {
388
+ if (step.t === 'frame')
389
+ throw new Error('testgen: frames are only supported in the main locator chain');
390
+ if (searchKinds.has(step.t)) {
391
+ scopes = current;
392
+ current = unique(current.flatMap(root => search(root, step)));
393
+ continue;
394
+ }
395
+ switch (step.t) {
396
+ case 'hasText':
397
+ current = current.filter(el => isElement(el) && matches(elementText(el), step.v, step.exact) !== !!step.not);
398
+ break;
399
+ case 'visible':
400
+ current = current.filter(el => isElement(el) && isVisible(el) === step.v);
401
+ break;
402
+ case 'has':
403
+ current = current.filter(el => (resolve(step.steps, [el]).length > 0) !== !!step.not);
404
+ break;
405
+ case 'nth': {
406
+ const index = step.v < 0 ? current.length + step.v : step.v;
407
+ current = index >= 0 && index < current.length ? [current[index]] : [];
408
+ break;
409
+ }
410
+ case 'and': {
411
+ const other = new Set(resolve(step.steps, scopes));
412
+ current = current.filter(el => other.has(el));
413
+ break;
414
+ }
415
+ case 'or':
416
+ current = inDocumentOrder(unique([...current, ...resolve(step.steps, scopes)]));
417
+ break;
418
+ default:
419
+ throw new Error('testgen: unknown locator step ' + JSON.stringify(step));
420
+ }
421
+ }
422
+ return current.filter(isElement);
423
+ };
424
+
425
+ return resolve(steps, [document]);
426
+ }