Skip to content

Commit c4650e9

Browse files
ptrthomasclaude
andcommitted
feat(driver): candidate-ranking extension seam for the wildcard resolver
__kjs.setResolveRanker(fn) registers a ranker consulted by __kjs.resolve: it receives the full matched-candidate list (same predicate as the default path — visible, leaf-text match, DOM order) plus the resolve context, and returns the ranked list; index selection then applies to that list. The ranker may reorder matches or add candidates the default visible-only filter excluded (e.g. a hidden element the caller knows how to reveal). Behavior-compatible by default: with no ranker registered the resolver keeps its exact short-circuit path; an identity ranker reproduces default behavior; a throwing or non-array-returning ranker is ignored for that resolution and logged. Motivation: a downstream runtime that knows an open menu item should beat an ambient same-text label (a text tie where DOM order shifts between record and replay) previously had to monkeypatch __kjs.resolve wholesale. The seam replaces that with a registered ranker the resolver owns, and is the extension point richer candidate ranking (hidden-with-reveal-plan, actionable-over-ambient, self-healing) can plug into without ever clobbering the resolver. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 52849ca commit c4650e9

2 files changed

Lines changed: 135 additions & 0 deletions

File tree

karate-core/src/main/resources/io/karatelabs/driver/driver.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,13 +307,36 @@
307307
// ==================== Wildcard Resolver ====================
308308
// Public API: __kjs.resolve(tag, text, index, contains)
309309

310+
/**
311+
* Extension seam: register a candidate ranker consulted by resolve().
312+
*
313+
* The ranker receives (matches, ctx) where matches is the full list of
314+
* elements satisfying the resolve criteria in DOM order (visible,
315+
* leaf-text match \u2014 the same predicate the default path applies) and
316+
* ctx is {tag, text, index, contains, selector} with text normalized.
317+
* It returns the ranked candidate list; index selection then applies to
318+
* that list. The ranker may reorder matches or add candidates of its own
319+
* (e.g. elements the default visibility filter excluded). A ranker that
320+
* throws or returns a non-array is ignored for that resolution.
321+
*
322+
* With no ranker registered, resolution behavior is unchanged (first
323+
* visible leaf match in DOM order, short-circuit preserved).
324+
* Pass null to unregister.
325+
*/
326+
kjs.setResolveRanker = function(fn) {
327+
this._resolveRanker = (typeof fn === 'function') ? fn : null;
328+
};
329+
310330
/**
311331
* Match candidates against text criteria.
312332
* Shared logic for light DOM and shadow DOM resolution.
313333
*/
314334
kjs._resolveFromCandidates = function(candidates, tag, text, index, contains) {
315335
var selector = this.getSelector(tag);
316336
text = text.replace(/\s+/g, ' ').trim(); // normalize whitespace (including \u00a0)
337+
if (this._resolveRanker) {
338+
return this._resolveRanked(candidates, tag, text, index, contains, selector);
339+
}
317340
var count = 0;
318341
for (var i = 0; i < candidates.length; i++) {
319342
var el = candidates[i];
@@ -337,6 +360,46 @@
337360
return null;
338361
};
339362

363+
/**
364+
* Ranked resolution path (active only when a ranker is registered).
365+
* Collects ALL matches with the same predicate the default path applies,
366+
* hands them to the ranker, then applies the same index selection to the
367+
* ranked list \u2014 so an identity ranker reproduces default behavior exactly.
368+
*/
369+
kjs._resolveRanked = function(candidates, tag, text, index, contains, selector) {
370+
var matches = [];
371+
for (var i = 0; i < candidates.length; i++) {
372+
var el = candidates[i];
373+
if (!this.isVisible(el)) continue;
374+
if (text === '') {
375+
matches.push(el);
376+
continue;
377+
}
378+
var elText = this.getVisibleText(el);
379+
if (!elText) continue;
380+
var ok = contains
381+
? elText.indexOf(text) !== -1
382+
: elText === text;
383+
if (ok && !this.hasMatchingDescendant(el, text, contains, selector)) {
384+
matches.push(el);
385+
}
386+
}
387+
var ranked = matches;
388+
try {
389+
var result = this._resolveRanker(matches.slice(), {
390+
tag: tag, text: text, index: index, contains: contains, selector: selector
391+
});
392+
if (Array.isArray(result)) ranked = result;
393+
} catch (e) {
394+
this.log('resolve ranker failed, using default order', {error: '' + e});
395+
}
396+
// same selection semantics as the default path: the index-th match, 1-based
397+
for (var j = 0; j < ranked.length; j++) {
398+
if (j + 1 === index) return ranked[j];
399+
}
400+
return null;
401+
};
402+
340403
kjs.resolve = function(tag, text, index, contains) {
341404
var selector = this.getSelector(tag);
342405
// Try light DOM first

karate-core/src/test/java/io/karatelabs/driver/e2e/LocatorsE2eTest.java

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,4 +495,76 @@ void testActionJsOnMissingElementFailsLoudlyNamingLocator() {
495495
}
496496
}
497497

498+
// ========== Resolve ranker (the __kjs.setResolveRanker extension seam) ==========
499+
// A downstream runtime (e.g. an agent layer that knows an open menu item should
500+
// beat an ambient same-text label) can register a candidate ranker instead of
501+
// monkeypatching __kjs.resolve. The seam's contract: with no ranker — or a
502+
// broken one — resolution is EXACTLY the default (first visible leaf match in
503+
// DOM order); a registered ranker reorders or augments the matched candidate
504+
// list, and index selection applies to the ranked list.
505+
506+
@Test
507+
void testResolveRankerReordersTieAndUnregisters() {
508+
driver.setUrl("data:text/html," +
509+
"<div id='ambient'>Ray</div>" +
510+
"<div id='menu-item'>Ray</div>");
511+
// default: first visible match in DOM order
512+
assertEquals("ambient", driver.attribute("{div}Ray", "id"));
513+
driver.script("window.__kjs.setResolveRanker(function(matches, ctx){"
514+
+ " return matches.slice().reverse(); })");
515+
assertEquals("menu-item", driver.attribute("{div}Ray", "id"));
516+
// unregister restores the default order
517+
driver.script("window.__kjs.setResolveRanker(null)");
518+
assertEquals("ambient", driver.attribute("{div}Ray", "id"));
519+
}
520+
521+
@Test
522+
void testResolveRankerIndexAppliesToRankedList() {
523+
driver.setUrl("data:text/html," +
524+
"<b id='one'>X</b><b id='two'>X</b><b id='three'>X</b>");
525+
// rotate: [two, three, one]
526+
driver.script("window.__kjs.setResolveRanker(function(m){ m.push(m.shift()); return m; })");
527+
assertEquals("two", driver.attribute("{b:1}X", "id"));
528+
assertEquals("three", driver.attribute("{b:2}X", "id"));
529+
assertEquals("one", driver.attribute("{b:3}X", "id"));
530+
// index beyond the ranked list still misses
531+
assertFalse(driver.exists("{b:4}X"));
532+
}
533+
534+
@Test
535+
void testResolveRankerIdentityKeepsDefaultBehavior() {
536+
driver.script("window.__kjs.setResolveRanker(function(m){ return m; })");
537+
assertEquals("1", driver.attribute("{li:1}Item", "data-index"));
538+
assertEquals("2", driver.attribute("{li:2}Item", "data-index"));
539+
assertEquals("3", driver.attribute("{li:3}Item", "data-index"));
540+
assertEquals("Click Me", driver.text("{button}Click Me"));
541+
assertFalse(driver.exists("{button}NonExistentText"));
542+
}
543+
544+
@Test
545+
void testResolveRankerFailuresFallBackToDefaultOrder() {
546+
driver.setUrl("data:text/html," +
547+
"<div id='first'>Twin</div>" +
548+
"<div id='second'>Twin</div>");
549+
// a throwing ranker must not break resolution
550+
driver.script("window.__kjs.setResolveRanker(function(){ throw new Error('boom'); })");
551+
assertEquals("first", driver.attribute("{div}Twin", "id"));
552+
// a non-array return is ignored
553+
driver.script("window.__kjs.setResolveRanker(function(){ return 'nope'; })");
554+
assertEquals("first", driver.attribute("{div}Twin", "id"));
555+
}
556+
557+
@Test
558+
void testResolveRankerMayAddCandidatesTheDefaultFilterExcluded() {
559+
// the ranker may inject candidates of its own — e.g. a hidden element it
560+
// knows how to reveal — which the default visible-only filter never emits
561+
driver.setUrl("data:text/html," +
562+
"<div id='visible-twin'>Save</div>" +
563+
"<div id='hidden-menu' style='display:none'>Save</div>");
564+
assertEquals("visible-twin", driver.attribute("{div}Save", "id"));
565+
driver.script("window.__kjs.setResolveRanker(function(m){"
566+
+ " m.unshift(document.getElementById('hidden-menu')); return m; })");
567+
assertEquals("hidden-menu", driver.attribute("{div}Save", "id"));
568+
}
569+
498570
}

0 commit comments

Comments
 (0)