Skip to content

Commit 1308a6e

Browse files
authored
Merge pull request #157 from Lykhoyda/fix/issue-119-cascading-selector-diff
fix(gh-119): record nextFailedSelector on cascading-selector failures
2 parents 211100f + 0e7175e commit 1308a6e

8 files changed

Lines changed: 250 additions & 3 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
{
1010
"name": "rn-dev-agent",
1111
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
12-
"version": "0.44.43",
12+
"version": "0.44.44",
1313
"source": "./",
1414
"category": "mobile-development",
1515
"homepage": "https://github.com/Lykhoyda/rn-dev-agent"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent",
3-
"version": "0.44.43",
3+
"version": "0.44.44",
44
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
55
"author": {
66
"name": "Anton Lykhoyda",

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ All notable changes to rn-dev-agent will be documented in this file.
44

55
Format follows [Keep a Changelog](https://keepachangelog.com/).
66

7+
## [0.44.44] — 2026-05-13
8+
9+
### Fixed (GH #119 — AutoRepairOutcome cascading-selector clarification)
10+
11+
- **`AutoRepairOutcome.nextFailedSelector`**: new optional field, populated
12+
when auto-repair succeeded but the post-repair retry failed on a DIFFERENT
13+
selector. Lets MTTR analysis distinguish "patch didn't work" from
14+
"cascading failure — patch worked, next selector broke." Without this,
15+
the telemetry made every cascading failure look like a failed patch.
16+
- Absent when retry passed (happy path) OR when retry failed on the
17+
SAME selector as the patch (= patch didn't actually fix it). Codex
18+
flagged the misclassification at conf 85 in the PR #115 review.
19+
- 3 new regression tests cover the three cases. Suite 1312 → 1315 passing.
20+
721
## [0.44.43] — 2026-05-13
822

923
### Added (GH #116 — wire cdp_run_action into /run-action slash command)

scripts/cdp-bridge/dist/tools/run-action.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,24 @@ export function createRunActionHandler(deps = {}) {
292292
const repairTimestamp = reloadedAction.state.repairHistory.length > 0
293293
? reloadedAction.state.repairHistory[reloadedAction.state.repairHistory.length - 1].timestamp
294294
: undefined;
295+
// GH #119: when the retry fails on a DIFFERENT selector than the
296+
// one just patched, capture it as `nextFailedSelector` so MTTR
297+
// analysis can distinguish "patch didn't work" from "cascading
298+
// failure — patch worked, next selector broke." Only meaningful
299+
// when retry failed; same-selector failures (= patch didn't work)
300+
// are implicit in the existing diff.
301+
let nextFailedSelector;
302+
if (!retryPassed) {
303+
try {
304+
const retryFailure = parseMaestroFailure(retryOutput);
305+
if (retryFailure.kind === 'SELECTOR_NOT_FOUND' &&
306+
retryFailure.selector &&
307+
retryFailure.selector !== repairData.newSelector) {
308+
nextFailedSelector = retryFailure.selector;
309+
}
310+
}
311+
catch { /* best-effort — don't fail the run because the parser hiccuped */ }
312+
}
295313
const autoRepair = {
296314
attempted: true,
297315
outcome: retryPassed ? 'passed' : 'failed',
@@ -304,6 +322,7 @@ export function createRunActionHandler(deps = {}) {
304322
},
305323
phases: { firstAttemptMs, repairMs, retryMs },
306324
repairTimestamp,
325+
...(nextFailedSelector ? { nextFailedSelector } : {}),
307326
};
308327
await persistRun(args.actionId, projectRoot, {
309328
timestamp: new Date().toISOString(),

scripts/cdp-bridge/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent-cdp",
3-
"version": "0.38.38",
3+
"version": "0.38.39",
44
"type": "module",
55
"main": "dist/index.js",
66
"scripts": {

scripts/cdp-bridge/src/domain/reusable-action.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,14 @@ export interface AutoRepairOutcome {
209209
* and emitted a RepairRecord).
210210
*/
211211
repairTimestamp?: string;
212+
/**
213+
* GH #119: when outcome === 'failed' AND the post-repair retry failed
214+
* on a DIFFERENT selector than the one just patched, record it here
215+
* so MTTR analysis can distinguish "patch didn't work" from
216+
* "cascading failure — patch worked, next selector broke." Absent
217+
* when the retry failed on the same selector or didn't run.
218+
*/
219+
nextFailedSelector?: string;
212220
}
213221

214222
/** A single replay attempt's outcome. Append-only; oldest dropped at limit. */

scripts/cdp-bridge/src/tools/run-action.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,26 @@ export function createRunActionHandler(deps: RunActionDeps = {}) {
392392
? reloadedAction.state.repairHistory[reloadedAction.state.repairHistory.length - 1].timestamp
393393
: undefined;
394394

395+
// GH #119: when the retry fails on a DIFFERENT selector than the
396+
// one just patched, capture it as `nextFailedSelector` so MTTR
397+
// analysis can distinguish "patch didn't work" from "cascading
398+
// failure — patch worked, next selector broke." Only meaningful
399+
// when retry failed; same-selector failures (= patch didn't work)
400+
// are implicit in the existing diff.
401+
let nextFailedSelector: string | undefined;
402+
if (!retryPassed) {
403+
try {
404+
const retryFailure = parseMaestroFailure(retryOutput);
405+
if (
406+
retryFailure.kind === 'SELECTOR_NOT_FOUND' &&
407+
retryFailure.selector &&
408+
retryFailure.selector !== repairData.newSelector
409+
) {
410+
nextFailedSelector = retryFailure.selector;
411+
}
412+
} catch { /* best-effort — don't fail the run because the parser hiccuped */ }
413+
}
414+
395415
const autoRepair: AutoRepairOutcome = {
396416
attempted: true,
397417
outcome: retryPassed ? 'passed' : 'failed',
@@ -404,6 +424,7 @@ export function createRunActionHandler(deps: RunActionDeps = {}) {
404424
},
405425
phases: { firstAttemptMs, repairMs, retryMs },
406426
repairTimestamp,
427+
...(nextFailedSelector ? { nextFailedSelector } : {}),
407428
};
408429

409430
await persistRun(args.actionId, projectRoot, {
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// GH #119: cascading-selector hardening. When auto-repair patches
2+
// selector A→A' and the retry then fails on a DIFFERENT selector B,
3+
// AutoRepairOutcome.nextFailedSelector captures B so MTTR can
4+
// distinguish "patch didn't work" from "patch worked, next selector
5+
// broke."
6+
import { test, mock } from 'node:test';
7+
import assert from 'node:assert/strict';
8+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
9+
import { join } from 'node:path';
10+
import { tmpdir } from 'node:os';
11+
12+
const RUN_ACTION_PATH = '../../dist/tools/run-action.js';
13+
14+
function makeProject() {
15+
const root = mkdtempSync(join(tmpdir(), 'gh119-'));
16+
mkdirSync(join(root, '.rn-agent', 'actions'), { recursive: true });
17+
writeFileSync(join(root, '.rn-agent', 'actions', 'sample.yaml'), [
18+
'appId: com.test.app',
19+
'---',
20+
'# id: sample',
21+
'# intent: a sample',
22+
'# status: experimental',
23+
'# mutates: false',
24+
'- launchApp',
25+
'- tapOn:',
26+
' id: "btn-A"',
27+
].join('\n'));
28+
return root;
29+
}
30+
31+
test('AutoRepairOutcome.nextFailedSelector populated when retry fails on a different selector', async () => {
32+
const { createRunActionHandler } = await import(RUN_ACTION_PATH);
33+
const root = makeProject();
34+
35+
// First call: maestro fails with SELECTOR_NOT_FOUND on selector "btn-A".
36+
// Second call (post-repair retry): maestro fails on a DIFFERENT selector
37+
// "btn-B" → cascading failure.
38+
let mCount = 0;
39+
const fakeMaestroRun = mock.fn(async () => {
40+
mCount++;
41+
if (mCount === 1) {
42+
return {
43+
content: [{ type: 'text', text: JSON.stringify({
44+
ok: true,
45+
data: { passed: false, output: '== Element with id "btn-A" not found ==' },
46+
}) }],
47+
};
48+
}
49+
// Retry: also fails, but on a different selector
50+
return {
51+
content: [{ type: 'text', text: JSON.stringify({
52+
ok: true,
53+
data: { passed: false, output: '== Element with id "btn-B" not found ==' },
54+
}) }],
55+
};
56+
});
57+
58+
const fakeRepairAction = mock.fn(async () => ({
59+
content: [{ type: 'text', text: JSON.stringify({
60+
ok: true,
61+
data: { patched: true, oldSelector: 'btn-A', newSelector: 'btn-A-new', score: 0.9 },
62+
meta: { repairTimestamp: new Date().toISOString() },
63+
}) }],
64+
}));
65+
66+
const handler = createRunActionHandler({ maestroRun: fakeMaestroRun, repairAction: fakeRepairAction });
67+
const result = await handler({
68+
actionId: 'sample',
69+
projectRoot: root,
70+
platform: 'ios',
71+
autoRepair: true,
72+
});
73+
74+
// Result should be a failResult with the autoRepair meta
75+
assert.equal(result.isError, true);
76+
const env = JSON.parse(result.content[0].text);
77+
// The autoRepair payload is in meta (failResult shape)
78+
const autoRepair = env.meta?.autoRepair ?? env.data?.autoRepair;
79+
assert.ok(autoRepair, `expected autoRepair in envelope; got ${result.content[0].text.slice(0, 300)}`);
80+
assert.equal(autoRepair.outcome, 'failed');
81+
// The fix's contract: when retry fails on a different selector, capture it
82+
assert.equal(autoRepair.nextFailedSelector, 'btn-B',
83+
`nextFailedSelector should be the retry's failed selector; got ${autoRepair.nextFailedSelector}`);
84+
// The original diff stays unchanged
85+
assert.equal(autoRepair.diff?.selector?.from, 'btn-A');
86+
assert.equal(autoRepair.diff?.selector?.to, 'btn-A-new');
87+
88+
rmSync(root, { recursive: true, force: true });
89+
});
90+
91+
test('AutoRepairOutcome.nextFailedSelector NOT populated when retry fails on the SAME selector (patch did not work)', async () => {
92+
const { createRunActionHandler } = await import(RUN_ACTION_PATH);
93+
const root = makeProject();
94+
95+
let mCount = 0;
96+
const fakeMaestroRun = mock.fn(async () => {
97+
mCount++;
98+
if (mCount === 1) {
99+
return {
100+
content: [{ type: 'text', text: JSON.stringify({
101+
ok: true,
102+
data: { passed: false, output: '== Element with id "btn-A" not found ==' },
103+
}) }],
104+
};
105+
}
106+
// Retry fails on the SAME (newly patched) selector — patch didn't work
107+
return {
108+
content: [{ type: 'text', text: JSON.stringify({
109+
ok: true,
110+
data: { passed: false, output: '== Element with id "btn-A-new" not found ==' },
111+
}) }],
112+
};
113+
});
114+
115+
const fakeRepairAction = mock.fn(async () => ({
116+
content: [{ type: 'text', text: JSON.stringify({
117+
ok: true,
118+
data: { patched: true, oldSelector: 'btn-A', newSelector: 'btn-A-new' },
119+
meta: { repairTimestamp: new Date().toISOString() },
120+
}) }],
121+
}));
122+
123+
const handler = createRunActionHandler({ maestroRun: fakeMaestroRun, repairAction: fakeRepairAction });
124+
const result = await handler({
125+
actionId: 'sample',
126+
projectRoot: root,
127+
platform: 'ios',
128+
autoRepair: true,
129+
});
130+
131+
const env = JSON.parse(result.content[0].text);
132+
const autoRepair = env.meta?.autoRepair ?? env.data?.autoRepair;
133+
assert.ok(autoRepair);
134+
assert.equal(autoRepair.outcome, 'failed');
135+
// No cascading selector — should be absent
136+
assert.equal(autoRepair.nextFailedSelector, undefined,
137+
`nextFailedSelector should NOT be present when same selector failed; got ${autoRepair.nextFailedSelector}`);
138+
139+
rmSync(root, { recursive: true, force: true });
140+
});
141+
142+
test('AutoRepairOutcome.nextFailedSelector NOT populated when retry passed (happy repair path)', async () => {
143+
const { createRunActionHandler } = await import(RUN_ACTION_PATH);
144+
const root = makeProject();
145+
146+
let mCount = 0;
147+
const fakeMaestroRun = mock.fn(async () => {
148+
mCount++;
149+
if (mCount === 1) {
150+
return {
151+
content: [{ type: 'text', text: JSON.stringify({
152+
ok: true,
153+
data: { passed: false, output: '== Element with id "btn-A" not found ==' },
154+
}) }],
155+
};
156+
}
157+
return {
158+
content: [{ type: 'text', text: JSON.stringify({ ok: true, data: { passed: true, output: 'pass' } }) }],
159+
};
160+
});
161+
162+
const fakeRepairAction = mock.fn(async () => ({
163+
content: [{ type: 'text', text: JSON.stringify({
164+
ok: true,
165+
data: { patched: true, oldSelector: 'btn-A', newSelector: 'btn-A-new' },
166+
meta: { repairTimestamp: new Date().toISOString() },
167+
}) }],
168+
}));
169+
170+
const handler = createRunActionHandler({ maestroRun: fakeMaestroRun, repairAction: fakeRepairAction });
171+
const result = await handler({
172+
actionId: 'sample',
173+
projectRoot: root,
174+
platform: 'ios',
175+
autoRepair: true,
176+
});
177+
178+
assert.equal(result.isError, undefined);
179+
const env = JSON.parse(result.content[0].text);
180+
const autoRepair = env.data.autoRepair;
181+
assert.equal(autoRepair.outcome, 'passed');
182+
assert.equal(autoRepair.nextFailedSelector, undefined);
183+
184+
rmSync(root, { recursive: true, force: true });
185+
});

0 commit comments

Comments
 (0)