elementus-ai 1.2.0 → 1.4.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,34 @@
2
2
 
3
3
  All notable changes are documented here. This project adheres to [Semantic Versioning](https://semver.org).
4
4
 
5
+ ## 1.4.0
6
+
7
+ ### Added
8
+
9
+ - `HealEvent.resolved` — `{ tag, text, role }` of the element a heal resolved to (when known), so a
10
+ consumer can compose a **suggested replacement locator** (e.g. `getByRole(role, { name: text })`) and
11
+ show "selector X failed → replace with Y" in reports. Absent on coordinate-only vision heals.
12
+
13
+ ### Changed
14
+
15
+ - Additive on the existing `onHeal` event; no behavior change.
16
+
17
+ ## 1.3.0
18
+
19
+ ### Added
20
+
21
+ - `onHeal(event)` config callback — fires once per heal with `{ description, selector, method, framework }`
22
+ so consumers can surface healed locators (report annotations, CI fail-on-heal). Framework-agnostic:
23
+ elementus emits the event; wiring it to a reporter is consumer-side (README "Detecting heals" has
24
+ Playwright + WebdriverIO/Appium recipes).
25
+ - `HealEvent` exported type (`index.d.ts`); `onHeal` added to `ElementusOptions`.
26
+ - Test `T47` covering onHeal; docs in README, `docs/API.md`, `docs/ARCHITECTURE.md`.
27
+
28
+ ### Changed
29
+
30
+ - No change to existing behavior. `onHeal` is opt-in; unset ⇒ byte-for-byte the same as 1.2.0
31
+ (zero-overhead path untouched, single export preserved).
32
+
5
33
  ## 1.2.0
6
34
 
7
35
  ### 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. 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
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,91 @@ 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 }.
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 }`. 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
+ // 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
371
+ try {
372
+ const info = base.info()
373
+ info.annotations.push({ type: 'healed', description: line }) // Playwright HTML report
374
+ void info.attach('healed', { body: line, contentType: 'text/plain' }) // Allure + custom reporters
375
+ } catch {} // ignored outside a running test
376
+ },
377
+ })
378
+
379
+ const test = base.extend({
380
+ page: async ({ page }, use) => { await use(el.wrapPage(page)) },
381
+ // Optional: fail CI when a selector only passed via self-healing (drift you should fix).
382
+ failOnHeal: [async ({}, use, testInfo) => {
383
+ await use()
384
+ const heals = testInfo.annotations.filter(a => a.type === 'healed')
385
+ if (process.env.ELEMENTUS_FAIL_ON_HEAL && heals.length) {
386
+ throw new Error('Passed only via self-healing — fix the selector(s):\n ' + heals.map(h => h.description).join('\n '))
387
+ }
388
+ }, { auto: true }],
389
+ })
390
+ module.exports = { test, expect: require('@playwright/test').expect }
391
+ ```
392
+
393
+ (TypeScript: import the `HealEvent` type from `elementus-ai`; the annotation/fixture code is identical.)
394
+
395
+ **WebdriverIO / Appium** — collect in `onHeal`, fail (or report) in `afterTest`:
396
+
397
+ ```javascript
398
+ // wdio.conf.js
399
+ const { createElementus } = require('elementus-ai')
400
+ let heals = []
401
+ const el = createElementus({ provider: 'gemini', geminiApiKey: '...', onHeal: (e) => heals.push(e) })
402
+
403
+ exports.config = {
404
+ beforeTest() { heals = [] },
405
+ afterTest() {
406
+ if (process.env.ELEMENTUS_FAIL_ON_HEAL && heals.length) {
407
+ throw new Error('healed: ' + heals.map(h => h.description).join(', '))
408
+ }
409
+ },
410
+ }
411
+ ```
412
+
413
+ **Where each report shows it:** the annotation appears in the **Playwright HTML report** (test → _Annotations_).
414
+ In **Allure**, the heal shows in the test's **step tree** (the broken locator → the healed `[data-elementus=…]`
415
+ element) and, with fail-on-heal on, the **failure message** — note `allure-playwright` does not convert
416
+ runtime annotations into labels/steps, so for a dedicated Allure marker also
417
+ `testInfo.attach('healed', { body: \`${e.selector} → ${e.description}\`, contentType: 'text/plain' })`
418
+ (attachments surface in Allure and most custom reporters). A custom HTML reporter surfaces the heal via the
419
+ failure message and captured stdout.
420
+
326
421
  ## Security Notes
327
422
 
328
423
  - **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 } }) => 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
381
  * @returns {{ wrap, wrapPage, wrapBrowser, locate, find, click }}
381
382
  */
382
383
  function createElementus(userConfig = {}) {
@@ -1988,6 +1989,31 @@ 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
+ // Fire the user's onHeal(event) after a real heal — the original selector failed and the AI
2001
+ // pipeline resolved a replacement. Best-effort and fully isolated: a throwing or rejecting
2002
+ // callback must never break a test. No-op with zero overhead when onHeal is not configured.
2003
+ function _notifyHeal(ctx, description, selectorKey, method) {
2004
+ const resolved = _lastResolved
2005
+ _lastResolved = null
2006
+ const cb = config.onHeal
2007
+ if (typeof cb !== 'function') return
2008
+ const framework = _isNative(ctx) ? 'appium' : _isPlaywright(ctx) ? 'playwright' : 'wdio'
2009
+ try {
2010
+ const event = { description, selector: selectorKey, method, framework }
2011
+ if (resolved) event.resolved = resolved
2012
+ const r = cb(event)
2013
+ if (r && typeof r.then === 'function') r.then(undefined, () => {})
2014
+ } catch {}
2015
+ }
2016
+
1991
2017
  async function _findByDescription(ctx, description, selectorKey = '') {
1992
2018
  const { record, somCandidates } = await _resolveElement(ctx, description, selectorKey)
1993
2019
  if (record) {
@@ -1995,6 +2021,7 @@ function createElementus(userConfig = {}) {
1995
2021
  const mark = {}
1996
2022
  const locator = record._locator || await markByElement(ctx, record, mark)
1997
2023
  await _cacheStore(ctx, description, selectorKey, record, record._uid || mark.uid || null)
2024
+ _lastResolved = _resolvedFacts(record)
1998
2025
  return locator
1999
2026
  } catch (err) {
2000
2027
  console.log(`[Resolve] Mark failed (${err.message}) — trying vision`)
@@ -2006,11 +2033,13 @@ function createElementus(userConfig = {}) {
2006
2033
  const mark = {}
2007
2034
  const locator = await markByElement(ctx, result.element, mark)
2008
2035
  await _cacheStore(ctx, description, selectorKey, result.element, mark.uid || null)
2036
+ _lastResolved = _resolvedFacts(result.element)
2009
2037
  return locator
2010
2038
  }
2011
2039
  const mark = {}
2012
2040
  const locator = await markAtCoordinates(ctx, result.coords.docX, result.coords.docY, mark)
2013
2041
  await _cacheStore(ctx, description, selectorKey, result.coords, mark.uid || null)
2042
+ _lastResolved = null
2014
2043
  return locator
2015
2044
  } catch (err) {
2016
2045
  throw new Error(`All fallback paths exhausted for "${description}": ${err.message}`)
@@ -2038,7 +2067,10 @@ function createElementus(userConfig = {}) {
2038
2067
  } catch {
2039
2068
  console.log(`\u2717 Locator failed \u2014 searching for: "${description}"`)
2040
2069
  }
2041
- return _findByDescription(ctx, description, _selectorKey(locator))
2070
+ const selectorKey = _selectorKey(locator)
2071
+ const resolved = await _findByDescription(ctx, description, selectorKey)
2072
+ _notifyHeal(ctx, description, selectorKey, 'locate')
2073
+ return resolved
2042
2074
  }
2043
2075
 
2044
2076
  /**
@@ -2088,6 +2120,8 @@ function createElementus(userConfig = {}) {
2088
2120
  // Store before clicking \u2014 the click may navigate away from the page
2089
2121
  await _cacheStore(ctx, description, selectorKey, record)
2090
2122
  await scrollAndClick(ctx, record)
2123
+ _lastResolved = _resolvedFacts(record)
2124
+ _notifyHeal(ctx, description, selectorKey, 'click')
2091
2125
  return
2092
2126
  }
2093
2127
  try {
@@ -2095,9 +2129,13 @@ function createElementus(userConfig = {}) {
2095
2129
  if (result.element) {
2096
2130
  await _cacheStore(ctx, description, selectorKey, result.element)
2097
2131
  await scrollAndClick(ctx, result.element)
2132
+ _lastResolved = _resolvedFacts(result.element)
2133
+ _notifyHeal(ctx, description, selectorKey, 'click')
2098
2134
  return
2099
2135
  }
2100
2136
  await clickAtCoords(ctx, result.coords)
2137
+ _lastResolved = null
2138
+ _notifyHeal(ctx, description, selectorKey, 'click')
2101
2139
  } catch (err) {
2102
2140
  throw new Error(`All fallback paths exhausted for "${description}": ${err.message}`)
2103
2141
  }
@@ -2160,6 +2198,7 @@ function createElementus(userConfig = {}) {
2160
2198
  if (!_resolved) {
2161
2199
  console.log(`[AI] ${prop}() \u2014 resolving via AI first for "${description}"`)
2162
2200
  _resolved = await _findByDescription(driverContext, description, wrapSelectorKey)
2201
+ _notifyHeal(driverContext, description, wrapSelectorKey, String(prop))
2163
2202
  }
2164
2203
  return _resolved[prop](...args)
2165
2204
  }
@@ -2168,7 +2207,10 @@ function createElementus(userConfig = {}) {
2168
2207
  return await original.apply(target, args)
2169
2208
  } catch (firstError) {
2170
2209
  console.log(`[AI] ${String(prop)}() failed \u2014 AI fallback for "${description}"`)
2171
- if (!_resolved) _resolved = await _findByDescription(driverContext, description, wrapSelectorKey)
2210
+ if (!_resolved) {
2211
+ _resolved = await _findByDescription(driverContext, description, wrapSelectorKey)
2212
+ _notifyHeal(driverContext, description, wrapSelectorKey, String(prop))
2213
+ }
2172
2214
 
2173
2215
  const resolvedMethod = _resolved[prop]
2174
2216
  if (typeof resolvedMethod !== 'function') {
package/index.d.ts CHANGED
@@ -36,6 +36,29 @@ 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
+ /**
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
+ */
61
+ resolved?: { tag: string; text: string; role: string | null }
39
62
  }
40
63
 
41
64
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elementus-ai",
3
- "version": "1.2.0",
3
+ "version": "1.4.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",