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 +33 -0
- package/README.md +6 -12
- package/elementus.js +104 -8
- package/index.d.ts +7 -3
- package/package.json +1 -1
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.
|
|
51
|
-
|
|
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
|
-
//
|
|
364
|
-
const
|
|
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
|
|
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'
|
|
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'
|
|
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'
|
|
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'
|
|
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
|
-
|
|
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)
|
|
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
|
-
*
|
|
59
|
-
*
|
|
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
|
-
|
|
65
|
+
suggestion?: string
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
/**
|
package/package.json
CHANGED