elementus-ai 1.4.0 → 1.5.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  All notable changes are documented here. This project adheres to [Semantic Versioning](https://semver.org).
4
4
 
5
+ ## 1.5.1
6
+
7
+ ### Fixed
8
+
9
+ - Suggestion/`resolved` quality for **form controls**. A healed `<input>`/`<select>`/`<textarea>` now
10
+ reports its implicit ARIA **role** (`<input type="submit">` → `button`, text inputs → `textbox`,
11
+ checkbox/radio/range/number → their roles) and a proper **accessible name** (a button-like input's
12
+ `value`, e.g. `Login`, instead of its `name` attribute). So the heal suggests
13
+ `getByRole('button', { name: 'Login' })` instead of the previous `getByText('login-button')` — which
14
+ would never match a form control. `_suggestLocator` also never falls back to `getByText` for a form
15
+ control (it uses a tag locator when no role/name is available).
16
+
17
+ ### Changed
18
+
19
+ - `textOf` (element label derivation, used for both matching and suggestions) now prefers a button-like
20
+ input's `value` over its `name`/`title`/`alt` attributes; a text input's current value stays a last
21
+ resort. No public API change — the `resolved`/`suggestion` shapes are unchanged, just more accurate.
22
+
23
+ ## 1.5.0
24
+
25
+ ### Added
26
+
27
+ - `HealEvent.suggestion` — a ready-to-use, framework-native, properly-escaped suggested replacement
28
+ locator (`getByRole('link', { name: 'Save' })` on Playwright, `$('aria/Save')` on WDIO, `$('~Save')`
29
+ on Appium). Surface it verbatim. This moves suggestion-building into the library so consumers stop
30
+ hand-rolling it from `resolved` — hand-rolling re-introduced quote-escaping bugs (text with
31
+ apostrophes) and bad role mapping (form controls suggested as `getByText`). `resolved` stays for
32
+ consumers that want the raw facts.
33
+
34
+ ### Changed
35
+
36
+ - Additive on the existing `onHeal` event; no behavior change.
37
+
5
38
  ## 1.4.0
6
39
 
7
40
  ### Added
package/README.md CHANGED
@@ -47,8 +47,8 @@ I just installed the npm package "elementus-ai" — a self-healing element resol
47
47
  - TypeScript projects: use import/export and type the override as base.extend<{ page: ElementusPage }>({ ... }) (import ElementusPage from elementus-ai) so { ai } is autocompleted and documented. Types are bundled — do NOT add @types or a "declare module" shim
48
48
  - Set actionTimeout: 10000 in playwright config (Elementus respects framework timeouts)
49
49
  - Optional heal visibility: only if the user wants healed locators surfaced, wire onHeal to the
50
- framework's reporter. Build a suggested replacement from e.resolved ({ tag, text, role }), e.g.
51
- getByRole(role, { name: text }). Playwright: push a testInfo "healed" annotation (HTML report) AND
50
+ framework's reporter. Use e.suggestion a ready-to-use, escaped, framework-native replacement
51
+ locator verbatim. Playwright: push a testInfo "healed" annotation (HTML report) AND
52
52
  testInfo.attach (so it also reaches Allure + custom reporters); optionally fail CI on heal via an
53
53
  env-gated afterEach/fixture. WebdriverIO/Appium: collect in onHeal, fail in afterTest. See
54
54
  "Detecting heals" in the README. Default off
@@ -334,7 +334,7 @@ createElementus({
334
334
  // Custom stop words
335
335
  stopWords: null, // Set of words to ignore in descriptions
336
336
 
337
- // Heal telemetry (opt-in) — called once per heal with { description, selector, method, framework, resolved }.
337
+ // Heal telemetry (opt-in) — called once per heal with { description, selector, method, framework, resolved, suggestion }.
338
338
  // Best-effort & isolated: a throwing callback never breaks a test. See "Detecting heals" below.
339
339
  onHeal: null, // e.g. (e) => console.log('healed', e.selector, '→', e.description)
340
340
  })
@@ -343,7 +343,7 @@ createElementus({
343
343
  ## Detecting heals (reporting & CI)
344
344
 
345
345
  A heal is silent by default — the test still passes. To surface it, pass an `onHeal(event)` callback;
346
- elementus calls it **once per heal** with `{ description, selector, method, framework, resolved }`. The library only
346
+ elementus calls it **once per heal** with `{ description, selector, method, framework, resolved, suggestion }`. The library only
347
347
  *emits* the event — turning it into a report annotation or a CI failure is framework-specific (a few lines
348
348
  on your side). It fires when the AI resolves a replacement element, so it signals "the selector drifted and
349
349
  AI stepped in", not "the action ultimately passed".
@@ -360,14 +360,8 @@ const el = createElementus({
360
360
  provider: 'gemini',
361
361
  geminiApiKey: process.env.GEMINI_API_KEY,
362
362
  onHeal: (e) => {
363
- // Build a suggested replacement from the element elementus resolved to (e.resolved)
364
- const r = e.resolved
365
- const role = r && (r.role ?? { a: 'link', button: 'button' }[r.tag])
366
- const suggestion = !r ? '(located visually — no DOM suggestion)'
367
- : role && r.text ? `getByRole('${role}', { name: '${r.text}' })`
368
- : r.text ? `getByText('${r.text}')`
369
- : `locator('${r.tag}')`
370
- const line = `${e.selector} → ${suggestion}` // failed locator → suggested replacement
363
+ // e.suggestion is a ready-to-use, escaped, framework-native replacement locator use it verbatim.
364
+ const line = `${e.selector} → ${e.suggestion ?? '(located visually — no DOM suggestion)'}`
371
365
  try {
372
366
  const info = base.info()
373
367
  info.annotations.push({ type: 'healed', description: line }) // Playwright HTML report
package/elementus.js CHANGED
@@ -377,7 +377,7 @@ const REGION_LABELS = [
377
377
  * @param {number} [userConfig.visionMaxWidth=1280] - max screenshot width (px) sent to vision LLM
378
378
  * @param {string|null} [userConfig.cacheFile=null] - opt-in fingerprint cache file (e.g. './elementus-cache.json')
379
379
  * @param {string|null} [userConfig.embeddingModel=null] - opt-in embedding model for semantic matching
380
- * @param {(event: { description: string, selector: string, method: string, framework: string, resolved?: { tag: string, text: string, role: string|null } }) => void} [userConfig.onHeal] - called after a heal resolves a replacement element (original selector failed, AI found it). `resolved` is the element it healed to (when known), for suggesting a replacement. Best-effort; throwing never breaks a test.
380
+ * @param {(event: { description: string, selector: string, method: string, framework: string, resolved?: { tag: string, text: string, role: string|null }, suggestion?: string }) => void} [userConfig.onHeal] - called after a heal resolves a replacement element (original selector failed, AI found it). `resolved` is the element it healed to; `suggestion` is a ready-to-use, framework-native replacement locator (use it verbatim). Best-effort; throwing never breaks a test.
381
381
  * @returns {{ wrap, wrapPage, wrapBrowser, locate, find, click }}
382
382
  */
383
383
  function createElementus(userConfig = {}) {
@@ -800,10 +800,19 @@ function createElementus(userConfig = {}) {
800
800
  function textOf(el) {
801
801
  const t = el.textContent.trim().replace(/\s+/g, ' ')
802
802
  if (t) return t
803
- for (const attr of ['aria-label', 'placeholder', 'name', 'title', 'alt']) {
803
+ for (const attr of ['aria-label', 'placeholder']) {
804
804
  const v = el.getAttribute(attr)
805
805
  if (v && v.trim()) return v.trim().replace(/\s+/g, ' ')
806
806
  }
807
+ // Button-like inputs: `value` is the visible label, e.g. <input type="submit" value="Login">
808
+ if (el.tagName === 'INPUT' && /^(submit|button|reset)$/i.test(el.type) && el.value) {
809
+ return String(el.value).trim().replace(/\s+/g, ' ')
810
+ }
811
+ for (const attr of ['name', 'title', 'alt']) {
812
+ const v = el.getAttribute(attr)
813
+ if (v && v.trim()) return v.trim().replace(/\s+/g, ' ')
814
+ }
815
+ // Text inputs/textarea: current value last (it's user-entered content, not a label)
807
816
  if ((el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') && el.type !== 'password' && el.value) {
808
817
  return String(el.value).trim().replace(/\s+/g, ' ')
809
818
  }
@@ -824,6 +833,7 @@ function createElementus(userConfig = {}) {
824
833
  text,
825
834
  tag: el.tagName.toLowerCase(),
826
835
  role: el.getAttribute('role') || null,
836
+ type: el.type || '',
827
837
  href: el.getAttribute('href') || null,
828
838
  docX: Math.round(rect.left + window.scrollX + rect.width / 2),
829
839
  docY: Math.round(rect.top + window.scrollY + rect.height / 2),
@@ -1013,10 +1023,19 @@ function createElementus(userConfig = {}) {
1013
1023
  function textOf(el) {
1014
1024
  const t = el.textContent.trim().replace(/\s+/g, ' ')
1015
1025
  if (t) return t
1016
- for (const attr of ['aria-label', 'placeholder', 'name', 'title', 'alt']) {
1026
+ for (const attr of ['aria-label', 'placeholder']) {
1027
+ const v = el.getAttribute(attr)
1028
+ if (v && v.trim()) return v.trim().replace(/\s+/g, ' ')
1029
+ }
1030
+ // Button-like inputs: `value` is the visible label, e.g. <input type="submit" value="Login">
1031
+ if (el.tagName === 'INPUT' && /^(submit|button|reset)$/i.test(el.type) && el.value) {
1032
+ return String(el.value).trim().replace(/\s+/g, ' ')
1033
+ }
1034
+ for (const attr of ['name', 'title', 'alt']) {
1017
1035
  const v = el.getAttribute(attr)
1018
1036
  if (v && v.trim()) return v.trim().replace(/\s+/g, ' ')
1019
1037
  }
1038
+ // Text inputs/textarea: current value last (it's user-entered content, not a label)
1020
1039
  if ((el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') && el.type !== 'password' && el.value) {
1021
1040
  return String(el.value).trim().replace(/\s+/g, ' ')
1022
1041
  }
@@ -1035,6 +1054,7 @@ function createElementus(userConfig = {}) {
1035
1054
  classes: typeof el.className === 'string' ? el.className.trim() : '',
1036
1055
  name: el.getAttribute('name') || '',
1037
1056
  role: el.getAttribute('role') || '',
1057
+ type: el.type || '',
1038
1058
  href: el.getAttribute('href') || '',
1039
1059
  text: textOf(el),
1040
1060
  neighborText: el.parentElement
@@ -1341,10 +1361,19 @@ function createElementus(userConfig = {}) {
1341
1361
  function textOf(el) {
1342
1362
  const t = el.textContent.trim().replace(/\s+/g, ' ')
1343
1363
  if (t) return t
1344
- for (const attr of ['aria-label', 'placeholder', 'name', 'title', 'alt']) {
1364
+ for (const attr of ['aria-label', 'placeholder']) {
1365
+ const v = el.getAttribute(attr)
1366
+ if (v && v.trim()) return v.trim().replace(/\s+/g, ' ')
1367
+ }
1368
+ // Button-like inputs: `value` is the visible label, e.g. <input type="submit" value="Login">
1369
+ if (el.tagName === 'INPUT' && /^(submit|button|reset)$/i.test(el.type) && el.value) {
1370
+ return String(el.value).trim().replace(/\s+/g, ' ')
1371
+ }
1372
+ for (const attr of ['name', 'title', 'alt']) {
1345
1373
  const v = el.getAttribute(attr)
1346
1374
  if (v && v.trim()) return v.trim().replace(/\s+/g, ' ')
1347
1375
  }
1376
+ // Text inputs/textarea: current value last (it's user-entered content, not a label)
1348
1377
  if ((el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') && el.type !== 'password' && el.value) {
1349
1378
  return String(el.value).trim().replace(/\s+/g, ' ')
1350
1379
  }
@@ -1356,6 +1385,7 @@ function createElementus(userConfig = {}) {
1356
1385
  return {
1357
1386
  uid: existing || uid,
1358
1387
  tag: el.tagName.toLowerCase(),
1388
+ type: el.type || '',
1359
1389
  text: textOf(el),
1360
1390
  href: el.getAttribute('href') || null,
1361
1391
  docX: Math.round(rect.left + window.scrollX + rect.width / 2),
@@ -1364,7 +1394,7 @@ function createElementus(userConfig = {}) {
1364
1394
  }, uid, { timeout: 5000 })
1365
1395
  console.log(`[Resolve] Aria grounded <${record.tag}> "${record.text}" via ref=${ref}`)
1366
1396
  const locator = await _makeLocator(ctx, `[data-elementus="${record.uid}"]`)
1367
- return { tag: record.tag, text: record.text, href: record.href, docX: record.docX, docY: record.docY, _locator: locator, _uid: record.uid }
1397
+ return { tag: record.tag, type: record.type, text: record.text, href: record.href, docX: record.docX, docY: record.docY, _locator: locator, _uid: record.uid }
1368
1398
  } catch (err) {
1369
1399
  console.log(`[Resolve] Aria ref resolution failed (${err.message}) — falling through`)
1370
1400
  return null
@@ -1693,10 +1723,19 @@ function createElementus(userConfig = {}) {
1693
1723
  function textOf(el) {
1694
1724
  const t = el.textContent.trim().replace(/\s+/g, ' ')
1695
1725
  if (t) return t
1696
- for (const attr of ['aria-label', 'placeholder', 'name', 'title', 'alt']) {
1726
+ for (const attr of ['aria-label', 'placeholder']) {
1727
+ const v = el.getAttribute(attr)
1728
+ if (v && v.trim()) return v.trim().replace(/\s+/g, ' ')
1729
+ }
1730
+ // Button-like inputs: `value` is the visible label, e.g. <input type="submit" value="Login">
1731
+ if (el.tagName === 'INPUT' && /^(submit|button|reset)$/i.test(el.type) && el.value) {
1732
+ return String(el.value).trim().replace(/\s+/g, ' ')
1733
+ }
1734
+ for (const attr of ['name', 'title', 'alt']) {
1697
1735
  const v = el.getAttribute(attr)
1698
1736
  if (v && v.trim()) return v.trim().replace(/\s+/g, ' ')
1699
1737
  }
1738
+ // Text inputs/textarea: current value last (it's user-entered content, not a label)
1700
1739
  if ((el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') && el.type !== 'password' && el.value) {
1701
1740
  return String(el.value).trim().replace(/\s+/g, ' ')
1702
1741
  }
@@ -1992,9 +2031,63 @@ function createElementus(userConfig = {}) {
1992
2031
  // Facts about the element the last heal resolved to (tag/text/role) — set during resolution,
1993
2032
  // read once by _notifyHeal. Framework-agnostic; consumers format a suggested replacement locator.
1994
2033
  let _lastResolved = null
2034
+ // Implicit ARIA role for native interactive elements with no explicit `role` attribute, derived
2035
+ // from the tag (and an input's `type`). Lets the heal suggest a correct getByRole(...) — e.g. a
2036
+ // submit input becomes role 'button', not a dead getByText(...).
2037
+ function _implicitRole(tag, type, href) {
2038
+ if (tag === 'a') return href ? 'link' : null
2039
+ if (tag === 'button') return 'button'
2040
+ if (tag === 'select') return 'combobox'
2041
+ if (tag === 'textarea') return 'textbox'
2042
+ if (tag === 'input') {
2043
+ const t = (type || '').toLowerCase()
2044
+ if (t === 'submit' || t === 'button' || t === 'reset' || t === 'image') return 'button'
2045
+ if (t === 'checkbox') return 'checkbox'
2046
+ if (t === 'radio') return 'radio'
2047
+ if (t === 'range') return 'slider'
2048
+ if (t === 'number') return 'spinbutton'
2049
+ if (t === 'hidden') return null
2050
+ return 'textbox' // text/email/search/tel/url/password/date/…
2051
+ }
2052
+ return null
2053
+ }
1995
2054
  function _resolvedFacts(record) {
1996
2055
  if (!record) return null
1997
- return { tag: record.tag || '', text: (record.text || '').trim(), role: record.role || null }
2056
+ const tag = (record.tag || '').toLowerCase()
2057
+ return { tag, text: (record.text || '').trim(), role: record.role || _implicitRole(tag, record.type, record.href) }
2058
+ }
2059
+
2060
+ // Implicit ARIA roles for the common interactive tags (used when the element has no explicit
2061
+ // role attribute). `input` is deliberately omitted — its role depends on its type.
2062
+ const ROLE_BY_TAG = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' }
2063
+ // Escape a value for a single-quoted string literal in the suggested locator.
2064
+ function _escSingle(s) {
2065
+ return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")
2066
+ }
2067
+ // Collapse whitespace and cap length so the suggested locator stays one readable line.
2068
+ function _suggestName(s) {
2069
+ return String(s || '').replace(/\s+/g, ' ').trim().slice(0, 80)
2070
+ }
2071
+ // Build a ready-to-use, framework-native, properly-escaped suggested replacement locator from
2072
+ // the resolved element. Consumers should surface event.suggestion verbatim — do NOT hand-roll it
2073
+ // (raw text interpolation re-introduces quote-escaping and role-mapping bugs).
2074
+ function _suggestLocator(framework, resolved) {
2075
+ if (!resolved) return undefined
2076
+ const tag = resolved.tag || ''
2077
+ const name = _suggestName(resolved.text)
2078
+ const role = resolved.role || ROLE_BY_TAG[tag] || null
2079
+ if (framework === 'playwright') {
2080
+ if (role && name) return `getByRole('${_escSingle(role)}', { name: '${_escSingle(name)}' })`
2081
+ // getByText matches visible text content — never right for a form control (its label lives in
2082
+ // value/label/placeholder, not text), so fall back to a tag locator instead of a dead getByText.
2083
+ const isFormControl = tag === 'input' || tag === 'select' || tag === 'textarea'
2084
+ if (name && !isFormControl) return `getByText('${_escSingle(name)}')`
2085
+ return `locator('${_escSingle(tag || '*')}')`
2086
+ }
2087
+ if (framework === 'appium') {
2088
+ return name ? `$('~${_escSingle(name)}')` : `$('${_escSingle(tag || '*')}')`
2089
+ }
2090
+ return name ? `$('aria/${_escSingle(name)}')` : `$('${_escSingle(tag || '*')}')`
1998
2091
  }
1999
2092
 
2000
2093
  // Fire the user's onHeal(event) after a real heal — the original selector failed and the AI
@@ -2008,7 +2101,10 @@ function createElementus(userConfig = {}) {
2008
2101
  const framework = _isNative(ctx) ? 'appium' : _isPlaywright(ctx) ? 'playwright' : 'wdio'
2009
2102
  try {
2010
2103
  const event = { description, selector: selectorKey, method, framework }
2011
- if (resolved) event.resolved = resolved
2104
+ if (resolved) {
2105
+ event.resolved = resolved
2106
+ event.suggestion = _suggestLocator(framework, resolved)
2107
+ }
2012
2108
  const r = cb(event)
2013
2109
  if (r && typeof r.then === 'function') r.then(undefined, () => {})
2014
2110
  } catch {}
package/index.d.ts CHANGED
@@ -54,11 +54,15 @@ export interface HealEvent {
54
54
  method: string
55
55
  /** Which driver the heal happened on. */
56
56
  framework: 'playwright' | 'wdio' | 'appium'
57
+ /** The element elementus resolved to (when known — absent on coordinate-only vision heals). */
58
+ resolved?: { tag: string; text: string; role: string | null }
57
59
  /**
58
- * The element elementus resolved to (when known absent on coordinate-only vision heals).
59
- * Use it to compose a suggested replacement locator, e.g. getByRole(role, { name: text }).
60
+ * Ready-to-use, framework-native, properly-escaped suggested replacement locator for the failed
61
+ * selector (e.g. `getByRole('link', { name: 'Save' })` on Playwright, `$('aria/Save')` on WDIO).
62
+ * Surface it verbatim — don't hand-build one from `resolved` (that re-introduces quote-escaping
63
+ * and role-mapping bugs). Absent on coordinate-only vision heals.
60
64
  */
61
- resolved?: { tag: string; text: string; role: string | null }
65
+ suggestion?: string
62
66
  }
63
67
 
64
68
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elementus-ai",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Self-healing element resolution for Playwright, WDIO & Appium. AI-powered fallback when selectors break.",
5
5
  "main": "elementus.js",
6
6
  "types": "index.d.ts",