donobu 5.61.0 → 5.61.2
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/dist/browser-side-scripts/smart-selector-generator.js +27 -1
- package/dist/cli/donobu-cli.d.ts +8 -0
- package/dist/cli/donobu-cli.js +19 -0
- package/dist/esm/browser-side-scripts/smart-selector-generator.js +27 -1
- package/dist/esm/cli/donobu-cli.d.ts +8 -0
- package/dist/esm/cli/donobu-cli.js +19 -0
- package/dist/esm/tools/ReplayableInteraction.js +20 -5
- package/dist/esm/utils/AdvancedSelectorGenerator.d.ts +24 -0
- package/dist/esm/utils/AdvancedSelectorGenerator.js +94 -5
- package/dist/tools/ReplayableInteraction.js +20 -5
- package/dist/utils/AdvancedSelectorGenerator.d.ts +24 -0
- package/dist/utils/AdvancedSelectorGenerator.js +94 -5
- package/package.json +1 -1
|
@@ -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 &&
|
|
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
|
}
|
package/dist/cli/donobu-cli.d.ts
CHANGED
|
@@ -18,12 +18,20 @@ declare function ensureReporterValueHasJson(value: string): {
|
|
|
18
18
|
* caller to fall back to the rerun's own exit code rather than assume success.
|
|
19
19
|
*/
|
|
20
20
|
declare function countUnexpectedTests(report: DonobuReport): number | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Reconcile a run's process status with its recorded report. The child process
|
|
23
|
+
* is normally authoritative, but wrappers can surface exit 0 even when the
|
|
24
|
+
* report records unexpected failures. Escalate only zero exits so infra and
|
|
25
|
+
* crash codes keep their original signal.
|
|
26
|
+
*/
|
|
27
|
+
declare function reconcileExitCodeWithReport(exitCode: number, report: DonobuReport | null): number;
|
|
21
28
|
/** @internal Exposed for unit tests only — not part of the public API. */
|
|
22
29
|
export declare const _forTesting: {
|
|
23
30
|
countUnexpectedTests: typeof countUnexpectedTests;
|
|
24
31
|
ensureReporterValueHasJson: typeof ensureReporterValueHasJson;
|
|
25
32
|
hasReporterArg: typeof hasReporterArg;
|
|
26
33
|
injectJsonReporterIntoArgs: typeof injectJsonReporterIntoArgs;
|
|
34
|
+
reconcileExitCodeWithReport: typeof reconcileExitCodeWithReport;
|
|
27
35
|
};
|
|
28
36
|
export {};
|
|
29
37
|
//# sourceMappingURL=donobu-cli.d.ts.map
|
package/dist/cli/donobu-cli.js
CHANGED
|
@@ -1512,6 +1512,23 @@ function countUnexpectedTests(report) {
|
|
|
1512
1512
|
const unexpected = stats?.unexpected;
|
|
1513
1513
|
return typeof unexpected === 'number' ? unexpected : undefined;
|
|
1514
1514
|
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Reconcile a run's process status with its recorded report. The child process
|
|
1517
|
+
* is normally authoritative, but wrappers can surface exit 0 even when the
|
|
1518
|
+
* report records unexpected failures. Escalate only zero exits so infra and
|
|
1519
|
+
* crash codes keep their original signal.
|
|
1520
|
+
*/
|
|
1521
|
+
function reconcileExitCodeWithReport(exitCode, report) {
|
|
1522
|
+
if (exitCode !== 0 || !report) {
|
|
1523
|
+
return exitCode;
|
|
1524
|
+
}
|
|
1525
|
+
const unexpected = countUnexpectedTests(report);
|
|
1526
|
+
if (unexpected !== undefined && unexpected > 0) {
|
|
1527
|
+
Logger_1.appLogger.warn(`Run exited 0 but the report records ${unexpected} failed test(s); exiting non-zero.`);
|
|
1528
|
+
return 1;
|
|
1529
|
+
}
|
|
1530
|
+
return exitCode;
|
|
1531
|
+
}
|
|
1515
1532
|
/**
|
|
1516
1533
|
* Find a test entry in a report by title + project name. File paths are
|
|
1517
1534
|
* deliberately not part of the lookup: treatment plans carry absolute paths
|
|
@@ -2038,6 +2055,7 @@ async function runTestCommand(cliArgs) {
|
|
|
2038
2055
|
// guarantee the reporter was deferring for.
|
|
2039
2056
|
await postDeferredSlackFromInitialRun(playwrightOutputDir);
|
|
2040
2057
|
}
|
|
2058
|
+
return reconcileExitCodeWithReport(autoHealOutcome.exitCode, enrichedInitialReport ?? initialDonobuReport);
|
|
2041
2059
|
}
|
|
2042
2060
|
return autoHealOutcome.exitCode;
|
|
2043
2061
|
}
|
|
@@ -2268,5 +2286,6 @@ exports._forTesting = {
|
|
|
2268
2286
|
ensureReporterValueHasJson,
|
|
2269
2287
|
hasReporterArg,
|
|
2270
2288
|
injectJsonReporterIntoArgs,
|
|
2289
|
+
reconcileExitCodeWithReport,
|
|
2271
2290
|
};
|
|
2272
2291
|
//# sourceMappingURL=donobu-cli.js.map
|
|
@@ -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 &&
|
|
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
|
}
|
|
@@ -18,12 +18,20 @@ declare function ensureReporterValueHasJson(value: string): {
|
|
|
18
18
|
* caller to fall back to the rerun's own exit code rather than assume success.
|
|
19
19
|
*/
|
|
20
20
|
declare function countUnexpectedTests(report: DonobuReport): number | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Reconcile a run's process status with its recorded report. The child process
|
|
23
|
+
* is normally authoritative, but wrappers can surface exit 0 even when the
|
|
24
|
+
* report records unexpected failures. Escalate only zero exits so infra and
|
|
25
|
+
* crash codes keep their original signal.
|
|
26
|
+
*/
|
|
27
|
+
declare function reconcileExitCodeWithReport(exitCode: number, report: DonobuReport | null): number;
|
|
21
28
|
/** @internal Exposed for unit tests only — not part of the public API. */
|
|
22
29
|
export declare const _forTesting: {
|
|
23
30
|
countUnexpectedTests: typeof countUnexpectedTests;
|
|
24
31
|
ensureReporterValueHasJson: typeof ensureReporterValueHasJson;
|
|
25
32
|
hasReporterArg: typeof hasReporterArg;
|
|
26
33
|
injectJsonReporterIntoArgs: typeof injectJsonReporterIntoArgs;
|
|
34
|
+
reconcileExitCodeWithReport: typeof reconcileExitCodeWithReport;
|
|
27
35
|
};
|
|
28
36
|
export {};
|
|
29
37
|
//# sourceMappingURL=donobu-cli.d.ts.map
|
|
@@ -1512,6 +1512,23 @@ function countUnexpectedTests(report) {
|
|
|
1512
1512
|
const unexpected = stats?.unexpected;
|
|
1513
1513
|
return typeof unexpected === 'number' ? unexpected : undefined;
|
|
1514
1514
|
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Reconcile a run's process status with its recorded report. The child process
|
|
1517
|
+
* is normally authoritative, but wrappers can surface exit 0 even when the
|
|
1518
|
+
* report records unexpected failures. Escalate only zero exits so infra and
|
|
1519
|
+
* crash codes keep their original signal.
|
|
1520
|
+
*/
|
|
1521
|
+
function reconcileExitCodeWithReport(exitCode, report) {
|
|
1522
|
+
if (exitCode !== 0 || !report) {
|
|
1523
|
+
return exitCode;
|
|
1524
|
+
}
|
|
1525
|
+
const unexpected = countUnexpectedTests(report);
|
|
1526
|
+
if (unexpected !== undefined && unexpected > 0) {
|
|
1527
|
+
Logger_1.appLogger.warn(`Run exited 0 but the report records ${unexpected} failed test(s); exiting non-zero.`);
|
|
1528
|
+
return 1;
|
|
1529
|
+
}
|
|
1530
|
+
return exitCode;
|
|
1531
|
+
}
|
|
1515
1532
|
/**
|
|
1516
1533
|
* Find a test entry in a report by title + project name. File paths are
|
|
1517
1534
|
* deliberately not part of the lookup: treatment plans carry absolute paths
|
|
@@ -2038,6 +2055,7 @@ async function runTestCommand(cliArgs) {
|
|
|
2038
2055
|
// guarantee the reporter was deferring for.
|
|
2039
2056
|
await postDeferredSlackFromInitialRun(playwrightOutputDir);
|
|
2040
2057
|
}
|
|
2058
|
+
return reconcileExitCodeWithReport(autoHealOutcome.exitCode, enrichedInitialReport ?? initialDonobuReport);
|
|
2041
2059
|
}
|
|
2042
2060
|
return autoHealOutcome.exitCode;
|
|
2043
2061
|
}
|
|
@@ -2268,5 +2286,6 @@ exports._forTesting = {
|
|
|
2268
2286
|
ensureReporterValueHasJson,
|
|
2269
2287
|
hasReporterArg,
|
|
2270
2288
|
injectJsonReporterIntoArgs,
|
|
2289
|
+
reconcileExitCodeWithReport,
|
|
2271
2290
|
};
|
|
2272
2291
|
//# sourceMappingURL=donobu-cli.js.map
|
|
@@ -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
|
-
|
|
629
|
-
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|