donobu 5.61.0 → 5.61.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.
@@ -147,6 +147,23 @@ function installSmartSelectorGenerator() {
147
147
  // 3. ≥2 long hex-ish segments joined with - or _
148
148
  str.split(/[-_]/).filter((s) => /^[a-f0-9]{6,}$/i.test(s)).length >= 2);
149
149
  };
150
+ /**
151
+ * Detects ids that are *ephemeral* — regenerated on every render — and so must
152
+ * never anchor a persisted selector: pinning one guarantees it goes stale at
153
+ * replay (the next render mints a fresh value).
154
+ *
155
+ * The canonical case is React's `useId()`, whose values are wrapped in colons
156
+ * (e.g. `:r18:`, `:R2iclofb:`, `:r1H2:`). React deliberately uses colons because
157
+ * they are invalid in a CSS id selector without escaping, signalling these ids
158
+ * are not meant to be used as selectors. The leading+trailing colon is the
159
+ * distinctive marker, and it avoids matching server-framework ids that merely
160
+ * use a colon as an internal separator (e.g. JSF's `form:button`). Extend this
161
+ * predicate as other ephemeral-id conventions surface.
162
+ *
163
+ * @param {string|null|undefined} id - The element id to test
164
+ * @returns {boolean} True if the id looks ephemeral (per-render)
165
+ */
166
+ const isEphemeralId = (id) => typeof id === 'string' && /^:.+:$/.test(id);
150
167
  // Semantic HTML5 elements that are likely to be unique and stable.
151
168
  const LANDMARK_TAGS = [
152
169
  'html',
@@ -400,6 +417,12 @@ function installSmartSelectorGenerator() {
400
417
  if (!id) {
401
418
  return;
402
419
  }
420
+ // Ephemeral ids (e.g. React useId() values) regenerate every render, so a
421
+ // selector pinned to one is guaranteed stale at replay. Never anchor on
422
+ // them — let the stable anchors (text, aria, placeholder, …) take over.
423
+ if (isEphemeralId(id)) {
424
+ return;
425
+ }
403
426
  const dynamic = isHashLike(id) || id.length > 24;
404
427
  this.push(`#${escapeCss(id)}`, dynamic ? 20 : 100);
405
428
  }
@@ -649,7 +672,10 @@ function installSmartSelectorGenerator() {
649
672
  const scope = scopeNode instanceof ShadowRoot || scopeNode instanceof Document
650
673
  ? scopeNode
651
674
  : document;
652
- if (el.id && !isHashLike(el.id) && el.id.length <= 24) {
675
+ if (el.id &&
676
+ !isHashLike(el.id) &&
677
+ !isEphemeralId(el.id) &&
678
+ el.id.length <= 24) {
653
679
  if (countMatchesCSS(`#${escapeCss(el.id)}`, scope) === 1) {
654
680
  return `#${escapeCss(el.id)}`;
655
681
  }
@@ -147,6 +147,23 @@ function installSmartSelectorGenerator() {
147
147
  // 3. ≥2 long hex-ish segments joined with - or _
148
148
  str.split(/[-_]/).filter((s) => /^[a-f0-9]{6,}$/i.test(s)).length >= 2);
149
149
  };
150
+ /**
151
+ * Detects ids that are *ephemeral* — regenerated on every render — and so must
152
+ * never anchor a persisted selector: pinning one guarantees it goes stale at
153
+ * replay (the next render mints a fresh value).
154
+ *
155
+ * The canonical case is React's `useId()`, whose values are wrapped in colons
156
+ * (e.g. `:r18:`, `:R2iclofb:`, `:r1H2:`). React deliberately uses colons because
157
+ * they are invalid in a CSS id selector without escaping, signalling these ids
158
+ * are not meant to be used as selectors. The leading+trailing colon is the
159
+ * distinctive marker, and it avoids matching server-framework ids that merely
160
+ * use a colon as an internal separator (e.g. JSF's `form:button`). Extend this
161
+ * predicate as other ephemeral-id conventions surface.
162
+ *
163
+ * @param {string|null|undefined} id - The element id to test
164
+ * @returns {boolean} True if the id looks ephemeral (per-render)
165
+ */
166
+ const isEphemeralId = (id) => typeof id === 'string' && /^:.+:$/.test(id);
150
167
  // Semantic HTML5 elements that are likely to be unique and stable.
151
168
  const LANDMARK_TAGS = [
152
169
  'html',
@@ -400,6 +417,12 @@ function installSmartSelectorGenerator() {
400
417
  if (!id) {
401
418
  return;
402
419
  }
420
+ // Ephemeral ids (e.g. React useId() values) regenerate every render, so a
421
+ // selector pinned to one is guaranteed stale at replay. Never anchor on
422
+ // them — let the stable anchors (text, aria, placeholder, …) take over.
423
+ if (isEphemeralId(id)) {
424
+ return;
425
+ }
403
426
  const dynamic = isHashLike(id) || id.length > 24;
404
427
  this.push(`#${escapeCss(id)}`, dynamic ? 20 : 100);
405
428
  }
@@ -649,7 +672,10 @@ function installSmartSelectorGenerator() {
649
672
  const scope = scopeNode instanceof ShadowRoot || scopeNode instanceof Document
650
673
  ? scopeNode
651
674
  : document;
652
- if (el.id && !isHashLike(el.id) && el.id.length <= 24) {
675
+ if (el.id &&
676
+ !isHashLike(el.id) &&
677
+ !isEphemeralId(el.id) &&
678
+ el.id.length <= 24) {
653
679
  if (countMatchesCSS(`#${escapeCss(el.id)}`, scope) === 1) {
654
680
  return `#${escapeCss(el.id)}`;
655
681
  }
@@ -608,6 +608,14 @@ class ReplayableInteraction extends Tool_1.Tool {
608
608
  ? ElementSelector_1.ElementSelectorSchema.parse(toolCall.parameters.selector)
609
609
  : ElementSelector_1.ElementSelectorSchema.parse(toolCall.outcome.metadata);
610
610
  let selectorCandidates = [...originalSelector.element];
611
+ // 0) Drop ephemeral-id selectors (e.g. React useId() values like
612
+ // `#\:r18\:`) outright. They regenerate every render, so replaying or
613
+ // (below) promoting one only re-pins a value that is already stale. Older
614
+ // recordings may still carry them; keep the list non-empty as a backstop.
615
+ const nonEphemeral = selectorCandidates.filter((sel) => !(0, AdvancedSelectorGenerator_1.isEphemeralIdSelector)(sel));
616
+ if (nonEphemeral.length > 0) {
617
+ selectorCandidates = nonEphemeral;
618
+ }
611
619
  // 1) Drop ID-based selectors if requested --------------------------------
612
620
  if (options.areElementIdsVolatile) {
613
621
  const nonId = selectorCandidates.filter((sel) => {
@@ -625,11 +633,18 @@ class ReplayableInteraction extends Tool_1.Tool {
625
633
  const firstSelectorIsAria = selectorCandidates.length > 0 &&
626
634
  ReplayableInteraction.isAriaBasedSelector(selectorCandidates[0]);
627
635
  if (!firstSelectorIsAria) {
628
- const idBasedSelectors = selectorCandidates.filter((sel) => ReplayableInteraction.isIdBasedSelector(sel));
629
- const nonIdBasedSelectors = selectorCandidates.filter((sel) => !ReplayableInteraction.isIdBasedSelector(sel));
630
- // Reorder: ID-based selectors first, then non-ID-based selectors
631
- if (idBasedSelectors.length > 0) {
632
- selectorCandidates = [...idBasedSelectors, ...nonIdBasedSelectors];
636
+ // Promote only STABLE ids. A dynamic id (a per-render React useId()
637
+ // value, a hash/UUID, an over-long id) is one the generator already
638
+ // deprioritized; promoting it would re-pin a value that is guaranteed
639
+ // stale at replay — exactly over the robust env/text selector that
640
+ // actually works. Such ids stay where generation ranked them (failover).
641
+ const stableIdSelectors = selectorCandidates.filter((sel) => ReplayableInteraction.isIdBasedSelector(sel) &&
642
+ !(0, AdvancedSelectorGenerator_1.isDynamicIdSelector)(sel));
643
+ const otherSelectors = selectorCandidates.filter((sel) => !ReplayableInteraction.isIdBasedSelector(sel) ||
644
+ (0, AdvancedSelectorGenerator_1.isDynamicIdSelector)(sel));
645
+ // Reorder: stable ID-based selectors first, the rest in generated order.
646
+ if (stableIdSelectors.length > 0) {
647
+ selectorCandidates = [...stableIdSelectors, ...otherSelectors];
633
648
  }
634
649
  }
635
650
  // If first selector is aria-based, leave the order unchanged
@@ -92,6 +92,30 @@ export declare function advanceSelectorCandidates(rawCandidates: string[], envDa
92
92
  export declare function pruneCoincidentalPositional(candidates: string[]): string[];
93
93
  /** A selector that carries an env reference (so it tracks the value at replay). */
94
94
  export declare function isEnvBearingSelector(selector: string): boolean;
95
+ /**
96
+ * A selector anchored on an *ephemeral* id — one regenerated on every render, so
97
+ * pinning it guarantees the selector goes stale at replay. The canonical case is
98
+ * a React `useId()` value: a colon-wrapped id (`#:r18:`, often CSS-escaped as
99
+ * `#\:r18\:`), possibly scoped (`#\:rd\: > li:nth-of-type(7)`). Mirrors the
100
+ * `isEphemeralId` guard in the browser-side generator; kept here as a Node-side
101
+ * funnel guard for candidates from any source. PURE.
102
+ */
103
+ export declare function isEphemeralIdSelector(selector: string): boolean;
104
+ /**
105
+ * Drop candidates anchored on an ephemeral id, but never empty the list: if every
106
+ * candidate is ephemeral, keep them (a stale selector still beats no selector).
107
+ * PURE.
108
+ */
109
+ export declare function pruneEphemeralIdSelectors(candidates: string[]): string[];
110
+ /**
111
+ * A selector anchored on a *dynamic* id — one the generator deliberately
112
+ * deprioritizes because it is machine-generated and unstable: an ephemeral
113
+ * (per-render) id like React's `useId()`, a hash/UUID-style id, or an over-long
114
+ * id. Such an id may legitimately survive as a low-priority failover, but must
115
+ * never be *promoted* to primary (doing so re-pins a value that is stale at
116
+ * replay). Mirrors the browser-side ranking heuristics. PURE.
117
+ */
118
+ export declare function isDynamicIdSelector(selector: string): boolean;
95
119
  /**
96
120
  * Heuristic for a selector that locates an element *purely by DOM position* — an
97
121
  * `:nth-*` pseudo, an XPath numeric index `[N]`, or a bare combinator chain — with
@@ -8,6 +8,9 @@ exports.buildSynthesisRequest = buildSynthesisRequest;
8
8
  exports.advanceSelectorCandidates = advanceSelectorCandidates;
9
9
  exports.pruneCoincidentalPositional = pruneCoincidentalPositional;
10
10
  exports.isEnvBearingSelector = isEnvBearingSelector;
11
+ exports.isEphemeralIdSelector = isEphemeralIdSelector;
12
+ exports.pruneEphemeralIdSelectors = pruneEphemeralIdSelectors;
13
+ exports.isDynamicIdSelector = isDynamicIdSelector;
11
14
  exports.isPurelyPositionalSelector = isPurelyPositionalSelector;
12
15
  const v4_1 = require("zod/v4");
13
16
  const Logger_1 = require("./Logger");
@@ -66,7 +69,10 @@ exports.SynthesizedSelectorSchema = v4_1.z.object({
66
69
  * or the model's rewrite can't be validated.
67
70
  */
68
71
  async function generateAdvancedSelectors(target, context) {
69
- const element = (await PlaywrightUtils_1.PlaywrightUtils.generateSelectors(target)).slice(0, MAX_REPLAY_SELECTOR_CANDIDATES);
72
+ // Prune ephemeral-id candidates (e.g. React useId() values) before capping, so
73
+ // the cap keeps stable failovers rather than spending slots on selectors that
74
+ // are guaranteed stale at replay.
75
+ const element = pruneEphemeralIdSelectors(await PlaywrightUtils_1.PlaywrightUtils.generateSelectors(target)).slice(0, MAX_REPLAY_SELECTOR_CANDIDATES);
70
76
  const frame = await deriveFrameSelector(target);
71
77
  const raw = { element, frame };
72
78
  if (element.length === 0) {
@@ -197,10 +203,14 @@ The target element is marked with the attribute \`${markerAttribute}\` in the HT
197
203
  below. Return a single CSS or XPath selector that UNIQUELY locates that element —
198
204
  but do NOT use \`${markerAttribute}\` (it is temporary and gone at replay).
199
205
  Instead, anchor on the env-derived text near it (e.g. a sibling/ancestor title or
200
- label), written as a {{$.env.NAME}} placeholder (use the \`| filter\` form when the
201
- on-page text is a transformed form of the value). Prefer an XPath that finds the
202
- container by its env-derived text and then descends to the target. If you cannot
203
- build a reliable, unique anchor, return null.
206
+ label), written as a {{$.env.NAME}} placeholder. When the on-page text is a
207
+ transformed form of the value, append EXACTLY ONE filter with the pipe form
208
+ \`{{$.env.NAME | filter}}\`, using only these filter names (they take NO
209
+ arguments): ${Object.keys(TemplateInterpolator_1.ENV_VALUE_FILTERS).join(', ')}. Do NOT invent other
210
+ filter names (e.g. \`upcase\`, \`slice\`) or pass arguments — any other filter is
211
+ rejected and the selector is discarded. Prefer an XPath that finds the container
212
+ by its env-derived text and then descends to the target. If you cannot build a
213
+ reliable, unique anchor, return null.
204
214
 
205
215
  Env vars in play:
206
216
  ${envMapping}`,
@@ -260,6 +270,85 @@ function pruneCoincidentalPositional(candidates) {
260
270
  function isEnvBearingSelector(selector) {
261
271
  return selector.includes('{{$.env.');
262
272
  }
273
+ /**
274
+ * A selector anchored on an *ephemeral* id — one regenerated on every render, so
275
+ * pinning it guarantees the selector goes stale at replay. The canonical case is
276
+ * a React `useId()` value: a colon-wrapped id (`#:r18:`, often CSS-escaped as
277
+ * `#\:r18\:`), possibly scoped (`#\:rd\: > li:nth-of-type(7)`). Mirrors the
278
+ * `isEphemeralId` guard in the browser-side generator; kept here as a Node-side
279
+ * funnel guard for candidates from any source. PURE.
280
+ */
281
+ function isEphemeralIdSelector(selector) {
282
+ // Drop CSS escaping (`#\:r18\:` -> `#:r18:`), then look for a `#`-id token
283
+ // whose value is colon-wrapped.
284
+ const unescaped = selector.replace(/\\/g, '');
285
+ return /#:[^\s>+~#.[\]:]+:/.test(unescaped);
286
+ }
287
+ /**
288
+ * Drop candidates anchored on an ephemeral id, but never empty the list: if every
289
+ * candidate is ephemeral, keep them (a stale selector still beats no selector).
290
+ * PURE.
291
+ */
292
+ function pruneEphemeralIdSelectors(candidates) {
293
+ const stable = candidates.filter((candidate) => !isEphemeralIdSelector(candidate));
294
+ return stable.length > 0 ? stable : candidates;
295
+ }
296
+ /**
297
+ * Mirrors the browser-side `isHashLike` heuristic (smart-selector-generator):
298
+ * detects machine-generated id *values* (webpack hashes, UUIDs, multi-segment
299
+ * hex) that change between builds/renders. Kept in sync by hand — the browser
300
+ * script is a separate, injected runtime that cannot import this module. PURE.
301
+ */
302
+ function isHashLikeIdValue(id) {
303
+ return (/^[a-f0-9]{6,}$/i.test(id) ||
304
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) ||
305
+ id.split(/[-_]/).filter((segment) => /^[a-f0-9]{6,}$/i.test(segment))
306
+ .length >= 2);
307
+ }
308
+ /**
309
+ * Decode the CSS identifier escapes that {@link CSS.escape} emits, enough to
310
+ * recover the underlying id value: hex escapes (`\39 ` -> `9`) and literal
311
+ * backslash escapes (`\:` -> `:`). PURE.
312
+ */
313
+ function cssUnescapeIdentifier(value) {
314
+ return value
315
+ .replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex) => {
316
+ const codePoint = parseInt(hex, 16);
317
+ return codePoint > 0 && codePoint <= 0x10ffff
318
+ ? String.fromCodePoint(codePoint)
319
+ : match;
320
+ })
321
+ .replace(/\\(.)/g, '$1');
322
+ }
323
+ /**
324
+ * The leading `#id` value of a CSS selector (unescaped), or null when the
325
+ * selector does not start with an id. Reads only the id token, stopping at the
326
+ * first structural boundary (combinator, attribute, class, or pseudo). PURE.
327
+ */
328
+ function leadingCssIdValue(selector) {
329
+ const trimmed = selector.trim();
330
+ if (!trimmed.startsWith('#')) {
331
+ return null;
332
+ }
333
+ const unescaped = cssUnescapeIdentifier(trimmed.slice(1));
334
+ const match = /^[^\s>+~.[:]+/.exec(unescaped);
335
+ return match ? match[0] : null;
336
+ }
337
+ /**
338
+ * A selector anchored on a *dynamic* id — one the generator deliberately
339
+ * deprioritizes because it is machine-generated and unstable: an ephemeral
340
+ * (per-render) id like React's `useId()`, a hash/UUID-style id, or an over-long
341
+ * id. Such an id may legitimately survive as a low-priority failover, but must
342
+ * never be *promoted* to primary (doing so re-pins a value that is stale at
343
+ * replay). Mirrors the browser-side ranking heuristics. PURE.
344
+ */
345
+ function isDynamicIdSelector(selector) {
346
+ if (isEphemeralIdSelector(selector)) {
347
+ return true;
348
+ }
349
+ const id = leadingCssIdValue(selector);
350
+ return id !== null && (isHashLikeIdValue(id) || id.length > 24);
351
+ }
263
352
  /**
264
353
  * Heuristic for a selector that locates an element *purely by DOM position* — an
265
354
  * `:nth-*` pseudo, an XPath numeric index `[N]`, or a bare combinator chain — with
@@ -608,6 +608,14 @@ class ReplayableInteraction extends Tool_1.Tool {
608
608
  ? ElementSelector_1.ElementSelectorSchema.parse(toolCall.parameters.selector)
609
609
  : ElementSelector_1.ElementSelectorSchema.parse(toolCall.outcome.metadata);
610
610
  let selectorCandidates = [...originalSelector.element];
611
+ // 0) Drop ephemeral-id selectors (e.g. React useId() values like
612
+ // `#\:r18\:`) outright. They regenerate every render, so replaying or
613
+ // (below) promoting one only re-pins a value that is already stale. Older
614
+ // recordings may still carry them; keep the list non-empty as a backstop.
615
+ const nonEphemeral = selectorCandidates.filter((sel) => !(0, AdvancedSelectorGenerator_1.isEphemeralIdSelector)(sel));
616
+ if (nonEphemeral.length > 0) {
617
+ selectorCandidates = nonEphemeral;
618
+ }
611
619
  // 1) Drop ID-based selectors if requested --------------------------------
612
620
  if (options.areElementIdsVolatile) {
613
621
  const nonId = selectorCandidates.filter((sel) => {
@@ -625,11 +633,18 @@ class ReplayableInteraction extends Tool_1.Tool {
625
633
  const firstSelectorIsAria = selectorCandidates.length > 0 &&
626
634
  ReplayableInteraction.isAriaBasedSelector(selectorCandidates[0]);
627
635
  if (!firstSelectorIsAria) {
628
- const idBasedSelectors = selectorCandidates.filter((sel) => ReplayableInteraction.isIdBasedSelector(sel));
629
- const nonIdBasedSelectors = selectorCandidates.filter((sel) => !ReplayableInteraction.isIdBasedSelector(sel));
630
- // Reorder: ID-based selectors first, then non-ID-based selectors
631
- if (idBasedSelectors.length > 0) {
632
- selectorCandidates = [...idBasedSelectors, ...nonIdBasedSelectors];
636
+ // Promote only STABLE ids. A dynamic id (a per-render React useId()
637
+ // value, a hash/UUID, an over-long id) is one the generator already
638
+ // deprioritized; promoting it would re-pin a value that is guaranteed
639
+ // stale at replay — exactly over the robust env/text selector that
640
+ // actually works. Such ids stay where generation ranked them (failover).
641
+ const stableIdSelectors = selectorCandidates.filter((sel) => ReplayableInteraction.isIdBasedSelector(sel) &&
642
+ !(0, AdvancedSelectorGenerator_1.isDynamicIdSelector)(sel));
643
+ const otherSelectors = selectorCandidates.filter((sel) => !ReplayableInteraction.isIdBasedSelector(sel) ||
644
+ (0, AdvancedSelectorGenerator_1.isDynamicIdSelector)(sel));
645
+ // Reorder: stable ID-based selectors first, the rest in generated order.
646
+ if (stableIdSelectors.length > 0) {
647
+ selectorCandidates = [...stableIdSelectors, ...otherSelectors];
633
648
  }
634
649
  }
635
650
  // If first selector is aria-based, leave the order unchanged
@@ -92,6 +92,30 @@ export declare function advanceSelectorCandidates(rawCandidates: string[], envDa
92
92
  export declare function pruneCoincidentalPositional(candidates: string[]): string[];
93
93
  /** A selector that carries an env reference (so it tracks the value at replay). */
94
94
  export declare function isEnvBearingSelector(selector: string): boolean;
95
+ /**
96
+ * A selector anchored on an *ephemeral* id — one regenerated on every render, so
97
+ * pinning it guarantees the selector goes stale at replay. The canonical case is
98
+ * a React `useId()` value: a colon-wrapped id (`#:r18:`, often CSS-escaped as
99
+ * `#\:r18\:`), possibly scoped (`#\:rd\: > li:nth-of-type(7)`). Mirrors the
100
+ * `isEphemeralId` guard in the browser-side generator; kept here as a Node-side
101
+ * funnel guard for candidates from any source. PURE.
102
+ */
103
+ export declare function isEphemeralIdSelector(selector: string): boolean;
104
+ /**
105
+ * Drop candidates anchored on an ephemeral id, but never empty the list: if every
106
+ * candidate is ephemeral, keep them (a stale selector still beats no selector).
107
+ * PURE.
108
+ */
109
+ export declare function pruneEphemeralIdSelectors(candidates: string[]): string[];
110
+ /**
111
+ * A selector anchored on a *dynamic* id — one the generator deliberately
112
+ * deprioritizes because it is machine-generated and unstable: an ephemeral
113
+ * (per-render) id like React's `useId()`, a hash/UUID-style id, or an over-long
114
+ * id. Such an id may legitimately survive as a low-priority failover, but must
115
+ * never be *promoted* to primary (doing so re-pins a value that is stale at
116
+ * replay). Mirrors the browser-side ranking heuristics. PURE.
117
+ */
118
+ export declare function isDynamicIdSelector(selector: string): boolean;
95
119
  /**
96
120
  * Heuristic for a selector that locates an element *purely by DOM position* — an
97
121
  * `:nth-*` pseudo, an XPath numeric index `[N]`, or a bare combinator chain — with
@@ -8,6 +8,9 @@ exports.buildSynthesisRequest = buildSynthesisRequest;
8
8
  exports.advanceSelectorCandidates = advanceSelectorCandidates;
9
9
  exports.pruneCoincidentalPositional = pruneCoincidentalPositional;
10
10
  exports.isEnvBearingSelector = isEnvBearingSelector;
11
+ exports.isEphemeralIdSelector = isEphemeralIdSelector;
12
+ exports.pruneEphemeralIdSelectors = pruneEphemeralIdSelectors;
13
+ exports.isDynamicIdSelector = isDynamicIdSelector;
11
14
  exports.isPurelyPositionalSelector = isPurelyPositionalSelector;
12
15
  const v4_1 = require("zod/v4");
13
16
  const Logger_1 = require("./Logger");
@@ -66,7 +69,10 @@ exports.SynthesizedSelectorSchema = v4_1.z.object({
66
69
  * or the model's rewrite can't be validated.
67
70
  */
68
71
  async function generateAdvancedSelectors(target, context) {
69
- const element = (await PlaywrightUtils_1.PlaywrightUtils.generateSelectors(target)).slice(0, MAX_REPLAY_SELECTOR_CANDIDATES);
72
+ // Prune ephemeral-id candidates (e.g. React useId() values) before capping, so
73
+ // the cap keeps stable failovers rather than spending slots on selectors that
74
+ // are guaranteed stale at replay.
75
+ const element = pruneEphemeralIdSelectors(await PlaywrightUtils_1.PlaywrightUtils.generateSelectors(target)).slice(0, MAX_REPLAY_SELECTOR_CANDIDATES);
70
76
  const frame = await deriveFrameSelector(target);
71
77
  const raw = { element, frame };
72
78
  if (element.length === 0) {
@@ -197,10 +203,14 @@ The target element is marked with the attribute \`${markerAttribute}\` in the HT
197
203
  below. Return a single CSS or XPath selector that UNIQUELY locates that element —
198
204
  but do NOT use \`${markerAttribute}\` (it is temporary and gone at replay).
199
205
  Instead, anchor on the env-derived text near it (e.g. a sibling/ancestor title or
200
- label), written as a {{$.env.NAME}} placeholder (use the \`| filter\` form when the
201
- on-page text is a transformed form of the value). Prefer an XPath that finds the
202
- container by its env-derived text and then descends to the target. If you cannot
203
- build a reliable, unique anchor, return null.
206
+ label), written as a {{$.env.NAME}} placeholder. When the on-page text is a
207
+ transformed form of the value, append EXACTLY ONE filter with the pipe form
208
+ \`{{$.env.NAME | filter}}\`, using only these filter names (they take NO
209
+ arguments): ${Object.keys(TemplateInterpolator_1.ENV_VALUE_FILTERS).join(', ')}. Do NOT invent other
210
+ filter names (e.g. \`upcase\`, \`slice\`) or pass arguments — any other filter is
211
+ rejected and the selector is discarded. Prefer an XPath that finds the container
212
+ by its env-derived text and then descends to the target. If you cannot build a
213
+ reliable, unique anchor, return null.
204
214
 
205
215
  Env vars in play:
206
216
  ${envMapping}`,
@@ -260,6 +270,85 @@ function pruneCoincidentalPositional(candidates) {
260
270
  function isEnvBearingSelector(selector) {
261
271
  return selector.includes('{{$.env.');
262
272
  }
273
+ /**
274
+ * A selector anchored on an *ephemeral* id — one regenerated on every render, so
275
+ * pinning it guarantees the selector goes stale at replay. The canonical case is
276
+ * a React `useId()` value: a colon-wrapped id (`#:r18:`, often CSS-escaped as
277
+ * `#\:r18\:`), possibly scoped (`#\:rd\: > li:nth-of-type(7)`). Mirrors the
278
+ * `isEphemeralId` guard in the browser-side generator; kept here as a Node-side
279
+ * funnel guard for candidates from any source. PURE.
280
+ */
281
+ function isEphemeralIdSelector(selector) {
282
+ // Drop CSS escaping (`#\:r18\:` -> `#:r18:`), then look for a `#`-id token
283
+ // whose value is colon-wrapped.
284
+ const unescaped = selector.replace(/\\/g, '');
285
+ return /#:[^\s>+~#.[\]:]+:/.test(unescaped);
286
+ }
287
+ /**
288
+ * Drop candidates anchored on an ephemeral id, but never empty the list: if every
289
+ * candidate is ephemeral, keep them (a stale selector still beats no selector).
290
+ * PURE.
291
+ */
292
+ function pruneEphemeralIdSelectors(candidates) {
293
+ const stable = candidates.filter((candidate) => !isEphemeralIdSelector(candidate));
294
+ return stable.length > 0 ? stable : candidates;
295
+ }
296
+ /**
297
+ * Mirrors the browser-side `isHashLike` heuristic (smart-selector-generator):
298
+ * detects machine-generated id *values* (webpack hashes, UUIDs, multi-segment
299
+ * hex) that change between builds/renders. Kept in sync by hand — the browser
300
+ * script is a separate, injected runtime that cannot import this module. PURE.
301
+ */
302
+ function isHashLikeIdValue(id) {
303
+ return (/^[a-f0-9]{6,}$/i.test(id) ||
304
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) ||
305
+ id.split(/[-_]/).filter((segment) => /^[a-f0-9]{6,}$/i.test(segment))
306
+ .length >= 2);
307
+ }
308
+ /**
309
+ * Decode the CSS identifier escapes that {@link CSS.escape} emits, enough to
310
+ * recover the underlying id value: hex escapes (`\39 ` -> `9`) and literal
311
+ * backslash escapes (`\:` -> `:`). PURE.
312
+ */
313
+ function cssUnescapeIdentifier(value) {
314
+ return value
315
+ .replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex) => {
316
+ const codePoint = parseInt(hex, 16);
317
+ return codePoint > 0 && codePoint <= 0x10ffff
318
+ ? String.fromCodePoint(codePoint)
319
+ : match;
320
+ })
321
+ .replace(/\\(.)/g, '$1');
322
+ }
323
+ /**
324
+ * The leading `#id` value of a CSS selector (unescaped), or null when the
325
+ * selector does not start with an id. Reads only the id token, stopping at the
326
+ * first structural boundary (combinator, attribute, class, or pseudo). PURE.
327
+ */
328
+ function leadingCssIdValue(selector) {
329
+ const trimmed = selector.trim();
330
+ if (!trimmed.startsWith('#')) {
331
+ return null;
332
+ }
333
+ const unescaped = cssUnescapeIdentifier(trimmed.slice(1));
334
+ const match = /^[^\s>+~.[:]+/.exec(unescaped);
335
+ return match ? match[0] : null;
336
+ }
337
+ /**
338
+ * A selector anchored on a *dynamic* id — one the generator deliberately
339
+ * deprioritizes because it is machine-generated and unstable: an ephemeral
340
+ * (per-render) id like React's `useId()`, a hash/UUID-style id, or an over-long
341
+ * id. Such an id may legitimately survive as a low-priority failover, but must
342
+ * never be *promoted* to primary (doing so re-pins a value that is stale at
343
+ * replay). Mirrors the browser-side ranking heuristics. PURE.
344
+ */
345
+ function isDynamicIdSelector(selector) {
346
+ if (isEphemeralIdSelector(selector)) {
347
+ return true;
348
+ }
349
+ const id = leadingCssIdValue(selector);
350
+ return id !== null && (isHashLikeIdValue(id) || id.length > 24);
351
+ }
263
352
  /**
264
353
  * Heuristic for a selector that locates an element *purely by DOM position* — an
265
354
  * `:nth-*` pseudo, an XPath numeric index `[N]`, or a bare combinator chain — with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "donobu",
3
- "version": "5.61.0",
3
+ "version": "5.61.1",
4
4
  "description": "Create browser automations with an LLM agent and replay them as Playwright scripts.",
5
5
  "main": "dist/main.js",
6
6
  "module": "dist/esm/main.js",