agent-device 0.3.2 → 0.3.4
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/src/daemon.js +15 -15
- package/package.json +1 -1
- package/src/daemon/__tests__/selectors.test.ts +133 -0
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +140 -0
- package/src/daemon/handlers/interaction.ts +6 -3
- package/src/daemon/handlers/session.ts +71 -6
- package/src/daemon/selectors.ts +137 -20
- package/src/daemon/snapshot-processing.ts +5 -1
package/package.json
CHANGED
|
@@ -74,6 +74,97 @@ test('resolveSelectorChain falls back when first selector is ambiguous', () => {
|
|
|
74
74
|
assert.equal(resolved.node.ref, 'e2');
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
test('resolveSelectorChain keeps strict ambiguity behavior by default', () => {
|
|
78
|
+
const chain = parseSelectorChain('label="Continue"');
|
|
79
|
+
const resolved = resolveSelectorChain(nodes, chain, {
|
|
80
|
+
platform: 'ios',
|
|
81
|
+
requireRect: true,
|
|
82
|
+
requireUnique: true,
|
|
83
|
+
});
|
|
84
|
+
assert.equal(resolved, null);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('resolveSelectorChain disambiguates to deeper/smaller matching node when enabled', () => {
|
|
88
|
+
const disambiguationNodes: SnapshotState['nodes'] = [
|
|
89
|
+
{
|
|
90
|
+
ref: 'e1',
|
|
91
|
+
index: 0,
|
|
92
|
+
type: 'Other',
|
|
93
|
+
label: 'Press me',
|
|
94
|
+
rect: { x: 0, y: 0, width: 300, height: 300 },
|
|
95
|
+
depth: 1,
|
|
96
|
+
enabled: true,
|
|
97
|
+
hittable: true,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
ref: 'e2',
|
|
101
|
+
index: 1,
|
|
102
|
+
type: 'Other',
|
|
103
|
+
label: 'Press me',
|
|
104
|
+
rect: { x: 10, y: 10, width: 100, height: 20 },
|
|
105
|
+
depth: 2,
|
|
106
|
+
enabled: true,
|
|
107
|
+
hittable: true,
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
const chain = parseSelectorChain('role="other" label="Press me" || label="Press me"');
|
|
111
|
+
const resolved = resolveSelectorChain(disambiguationNodes, chain, {
|
|
112
|
+
platform: 'ios',
|
|
113
|
+
requireRect: true,
|
|
114
|
+
requireUnique: true,
|
|
115
|
+
disambiguateAmbiguous: true,
|
|
116
|
+
});
|
|
117
|
+
assert.ok(resolved);
|
|
118
|
+
assert.equal(resolved.node.ref, 'e2');
|
|
119
|
+
assert.equal(resolved.matches, 2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('resolveSelectorChain disambiguation tie falls back to next selector', () => {
|
|
123
|
+
const tieNodes: SnapshotState['nodes'] = [
|
|
124
|
+
{
|
|
125
|
+
ref: 'e1',
|
|
126
|
+
index: 0,
|
|
127
|
+
type: 'Other',
|
|
128
|
+
label: 'Press me',
|
|
129
|
+
rect: { x: 0, y: 0, width: 100, height: 20 },
|
|
130
|
+
depth: 2,
|
|
131
|
+
enabled: true,
|
|
132
|
+
hittable: true,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
ref: 'e2',
|
|
136
|
+
index: 1,
|
|
137
|
+
type: 'Other',
|
|
138
|
+
label: 'Press me',
|
|
139
|
+
rect: { x: 0, y: 40, width: 100, height: 20 },
|
|
140
|
+
depth: 2,
|
|
141
|
+
enabled: true,
|
|
142
|
+
hittable: true,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
ref: 'e3',
|
|
146
|
+
index: 2,
|
|
147
|
+
type: 'Other',
|
|
148
|
+
label: 'Press me',
|
|
149
|
+
identifier: 'press_me_unique',
|
|
150
|
+
rect: { x: 0, y: 80, width: 100, height: 20 },
|
|
151
|
+
depth: 2,
|
|
152
|
+
enabled: true,
|
|
153
|
+
hittable: true,
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
const chain = parseSelectorChain('label="Press me" || id="press_me_unique"');
|
|
157
|
+
const resolved = resolveSelectorChain(tieNodes, chain, {
|
|
158
|
+
platform: 'ios',
|
|
159
|
+
requireRect: true,
|
|
160
|
+
requireUnique: true,
|
|
161
|
+
disambiguateAmbiguous: true,
|
|
162
|
+
});
|
|
163
|
+
assert.ok(resolved);
|
|
164
|
+
assert.equal(resolved.selectorIndex, 1);
|
|
165
|
+
assert.equal(resolved.node.ref, 'e3');
|
|
166
|
+
});
|
|
167
|
+
|
|
77
168
|
test('findSelectorChainMatch returns first matching selector for existence checks', () => {
|
|
78
169
|
const chain = parseSelectorChain('label="Continue" || id=auth_continue');
|
|
79
170
|
const match = findSelectorChainMatch(nodes, chain, {
|
|
@@ -91,12 +182,31 @@ test('splitSelectorFromArgs extracts selector prefix and trailing value', () =>
|
|
|
91
182
|
assert.deepEqual(split.rest, ['qa@example.com']);
|
|
92
183
|
});
|
|
93
184
|
|
|
185
|
+
test('splitSelectorFromArgs prefers trailing token for value when requested', () => {
|
|
186
|
+
const split = splitSelectorFromArgs(['label="Filter"', 'visible=true'], { preferTrailingValue: true });
|
|
187
|
+
assert.ok(split);
|
|
188
|
+
assert.equal(split.selectorExpression, 'label="Filter"');
|
|
189
|
+
assert.deepEqual(split.rest, ['visible=true']);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('splitSelectorFromArgs keeps full selector when trailing value preference is disabled', () => {
|
|
193
|
+
const split = splitSelectorFromArgs(['label="Filter"', 'visible=true']);
|
|
194
|
+
assert.ok(split);
|
|
195
|
+
assert.equal(split.selectorExpression, 'label="Filter" visible=true');
|
|
196
|
+
assert.deepEqual(split.rest, []);
|
|
197
|
+
});
|
|
198
|
+
|
|
94
199
|
test('parseSelectorChain rejects unknown keys and malformed quotes', () => {
|
|
95
200
|
assert.throws(() => parseSelectorChain('foo=bar'), /Unknown selector key/i);
|
|
96
201
|
assert.throws(() => parseSelectorChain('label="unclosed'), /Unclosed quote/i);
|
|
97
202
|
assert.throws(() => parseSelectorChain(''), /cannot be empty/i);
|
|
98
203
|
});
|
|
99
204
|
|
|
205
|
+
test('parseSelectorChain handles quoted values ending in escaped backslashes', () => {
|
|
206
|
+
const chain = parseSelectorChain('label="path\\\\" || id=auth_continue');
|
|
207
|
+
assert.equal(chain.selectors.length, 2);
|
|
208
|
+
});
|
|
209
|
+
|
|
100
210
|
test('isSelectorToken only accepts known keys for key=value tokens', () => {
|
|
101
211
|
assert.equal(isSelectorToken('id=foo'), true);
|
|
102
212
|
assert.equal(isSelectorToken('editable=true'), true);
|
|
@@ -126,3 +236,26 @@ test('buildSelectorChainForNode prefers id and adds editable for fill action', (
|
|
|
126
236
|
assert.ok(chain.some((entry) => entry.includes('id=')));
|
|
127
237
|
assert.ok(chain.some((entry) => entry.includes('editable=true')));
|
|
128
238
|
});
|
|
239
|
+
|
|
240
|
+
test('role selector normalization matches Android class names by leaf type', () => {
|
|
241
|
+
const androidNodes: SnapshotState['nodes'] = [
|
|
242
|
+
{
|
|
243
|
+
ref: 'a1',
|
|
244
|
+
index: 0,
|
|
245
|
+
type: 'android.widget.Button',
|
|
246
|
+
label: 'Continue',
|
|
247
|
+
identifier: 'auth_continue',
|
|
248
|
+
rect: { x: 0, y: 0, width: 120, height: 44 },
|
|
249
|
+
enabled: true,
|
|
250
|
+
hittable: true,
|
|
251
|
+
},
|
|
252
|
+
];
|
|
253
|
+
const chain = parseSelectorChain('role=button label="Continue"');
|
|
254
|
+
const resolved = resolveSelectorChain(androidNodes, chain, {
|
|
255
|
+
platform: 'android',
|
|
256
|
+
requireRect: true,
|
|
257
|
+
requireUnique: true,
|
|
258
|
+
});
|
|
259
|
+
assert.ok(resolved);
|
|
260
|
+
assert.equal(resolved.node.ref, 'a1');
|
|
261
|
+
});
|
|
@@ -234,6 +234,65 @@ test('replay without --update does not heal or rewrite', async () => {
|
|
|
234
234
|
assert.equal(fs.readFileSync(replayPath, 'utf8'), originalPayload);
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
+
test('replay --update skips malformed selector candidates and preserves replay error context', async () => {
|
|
238
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-malformed-candidate-'));
|
|
239
|
+
const sessionsDir = path.join(tempRoot, 'sessions');
|
|
240
|
+
const replayPath = path.join(tempRoot, 'replay.ad');
|
|
241
|
+
const sessionStore = new SessionStore(sessionsDir);
|
|
242
|
+
const sessionName = 'malformed-candidate-session';
|
|
243
|
+
sessionStore.set(sessionName, makeSession(sessionName));
|
|
244
|
+
|
|
245
|
+
writeReplayFile(replayPath, {
|
|
246
|
+
ts: Date.now(),
|
|
247
|
+
command: 'click',
|
|
248
|
+
positionals: ['id="old_continue" ||'],
|
|
249
|
+
flags: {},
|
|
250
|
+
result: {},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const dispatch = async (): Promise<Record<string, unknown> | void> => {
|
|
254
|
+
return {
|
|
255
|
+
nodes: [
|
|
256
|
+
{
|
|
257
|
+
index: 0,
|
|
258
|
+
type: 'XCUIElementTypeButton',
|
|
259
|
+
label: 'Continue',
|
|
260
|
+
identifier: 'auth_continue',
|
|
261
|
+
rect: { x: 10, y: 10, width: 100, height: 44 },
|
|
262
|
+
enabled: true,
|
|
263
|
+
hittable: true,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
truncated: false,
|
|
267
|
+
backend: 'xctest',
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const response = await handleSessionCommands({
|
|
272
|
+
req: {
|
|
273
|
+
token: 't',
|
|
274
|
+
session: sessionName,
|
|
275
|
+
command: 'replay',
|
|
276
|
+
positionals: [replayPath],
|
|
277
|
+
flags: { replayUpdate: true },
|
|
278
|
+
},
|
|
279
|
+
sessionName,
|
|
280
|
+
logPath: path.join(tempRoot, 'daemon.log'),
|
|
281
|
+
sessionStore,
|
|
282
|
+
invoke: async () => ({ ok: false, error: { code: 'COMMAND_FAILED', message: 'selector stale' } }),
|
|
283
|
+
dispatch,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
assert.ok(response);
|
|
287
|
+
assert.equal(response.ok, false);
|
|
288
|
+
if (!response.ok) {
|
|
289
|
+
assert.equal(response.error.code, 'COMMAND_FAILED');
|
|
290
|
+
assert.match(response.error.message, /Replay failed at step 1/);
|
|
291
|
+
assert.equal(response.error.details?.step, 1);
|
|
292
|
+
assert.equal(response.error.details?.action, 'click');
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
237
296
|
test('replay --update heals selector in is command', async () => {
|
|
238
297
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-is-'));
|
|
239
298
|
const sessionsDir = path.join(tempRoot, 'sessions');
|
|
@@ -306,6 +365,87 @@ test('replay --update heals selector in is command', async () => {
|
|
|
306
365
|
assert.ok(rewrittenSelector.includes('auth_continue'));
|
|
307
366
|
});
|
|
308
367
|
|
|
368
|
+
test('replay --update heals numeric get text drift when numeric candidate value is unique', async () => {
|
|
369
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-get-numeric-'));
|
|
370
|
+
const sessionsDir = path.join(tempRoot, 'sessions');
|
|
371
|
+
const replayPath = path.join(tempRoot, 'replay.ad');
|
|
372
|
+
const sessionStore = new SessionStore(sessionsDir);
|
|
373
|
+
const sessionName = 'heal-get-numeric-session';
|
|
374
|
+
sessionStore.set(sessionName, makeSession(sessionName));
|
|
375
|
+
|
|
376
|
+
writeReplayFile(replayPath, {
|
|
377
|
+
ts: Date.now(),
|
|
378
|
+
command: 'get',
|
|
379
|
+
positionals: ['text', 'role="statictext" label="2" || label="2"'],
|
|
380
|
+
flags: {},
|
|
381
|
+
result: {},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const invokeCalls: string[] = [];
|
|
385
|
+
const invoke = async (request: DaemonRequest): Promise<DaemonResponse> => {
|
|
386
|
+
if (request.command !== 'get') {
|
|
387
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: `unexpected command ${request.command}` } };
|
|
388
|
+
}
|
|
389
|
+
const selector = request.positionals?.[1] ?? '';
|
|
390
|
+
invokeCalls.push(selector);
|
|
391
|
+
if (selector.includes('label="2"')) {
|
|
392
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'selector stale' } };
|
|
393
|
+
}
|
|
394
|
+
if (selector.includes('label="20"')) {
|
|
395
|
+
return { ok: true, data: { text: '20' } };
|
|
396
|
+
}
|
|
397
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'unexpected selector' } };
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const dispatch = async (): Promise<Record<string, unknown> | void> => {
|
|
401
|
+
return {
|
|
402
|
+
nodes: [
|
|
403
|
+
{
|
|
404
|
+
index: 0,
|
|
405
|
+
type: 'XCUIElementTypeStaticText',
|
|
406
|
+
label: '20',
|
|
407
|
+
rect: { x: 0, y: 100, width: 100, height: 24 },
|
|
408
|
+
enabled: true,
|
|
409
|
+
hittable: true,
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
index: 1,
|
|
413
|
+
type: 'XCUIElementTypeStaticText',
|
|
414
|
+
label: 'Version: 0.84.0',
|
|
415
|
+
rect: { x: 0, y: 200, width: 220, height: 17 },
|
|
416
|
+
enabled: true,
|
|
417
|
+
hittable: true,
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
truncated: false,
|
|
421
|
+
backend: 'xctest',
|
|
422
|
+
};
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const response = await handleSessionCommands({
|
|
426
|
+
req: {
|
|
427
|
+
token: 't',
|
|
428
|
+
session: sessionName,
|
|
429
|
+
command: 'replay',
|
|
430
|
+
positionals: [replayPath],
|
|
431
|
+
flags: { replayUpdate: true },
|
|
432
|
+
},
|
|
433
|
+
sessionName,
|
|
434
|
+
logPath: path.join(tempRoot, 'daemon.log'),
|
|
435
|
+
sessionStore,
|
|
436
|
+
invoke,
|
|
437
|
+
dispatch,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
assert.ok(response);
|
|
441
|
+
assert.equal(response.ok, true, JSON.stringify(response));
|
|
442
|
+
if (response.ok) {
|
|
443
|
+
assert.equal(response.data?.healed, 1);
|
|
444
|
+
assert.equal(response.data?.replayed, 1);
|
|
445
|
+
}
|
|
446
|
+
assert.equal(invokeCalls.length, 2);
|
|
447
|
+
});
|
|
448
|
+
|
|
309
449
|
test('replay rejects legacy JSON payload files', async () => {
|
|
310
450
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-json-rejected-'));
|
|
311
451
|
const sessionsDir = path.join(tempRoot, 'sessions');
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
formatSelectorFailure,
|
|
13
13
|
parseSelectorChain,
|
|
14
14
|
resolveSelectorChain,
|
|
15
|
+
splitIsSelectorArgs,
|
|
15
16
|
splitSelectorFromArgs,
|
|
16
17
|
} from '../selectors.ts';
|
|
17
18
|
|
|
@@ -90,6 +91,7 @@ export async function handleInteractionCommands(params: {
|
|
|
90
91
|
platform: session.device.platform,
|
|
91
92
|
requireRect: true,
|
|
92
93
|
requireUnique: true,
|
|
94
|
+
disambiguateAmbiguous: true,
|
|
93
95
|
});
|
|
94
96
|
if (!resolved || !resolved.node.rect) {
|
|
95
97
|
return {
|
|
@@ -180,7 +182,7 @@ export async function handleInteractionCommands(params: {
|
|
|
180
182
|
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
|
|
181
183
|
};
|
|
182
184
|
}
|
|
183
|
-
const selectorArgs = splitSelectorFromArgs(req.positionals ?? []);
|
|
185
|
+
const selectorArgs = splitSelectorFromArgs(req.positionals ?? [], { preferTrailingValue: true });
|
|
184
186
|
if (selectorArgs) {
|
|
185
187
|
if (selectorArgs.rest.length === 0) {
|
|
186
188
|
return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' } };
|
|
@@ -197,6 +199,7 @@ export async function handleInteractionCommands(params: {
|
|
|
197
199
|
platform: session.device.platform,
|
|
198
200
|
requireRect: true,
|
|
199
201
|
requireUnique: true,
|
|
202
|
+
disambiguateAmbiguous: true,
|
|
200
203
|
});
|
|
201
204
|
if (!resolved || !resolved.node.rect) {
|
|
202
205
|
return {
|
|
@@ -307,6 +310,7 @@ export async function handleInteractionCommands(params: {
|
|
|
307
310
|
platform: session.device.platform,
|
|
308
311
|
requireRect: false,
|
|
309
312
|
requireUnique: true,
|
|
313
|
+
disambiguateAmbiguous: sub === 'text',
|
|
310
314
|
});
|
|
311
315
|
if (!resolved) {
|
|
312
316
|
return {
|
|
@@ -367,8 +371,7 @@ export async function handleInteractionCommands(params: {
|
|
|
367
371
|
error: { code: 'UNSUPPORTED_OPERATION', message: 'is is not supported on this device' },
|
|
368
372
|
};
|
|
369
373
|
}
|
|
370
|
-
const
|
|
371
|
-
const split = splitSelectorFromArgs(selectorArgs);
|
|
374
|
+
const { split } = splitIsSelectorArgs(req.positionals);
|
|
372
375
|
if (!split) {
|
|
373
376
|
return {
|
|
374
377
|
ok: false,
|
|
@@ -10,8 +10,14 @@ import { ensureDeviceReady } from '../device-ready.ts';
|
|
|
10
10
|
import { resolveIosAppStateFromSnapshots } from '../app-state.ts';
|
|
11
11
|
import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
|
|
12
12
|
import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
|
|
13
|
-
import { pruneGroupNodes } from '../snapshot-processing.ts';
|
|
14
|
-
import {
|
|
13
|
+
import { extractNodeText, normalizeType, pruneGroupNodes } from '../snapshot-processing.ts';
|
|
14
|
+
import {
|
|
15
|
+
buildSelectorChainForNode,
|
|
16
|
+
resolveSelectorChain,
|
|
17
|
+
splitIsSelectorArgs,
|
|
18
|
+
splitSelectorFromArgs,
|
|
19
|
+
tryParseSelectorChain,
|
|
20
|
+
} from '../selectors.ts';
|
|
15
21
|
import { inferFillText, uniqueStrings } from '../action-utils.ts';
|
|
16
22
|
|
|
17
23
|
type ReinstallOps = {
|
|
@@ -511,14 +517,20 @@ async function healReplayAction(params: {
|
|
|
511
517
|
const session = sessionStore.get(sessionName);
|
|
512
518
|
if (!session) return null;
|
|
513
519
|
const requiresRect = action.command === 'click' || action.command === 'fill';
|
|
520
|
+
const allowDisambiguation =
|
|
521
|
+
action.command === 'click' ||
|
|
522
|
+
action.command === 'fill' ||
|
|
523
|
+
(action.command === 'get' && action.positionals?.[0] === 'text');
|
|
514
524
|
const snapshot = await captureSnapshotForReplay(session, action, logPath, requiresRect, dispatch, sessionStore);
|
|
515
525
|
const selectorCandidates = collectReplaySelectorCandidates(action);
|
|
516
526
|
for (const candidate of selectorCandidates) {
|
|
517
|
-
const chain =
|
|
527
|
+
const chain = tryParseSelectorChain(candidate);
|
|
528
|
+
if (!chain) continue;
|
|
518
529
|
const resolved = resolveSelectorChain(snapshot.nodes, chain, {
|
|
519
530
|
platform: session.device.platform,
|
|
520
531
|
requireRect: requiresRect,
|
|
521
532
|
requireUnique: true,
|
|
533
|
+
disambiguateAmbiguous: allowDisambiguation,
|
|
522
534
|
});
|
|
523
535
|
if (!resolved) continue;
|
|
524
536
|
const selectorChain = buildSelectorChainForNode(resolved.node, session.device.platform, {
|
|
@@ -548,9 +560,8 @@ async function healReplayAction(params: {
|
|
|
548
560
|
};
|
|
549
561
|
}
|
|
550
562
|
if (action.command === 'is') {
|
|
551
|
-
const predicate = action.positionals
|
|
563
|
+
const { predicate, split } = splitIsSelectorArgs(action.positionals);
|
|
552
564
|
if (!predicate) continue;
|
|
553
|
-
const split = splitSelectorFromArgs(action.positionals.slice(1));
|
|
554
565
|
const expectedText = split?.rest.join(' ').trim() ?? '';
|
|
555
566
|
const nextPositionals = [predicate, selectorExpression];
|
|
556
567
|
if (predicate === 'text' && expectedText.length > 0) {
|
|
@@ -573,6 +584,10 @@ async function healReplayAction(params: {
|
|
|
573
584
|
};
|
|
574
585
|
}
|
|
575
586
|
}
|
|
587
|
+
const numericDriftHeal = healNumericGetTextDrift(action, snapshot, session);
|
|
588
|
+
if (numericDriftHeal) {
|
|
589
|
+
return numericDriftHeal;
|
|
590
|
+
}
|
|
576
591
|
return null;
|
|
577
592
|
}
|
|
578
593
|
|
|
@@ -641,7 +656,7 @@ function collectReplaySelectorCandidates(action: SessionAction): string[] {
|
|
|
641
656
|
}
|
|
642
657
|
}
|
|
643
658
|
if (action.command === 'is') {
|
|
644
|
-
const split =
|
|
659
|
+
const { split } = splitIsSelectorArgs(action.positionals);
|
|
645
660
|
if (split) {
|
|
646
661
|
result.push(split.selectorExpression);
|
|
647
662
|
}
|
|
@@ -690,6 +705,56 @@ function parseSelectorWaitPositionals(positionals: string[]): {
|
|
|
690
705
|
};
|
|
691
706
|
}
|
|
692
707
|
|
|
708
|
+
function healNumericGetTextDrift(
|
|
709
|
+
action: SessionAction,
|
|
710
|
+
snapshot: SnapshotState,
|
|
711
|
+
session: SessionState,
|
|
712
|
+
): SessionAction | null {
|
|
713
|
+
if (action.command !== 'get') return null;
|
|
714
|
+
if (action.positionals?.[0] !== 'text') return null;
|
|
715
|
+
const selectorExpression = action.positionals?.[1];
|
|
716
|
+
if (!selectorExpression) return null;
|
|
717
|
+
const chain = tryParseSelectorChain(selectorExpression);
|
|
718
|
+
if (!chain) return null;
|
|
719
|
+
|
|
720
|
+
const roleFilters = new Set<string>();
|
|
721
|
+
let hasNumericTerm = false;
|
|
722
|
+
for (const selector of chain.selectors) {
|
|
723
|
+
for (const term of selector.terms) {
|
|
724
|
+
if (term.key === 'role' && typeof term.value === 'string') {
|
|
725
|
+
roleFilters.add(normalizeType(term.value));
|
|
726
|
+
}
|
|
727
|
+
if (
|
|
728
|
+
(term.key === 'text' || term.key === 'label' || term.key === 'value') &&
|
|
729
|
+
typeof term.value === 'string' &&
|
|
730
|
+
/^\d+$/.test(term.value.trim())
|
|
731
|
+
) {
|
|
732
|
+
hasNumericTerm = true;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (!hasNumericTerm) return null;
|
|
737
|
+
|
|
738
|
+
const numericNodes = snapshot.nodes.filter((node) => {
|
|
739
|
+
const text = extractNodeText(node).trim();
|
|
740
|
+
if (!/^\d+$/.test(text)) return false;
|
|
741
|
+
if (roleFilters.size === 0) return true;
|
|
742
|
+
return roleFilters.has(normalizeType(node.type ?? ''));
|
|
743
|
+
});
|
|
744
|
+
if (numericNodes.length === 0) return null;
|
|
745
|
+
const numericValues = uniqueStrings(numericNodes.map((node) => extractNodeText(node).trim()));
|
|
746
|
+
if (numericValues.length !== 1) return null;
|
|
747
|
+
|
|
748
|
+
const targetNode = numericNodes[0];
|
|
749
|
+
if (!targetNode) return null;
|
|
750
|
+
const selectorChain = buildSelectorChainForNode(targetNode, session.device.platform, { action: 'get' });
|
|
751
|
+
if (selectorChain.length === 0) return null;
|
|
752
|
+
return {
|
|
753
|
+
...action,
|
|
754
|
+
positionals: ['text', selectorChain.join(' || ')],
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
693
758
|
function parseReplayScript(script: string): SessionAction[] {
|
|
694
759
|
const actions: SessionAction[] = [];
|
|
695
760
|
const lines = script.split(/\r?\n/);
|