elementus-ai 1.2.0 → 1.5.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  All notable changes are documented here. This project adheres to [Semantic Versioning](https://semver.org).
4
4
 
5
+ ## 1.5.0
6
+
7
+ ### Added
8
+
9
+ - `HealEvent.suggestion` — a ready-to-use, framework-native, properly-escaped suggested replacement
10
+ locator (`getByRole('link', { name: 'Save' })` on Playwright, `$('aria/Save')` on WDIO, `$('~Save')`
11
+ on Appium). Surface it verbatim. This moves suggestion-building into the library so consumers stop
12
+ hand-rolling it from `resolved` — hand-rolling re-introduced quote-escaping bugs (text with
13
+ apostrophes) and bad role mapping (form controls suggested as `getByText`). `resolved` stays for
14
+ consumers that want the raw facts.
15
+
16
+ ### Changed
17
+
18
+ - Additive on the existing `onHeal` event; no behavior change.
19
+
20
+ ## 1.4.0
21
+
22
+ ### Added
23
+
24
+ - `HealEvent.resolved` — `{ tag, text, role }` of the element a heal resolved to (when known), so a
25
+ consumer can compose a **suggested replacement locator** (e.g. `getByRole(role, { name: text })`) and
26
+ show "selector X failed → replace with Y" in reports. Absent on coordinate-only vision heals.
27
+
28
+ ### Changed
29
+
30
+ - Additive on the existing `onHeal` event; no behavior change.
31
+
32
+ ## 1.3.0
33
+
34
+ ### Added
35
+
36
+ - `onHeal(event)` config callback — fires once per heal with `{ description, selector, method, framework }`
37
+ so consumers can surface healed locators (report annotations, CI fail-on-heal). Framework-agnostic:
38
+ elementus emits the event; wiring it to a reporter is consumer-side (README "Detecting heals" has
39
+ Playwright + WebdriverIO/Appium recipes).
40
+ - `HealEvent` exported type (`index.d.ts`); `onHeal` added to `ElementusOptions`.
41
+ - Test `T47` covering onHeal; docs in README, `docs/API.md`, `docs/ARCHITECTURE.md`.
42
+
43
+ ### Changed
44
+
45
+ - No change to existing behavior. `onHeal` is opt-in; unset ⇒ byte-for-byte the same as 1.2.0
46
+ (zero-overhead path untouched, single export preserved).
47
+
5
48
  ## 1.2.0
6
49
 
7
50
  ### Added
package/README.md CHANGED
@@ -34,11 +34,24 @@ I just installed the npm package "elementus-ai" — a self-healing element resol
34
34
 
35
35
  3. INTEGRATE BASED ON MY FRAMEWORK
36
36
 
37
+ First create the elementus instance using the provider chosen in step 2:
38
+ const { createElementus } = require('elementus-ai') // ESM/TS: import { createElementus } from 'elementus-ai'
39
+ const el = createElementus({ provider: 'gemini', geminiApiKey: process.env.GEMINI_API_KEY })
40
+ // LM Studio instead: createElementus({ provider: 'lmstudio', lmStudioUrl: 'http://localhost:1234/v1/chat/completions', model: 'holo-3.1-9b' })
41
+ For exact, copy-pasteable code (TypeScript fixture, onHeal recipe), read the installed package's
42
+ node_modules/elementus-ai/README.md ("Quick Start", "TypeScript", "Detecting heals") and docs/API.md.
43
+
37
44
  For Playwright:
38
- - Create or update a fixtures file that wraps page with el.wrapPage(page)
45
+ - Create or update a fixtures file that overrides the page fixture with el.wrapPage(page)
39
46
  - Make sure all tests import from the fixtures file instead of @playwright/test
40
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
41
48
  - Set actionTimeout: 10000 in playwright config (Elementus respects framework timeouts)
49
+ - Optional heal visibility: only if the user wants healed locators surfaced, wire onHeal to the
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
+ testInfo.attach (so it also reaches Allure + custom reporters); optionally fail CI on heal via an
53
+ env-gated afterEach/fixture. WebdriverIO/Appium: collect in onHeal, fail in afterTest. See
54
+ "Detecting heals" in the README. Default off
42
55
 
43
56
  For WebDriverIO:
44
57
  - In wdio.conf.js before hook, wrap browser and override global $:
@@ -320,9 +333,85 @@ createElementus({
320
333
 
321
334
  // Custom stop words
322
335
  stopWords: null, // Set of words to ignore in descriptions
336
+
337
+ // Heal telemetry (opt-in) — called once per heal with { description, selector, method, framework, resolved, suggestion }.
338
+ // Best-effort & isolated: a throwing callback never breaks a test. See "Detecting heals" below.
339
+ onHeal: null, // e.g. (e) => console.log('healed', e.selector, '→', e.description)
323
340
  })
324
341
  ```
325
342
 
343
+ ## Detecting heals (reporting & CI)
344
+
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, suggestion }`. The library only
347
+ *emits* the event — turning it into a report annotation or a CI failure is framework-specific (a few lines
348
+ on your side). It fires when the AI resolves a replacement element, so it signals "the selector drifted and
349
+ AI stepped in", not "the action ultimately passed".
350
+
351
+ **Playwright** — annotate the test (shows in the Playwright HTML report), and optionally fail
352
+ CI on heal:
353
+
354
+ ```javascript
355
+ // fixtures.js
356
+ const { test: base } = require('@playwright/test')
357
+ const { createElementus } = require('elementus-ai')
358
+
359
+ const el = createElementus({
360
+ provider: 'gemini',
361
+ geminiApiKey: process.env.GEMINI_API_KEY,
362
+ onHeal: (e) => {
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)'}`
365
+ try {
366
+ const info = base.info()
367
+ info.annotations.push({ type: 'healed', description: line }) // Playwright HTML report
368
+ void info.attach('healed', { body: line, contentType: 'text/plain' }) // Allure + custom reporters
369
+ } catch {} // ignored outside a running test
370
+ },
371
+ })
372
+
373
+ const test = base.extend({
374
+ page: async ({ page }, use) => { await use(el.wrapPage(page)) },
375
+ // Optional: fail CI when a selector only passed via self-healing (drift you should fix).
376
+ failOnHeal: [async ({}, use, testInfo) => {
377
+ await use()
378
+ const heals = testInfo.annotations.filter(a => a.type === 'healed')
379
+ if (process.env.ELEMENTUS_FAIL_ON_HEAL && heals.length) {
380
+ throw new Error('Passed only via self-healing — fix the selector(s):\n ' + heals.map(h => h.description).join('\n '))
381
+ }
382
+ }, { auto: true }],
383
+ })
384
+ module.exports = { test, expect: require('@playwright/test').expect }
385
+ ```
386
+
387
+ (TypeScript: import the `HealEvent` type from `elementus-ai`; the annotation/fixture code is identical.)
388
+
389
+ **WebdriverIO / Appium** — collect in `onHeal`, fail (or report) in `afterTest`:
390
+
391
+ ```javascript
392
+ // wdio.conf.js
393
+ const { createElementus } = require('elementus-ai')
394
+ let heals = []
395
+ const el = createElementus({ provider: 'gemini', geminiApiKey: '...', onHeal: (e) => heals.push(e) })
396
+
397
+ exports.config = {
398
+ beforeTest() { heals = [] },
399
+ afterTest() {
400
+ if (process.env.ELEMENTUS_FAIL_ON_HEAL && heals.length) {
401
+ throw new Error('healed: ' + heals.map(h => h.description).join(', '))
402
+ }
403
+ },
404
+ }
405
+ ```
406
+
407
+ **Where each report shows it:** the annotation appears in the **Playwright HTML report** (test → _Annotations_).
408
+ In **Allure**, the heal shows in the test's **step tree** (the broken locator → the healed `[data-elementus=…]`
409
+ element) and, with fail-on-heal on, the **failure message** — note `allure-playwright` does not convert
410
+ runtime annotations into labels/steps, so for a dedicated Allure marker also
411
+ `testInfo.attach('healed', { body: \`${e.selector} → ${e.description}\`, contentType: 'text/plain' })`
412
+ (attachments surface in Allure and most custom reporters). A custom HTML reporter surfaces the heal via the
413
+ failure message and captured stdout.
414
+
326
415
  ## Security Notes
327
416
 
328
417
  - **Debug screenshots** capture the full page — including any sensitive data visible on it. Keep `debugDir` out of version control.
package/elementus.js CHANGED
@@ -377,6 +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 }, 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.
380
381
  * @returns {{ wrap, wrapPage, wrapBrowser, locate, find, click }}
381
382
  */
382
383
  function createElementus(userConfig = {}) {
@@ -1988,6 +1989,64 @@ function createElementus(userConfig = {}) {
1988
1989
  return { record: null, somCandidates: out.somCandidates || null }
1989
1990
  }
1990
1991
 
1992
+ // Facts about the element the last heal resolved to (tag/text/role) — set during resolution,
1993
+ // read once by _notifyHeal. Framework-agnostic; consumers format a suggested replacement locator.
1994
+ let _lastResolved = null
1995
+ function _resolvedFacts(record) {
1996
+ if (!record) return null
1997
+ return { tag: record.tag || '', text: (record.text || '').trim(), role: record.role || null }
1998
+ }
1999
+
2000
+ // Implicit ARIA roles for the common interactive tags (used when the element has no explicit
2001
+ // role attribute). `input` is deliberately omitted — its role depends on its type.
2002
+ const ROLE_BY_TAG = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' }
2003
+ // Escape a value for a single-quoted string literal in the suggested locator.
2004
+ function _escSingle(s) {
2005
+ return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")
2006
+ }
2007
+ // Collapse whitespace and cap length so the suggested locator stays one readable line.
2008
+ function _suggestName(s) {
2009
+ return String(s || '').replace(/\s+/g, ' ').trim().slice(0, 80)
2010
+ }
2011
+ // Build a ready-to-use, framework-native, properly-escaped suggested replacement locator from
2012
+ // the resolved element. Consumers should surface event.suggestion verbatim — do NOT hand-roll it
2013
+ // (raw text interpolation re-introduces quote-escaping and role-mapping bugs).
2014
+ function _suggestLocator(framework, resolved) {
2015
+ if (!resolved) return undefined
2016
+ const tag = resolved.tag || ''
2017
+ const name = _suggestName(resolved.text)
2018
+ const role = resolved.role || ROLE_BY_TAG[tag] || null
2019
+ if (framework === 'playwright') {
2020
+ if (role && name) return `getByRole('${_escSingle(role)}', { name: '${_escSingle(name)}' })`
2021
+ if (name) return `getByText('${_escSingle(name)}')`
2022
+ return `locator('${_escSingle(tag || '*')}')`
2023
+ }
2024
+ if (framework === 'appium') {
2025
+ return name ? `$('~${_escSingle(name)}')` : `$('${_escSingle(tag || '*')}')`
2026
+ }
2027
+ return name ? `$('aria/${_escSingle(name)}')` : `$('${_escSingle(tag || '*')}')`
2028
+ }
2029
+
2030
+ // Fire the user's onHeal(event) after a real heal — the original selector failed and the AI
2031
+ // pipeline resolved a replacement. Best-effort and fully isolated: a throwing or rejecting
2032
+ // callback must never break a test. No-op with zero overhead when onHeal is not configured.
2033
+ function _notifyHeal(ctx, description, selectorKey, method) {
2034
+ const resolved = _lastResolved
2035
+ _lastResolved = null
2036
+ const cb = config.onHeal
2037
+ if (typeof cb !== 'function') return
2038
+ const framework = _isNative(ctx) ? 'appium' : _isPlaywright(ctx) ? 'playwright' : 'wdio'
2039
+ try {
2040
+ const event = { description, selector: selectorKey, method, framework }
2041
+ if (resolved) {
2042
+ event.resolved = resolved
2043
+ event.suggestion = _suggestLocator(framework, resolved)
2044
+ }
2045
+ const r = cb(event)
2046
+ if (r && typeof r.then === 'function') r.then(undefined, () => {})
2047
+ } catch {}
2048
+ }
2049
+
1991
2050
  async function _findByDescription(ctx, description, selectorKey = '') {
1992
2051
  const { record, somCandidates } = await _resolveElement(ctx, description, selectorKey)
1993
2052
  if (record) {
@@ -1995,6 +2054,7 @@ function createElementus(userConfig = {}) {
1995
2054
  const mark = {}
1996
2055
  const locator = record._locator || await markByElement(ctx, record, mark)
1997
2056
  await _cacheStore(ctx, description, selectorKey, record, record._uid || mark.uid || null)
2057
+ _lastResolved = _resolvedFacts(record)
1998
2058
  return locator
1999
2059
  } catch (err) {
2000
2060
  console.log(`[Resolve] Mark failed (${err.message}) — trying vision`)
@@ -2006,11 +2066,13 @@ function createElementus(userConfig = {}) {
2006
2066
  const mark = {}
2007
2067
  const locator = await markByElement(ctx, result.element, mark)
2008
2068
  await _cacheStore(ctx, description, selectorKey, result.element, mark.uid || null)
2069
+ _lastResolved = _resolvedFacts(result.element)
2009
2070
  return locator
2010
2071
  }
2011
2072
  const mark = {}
2012
2073
  const locator = await markAtCoordinates(ctx, result.coords.docX, result.coords.docY, mark)
2013
2074
  await _cacheStore(ctx, description, selectorKey, result.coords, mark.uid || null)
2075
+ _lastResolved = null
2014
2076
  return locator
2015
2077
  } catch (err) {
2016
2078
  throw new Error(`All fallback paths exhausted for "${description}": ${err.message}`)
@@ -2038,7 +2100,10 @@ function createElementus(userConfig = {}) {
2038
2100
  } catch {
2039
2101
  console.log(`\u2717 Locator failed \u2014 searching for: "${description}"`)
2040
2102
  }
2041
- return _findByDescription(ctx, description, _selectorKey(locator))
2103
+ const selectorKey = _selectorKey(locator)
2104
+ const resolved = await _findByDescription(ctx, description, selectorKey)
2105
+ _notifyHeal(ctx, description, selectorKey, 'locate')
2106
+ return resolved
2042
2107
  }
2043
2108
 
2044
2109
  /**
@@ -2088,6 +2153,8 @@ function createElementus(userConfig = {}) {
2088
2153
  // Store before clicking \u2014 the click may navigate away from the page
2089
2154
  await _cacheStore(ctx, description, selectorKey, record)
2090
2155
  await scrollAndClick(ctx, record)
2156
+ _lastResolved = _resolvedFacts(record)
2157
+ _notifyHeal(ctx, description, selectorKey, 'click')
2091
2158
  return
2092
2159
  }
2093
2160
  try {
@@ -2095,9 +2162,13 @@ function createElementus(userConfig = {}) {
2095
2162
  if (result.element) {
2096
2163
  await _cacheStore(ctx, description, selectorKey, result.element)
2097
2164
  await scrollAndClick(ctx, result.element)
2165
+ _lastResolved = _resolvedFacts(result.element)
2166
+ _notifyHeal(ctx, description, selectorKey, 'click')
2098
2167
  return
2099
2168
  }
2100
2169
  await clickAtCoords(ctx, result.coords)
2170
+ _lastResolved = null
2171
+ _notifyHeal(ctx, description, selectorKey, 'click')
2101
2172
  } catch (err) {
2102
2173
  throw new Error(`All fallback paths exhausted for "${description}": ${err.message}`)
2103
2174
  }
@@ -2160,6 +2231,7 @@ function createElementus(userConfig = {}) {
2160
2231
  if (!_resolved) {
2161
2232
  console.log(`[AI] ${prop}() \u2014 resolving via AI first for "${description}"`)
2162
2233
  _resolved = await _findByDescription(driverContext, description, wrapSelectorKey)
2234
+ _notifyHeal(driverContext, description, wrapSelectorKey, String(prop))
2163
2235
  }
2164
2236
  return _resolved[prop](...args)
2165
2237
  }
@@ -2168,7 +2240,10 @@ function createElementus(userConfig = {}) {
2168
2240
  return await original.apply(target, args)
2169
2241
  } catch (firstError) {
2170
2242
  console.log(`[AI] ${String(prop)}() failed \u2014 AI fallback for "${description}"`)
2171
- if (!_resolved) _resolved = await _findByDescription(driverContext, description, wrapSelectorKey)
2243
+ if (!_resolved) {
2244
+ _resolved = await _findByDescription(driverContext, description, wrapSelectorKey)
2245
+ _notifyHeal(driverContext, description, wrapSelectorKey, String(prop))
2246
+ }
2172
2247
 
2173
2248
  const resolvedMethod = _resolved[prop]
2174
2249
  if (typeof resolvedMethod !== 'function') {
package/index.d.ts CHANGED
@@ -36,6 +36,33 @@ export interface ElementusOptions {
36
36
  cacheFile?: string | null
37
37
  /** Opt-in embedding model for semantic paraphrase matching. @default null */
38
38
  embeddingModel?: string | null
39
+ /**
40
+ * Called after a heal resolves a replacement element (the original selector failed and the AI
41
+ * pipeline found a match). Best-effort and isolated — a throwing/rejecting callback never breaks
42
+ * a test. Fires at resolution time, so it does NOT guarantee the subsequent action succeeded.
43
+ */
44
+ onHeal?: (event: HealEvent) => void | Promise<void>
45
+ }
46
+
47
+ /** Event passed to `onHeal` when a broken selector is healed. */
48
+ export interface HealEvent {
49
+ /** The natural-language `{ ai }` description that resolved the element. */
50
+ description: string
51
+ /** The original selector that failed (selector key). */
52
+ selector: string
53
+ /** The action that triggered the heal: 'click', 'fill', 'isVisible', 'locate', etc. */
54
+ method: string
55
+ /** Which driver the heal happened on. */
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 }
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.
64
+ */
65
+ suggestion?: string
39
66
  }
40
67
 
41
68
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elementus-ai",
3
- "version": "1.2.0",
3
+ "version": "1.5.0",
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",