Skip to content

Commit 09e7ef2

Browse files
committed
feat: for self-healing e2e tests; assertions
1 parent 9a3570f commit 09e7ef2

19 files changed

Lines changed: 1931 additions & 187 deletions

AGENTS.md

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ Minimal operating guide for AI coding agents in this repo.
1414
- Use daemon session flow for interactions (`open` before interactions, `close` after).
1515
- Do not remove shared snapshot/session model behavior without full migration.
1616
- If Swift runner code changes, run `pnpm build:xcuitest`.
17+
- Do not add command logic to `daemon.ts` — it is a thin router. Use handler modules.
18+
- Use `inferFillText` and `uniqueStrings` from `src/daemon/action-utils.ts`. Do not duplicate.
19+
- Use `evaluateIsPredicate` from `src/daemon/is-predicates.ts` for assertion logic. Do not inline.
1720

1821
## Architecture In One Screen
1922

@@ -24,14 +27,18 @@ Minimal operating guide for AI coding agents in this repo.
2427
2. Daemon client transport:
2528
- `src/daemon-client.ts`
2629
3. Daemon server bootstrap/router:
27-
- `src/daemon.ts`
30+
- `src/daemon.ts` — thin router only, delegates to handler modules
2831
4. Daemon command families:
2932
- session/apps/appstate/open/close/replay: `src/daemon/handlers/session.ts`
33+
- click/fill/get/is: `src/daemon/handlers/interaction.ts`
3034
- snapshot/wait/alert/settings: `src/daemon/handlers/snapshot.ts`
3135
- semantic find actions: `src/daemon/handlers/find.ts`
3236
- record/trace: `src/daemon/handlers/record-trace.ts`
3337
5. Daemon shared domain:
3438
- session state + logs: `src/daemon/session-store.ts`
39+
- selector DSL (parse, resolve, build): `src/daemon/selectors.ts`
40+
- `is` predicate evaluation: `src/daemon/is-predicates.ts`
41+
- shared action helpers (inferFillText, uniqueStrings): `src/daemon/action-utils.ts`
3542
- snapshot tree shaping + label resolution: `src/daemon/snapshot-processing.ts`
3643
- handler context helpers: `src/daemon/context.ts`, `src/daemon/device-ready.ts`, `src/daemon/app-state.ts`
3744
6. Platform dispatch/backends:
@@ -55,34 +62,42 @@ Do not read both iOS and Android paths unless issue is explicitly cross-platform
5562

5663
- `session list`, `devices`, `apps`, `appstate`, `open`, `close`, `replay`:
5764
- `src/daemon/handlers/session.ts`
65+
- `click`, `fill`, `get`, `is`:
66+
- `src/daemon/handlers/interaction.ts`
5867
- `snapshot`, `wait`, `alert`, `settings`:
5968
- `src/daemon/handlers/snapshot.ts`
6069
- `find ...`:
6170
- `src/daemon/handlers/find.ts`
6271
- `record start|stop`, `trace start|stop`:
6372
- `src/daemon/handlers/record-trace.ts`
64-
- `click`, `fill`, `get`, generic passthrough:
65-
- `src/daemon.ts` (remaining fallback logic)
73+
- Generic passthrough (press, scroll, type, etc.):
74+
- `src/daemon.ts` (fallback after all handlers return null)
6675

6776
## Capability Source Of Truth
6877

6978
- Command/device support must come from `src/core/capabilities.ts`.
7079
- Do not scatter new support checks across handlers.
7180

81+
## Selector System Rules
82+
83+
All interaction commands (`click`, `fill`, `get`, `is`) and `wait` accept selectors in addition to `@ref`.
84+
The selector pipeline is: **parse → resolve → act → record selectorChain → heal on replay**.
85+
86+
- Selector DSL lives in `src/daemon/selectors.ts`. Do not duplicate parsing/matching logic elsewhere.
87+
- `buildSelectorChainForNode` generates fallback chains stored in action results. Always call it after resolving a node for an interaction — it powers replay healing.
88+
- When adding a new interaction command that targets a UI element: support both `@ref` and selector input, record `selectorChain`, and update replay healing (`healReplayAction` + `collectReplaySelectorCandidates` in `session.ts`).
89+
- When adding a new selector key: update `SelectorKey` type, `ALL_KEYS`/`TEXT_KEYS`/`BOOLEAN_KEYS` sets, `matchesTerm`, and `isSelectorToken` — all in `selectors.ts`.
90+
- When adding a new `is` predicate: update `IsPredicate` type and `evaluateIsPredicate` in `is-predicates.ts`, not in the handler.
91+
- `daemon.ts` must stay a thin router. Do not add command logic there — use the appropriate handler module.
92+
7293
## Testing Strategy
7394

7495
### Test placement policy
7596

7697
- Unit tests are colocated with source files under `src/**`.
7798
- Use `__tests__` folders colocated with the related source folder.
7899
- The `test/**` tree is integration-only (including smoke integration tests).
79-
80-
### Unit tests (default for all refactors, colocated)
81-
82-
- `src/core/__tests__/capabilities.test.ts`
83-
- `src/daemon/handlers/__tests__/find.test.ts`
84-
- `src/daemon/__tests__/snapshot-processing.test.ts`
85-
- `src/daemon/__tests__/session-store.test.ts`
100+
- Example: tests for `src/daemon/selectors.ts` go in `src/daemon/__tests__/selectors.test.ts`.
86101

87102
Add/extend colocated unit tests in the same PR for touched module logic.
88103

src/cli.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ export async function runCli(argv: string[]): Promise<void> {
9393
return;
9494
}
9595
}
96+
if (command === 'is') {
97+
const predicate = (response.data as any)?.predicate ?? 'assertion';
98+
process.stdout.write(`Passed: is ${predicate}\n`);
99+
if (logTailStopper) logTailStopper();
100+
return;
101+
}
96102
if (command === 'click') {
97103
const ref = (response.data as any)?.ref ?? '';
98104
const x = (response.data as any)?.x;

src/core/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
2525
find: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2626
focus: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2727
get: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
28+
is: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2829
home: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2930
'long-press': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
3031
open: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },

src/core/dispatch.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type CommandFlags = {
3737
recordJson?: boolean;
3838
appsFilter?: 'launchable' | 'user-installed' | 'all';
3939
appsMetadata?: boolean;
40+
replayUpdate?: boolean;
4041
};
4142

4243
export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceInfo> {

src/daemon.ts

Lines changed: 10 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,15 @@ import { fileURLToPath } from 'node:url';
77
import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
88
import { isCommandSupportedOnDevice } from './core/capabilities.ts';
99
import { asAppError, AppError } from './utils/errors.ts';
10-
import { centerOfRect, findNodeByRef, normalizeRef } from './utils/snapshot.ts';
1110
import { stopIosRunnerSession } from './platforms/ios/runner-client.ts';
1211
import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
1312
import { SessionStore } from './daemon/session-store.ts';
14-
import { contextFromFlags as contextFromFlagsWithLog } from './daemon/context.ts';
13+
import { contextFromFlags as contextFromFlagsWithLog, type DaemonCommandContext } from './daemon/context.ts';
1514
import { handleSessionCommands } from './daemon/handlers/session.ts';
1615
import { handleSnapshotCommands } from './daemon/handlers/snapshot.ts';
1716
import { handleFindCommands } from './daemon/handlers/find.ts';
1817
import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts';
19-
import { findNodeByLabel, isFillableType, resolveRefLabel } from './daemon/snapshot-processing.ts';
18+
import { handleInteractionCommands } from './daemon/handlers/interaction.ts';
2019
import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
2120
import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
2221

@@ -33,19 +32,7 @@ function contextFromFlags(
3332
flags: CommandFlags | undefined,
3433
appBundleId?: string,
3534
traceLogPath?: string,
36-
): {
37-
appBundleId?: string;
38-
activity?: string;
39-
verbose?: boolean;
40-
logPath?: string;
41-
traceLogPath?: string;
42-
snapshotInteractiveOnly?: boolean;
43-
snapshotCompact?: boolean;
44-
snapshotDepth?: number;
45-
snapshotScope?: string;
46-
snapshotBackend?: 'ax' | 'xctest';
47-
snapshotRaw?: boolean;
48-
} {
35+
): DaemonCommandContext {
4936
return contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath);
5037
}
5138

@@ -94,139 +81,13 @@ async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
9481
});
9582
if (findResponse) return findResponse;
9683

97-
if (command === 'click') {
98-
const session = sessionStore.get(sessionName);
99-
if (!session?.snapshot) {
100-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
101-
}
102-
const refInput = req.positionals?.[0] ?? '';
103-
const ref = normalizeRef(refInput);
104-
if (!ref) {
105-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'click requires a ref like @e2' } };
106-
}
107-
let node = findNodeByRef(session.snapshot.nodes, ref);
108-
if (!node?.rect && req.positionals.length > 1) {
109-
const fallbackLabel = req.positionals.slice(1).join(' ').trim();
110-
if (fallbackLabel.length > 0) {
111-
node = findNodeByLabel(session.snapshot.nodes, fallbackLabel);
112-
}
113-
}
114-
if (!node?.rect) {
115-
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${refInput} not found or has no bounds` } };
116-
}
117-
const refLabel = resolveRefLabel(node, session.snapshot.nodes);
118-
const { x, y } = centerOfRect(node.rect);
119-
await dispatchCommand(session.device, 'press', [String(x), String(y)], req.flags?.out, {
120-
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
121-
});
122-
sessionStore.recordAction(session, {
123-
command,
124-
positionals: req.positionals ?? [],
125-
flags: req.flags ?? {},
126-
result: { ref, x, y, refLabel },
127-
});
128-
return { ok: true, data: { ref, x, y } };
129-
}
130-
131-
if (command === 'fill') {
132-
const session = sessionStore.get(sessionName);
133-
if (req.positionals?.[0]?.startsWith('@')) {
134-
if (!session?.snapshot) {
135-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
136-
}
137-
const ref = normalizeRef(req.positionals[0]);
138-
if (!ref) {
139-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires a ref like @e2' } };
140-
}
141-
const labelCandidate = req.positionals.length >= 3 ? req.positionals[1] : '';
142-
const text = req.positionals.length >= 3 ? req.positionals.slice(2).join(' ') : req.positionals.slice(1).join(' ');
143-
if (!text) {
144-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires text after ref' } };
145-
}
146-
let node = findNodeByRef(session.snapshot.nodes, ref);
147-
if (!node?.rect && labelCandidate) {
148-
node = findNodeByLabel(session.snapshot.nodes, labelCandidate);
149-
}
150-
if (!node?.rect) {
151-
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${req.positionals[0]} not found or has no bounds` } };
152-
}
153-
const nodeType = node.type ?? '';
154-
const fillWarning =
155-
nodeType && !isFillableType(nodeType, session.device.platform)
156-
? `fill target ${req.positionals[0]} resolved to "${nodeType}", attempting fill anyway.`
157-
: undefined;
158-
const refLabel = resolveRefLabel(node, session.snapshot.nodes);
159-
const { x, y } = centerOfRect(node.rect);
160-
const data = await dispatchCommand(
161-
session.device,
162-
'fill',
163-
[String(x), String(y), text],
164-
req.flags?.out,
165-
{
166-
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
167-
},
168-
);
169-
const resultPayload: Record<string, unknown> = {
170-
...(data ?? { ref, x, y }),
171-
};
172-
if (fillWarning) {
173-
resultPayload.warning = fillWarning;
174-
}
175-
sessionStore.recordAction(session, {
176-
command,
177-
positionals: req.positionals ?? [],
178-
flags: req.flags ?? {},
179-
result: { ...resultPayload, refLabel },
180-
});
181-
return { ok: true, data: resultPayload };
182-
}
183-
}
184-
185-
if (command === 'get') {
186-
const sub = req.positionals?.[0];
187-
const refInput = req.positionals?.[1];
188-
if (sub !== 'text' && sub !== 'attrs') {
189-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'get only supports text or attrs' } };
190-
}
191-
const session = sessionStore.get(sessionName);
192-
if (!session?.snapshot) {
193-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
194-
}
195-
const ref = normalizeRef(refInput ?? '');
196-
if (!ref) {
197-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'get text requires a ref like @e2' } };
198-
}
199-
let node = findNodeByRef(session.snapshot.nodes, ref);
200-
if (!node && req.positionals.length > 2) {
201-
const labelCandidate = req.positionals.slice(2).join(' ').trim();
202-
if (labelCandidate.length > 0) {
203-
node = findNodeByLabel(session.snapshot.nodes, labelCandidate);
204-
}
205-
}
206-
if (!node) {
207-
return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${refInput} not found` } };
208-
}
209-
if (sub === 'attrs') {
210-
sessionStore.recordAction(session, {
211-
command,
212-
positionals: req.positionals ?? [],
213-
flags: req.flags ?? {},
214-
result: { ref },
215-
});
216-
return { ok: true, data: { ref, node } };
217-
}
218-
const candidates = [node.label, node.value, node.identifier]
219-
.map((value) => (typeof value === 'string' ? value.trim() : ''))
220-
.filter((value) => value.length > 0);
221-
const text = candidates[0] ?? '';
222-
sessionStore.recordAction(session, {
223-
command,
224-
positionals: req.positionals ?? [],
225-
flags: req.flags ?? {},
226-
result: { ref, text, refLabel: text || undefined },
227-
});
228-
return { ok: true, data: { ref, text, node } };
229-
}
84+
const interactionResponse = await handleInteractionCommands({
85+
req,
86+
sessionName,
87+
sessionStore,
88+
contextFromFlags,
89+
});
90+
if (interactionResponse) return interactionResponse;
23091

23192

23293
const session = sessionStore.get(sessionName);
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { evaluateIsPredicate, isSupportedPredicate } from '../is-predicates.ts';
4+
5+
const baseNode = {
6+
ref: 'e1',
7+
index: 0,
8+
type: 'XCUIElementTypeTextField',
9+
label: 'Email',
10+
value: '',
11+
identifier: 'login_email',
12+
rect: { x: 0, y: 0, width: 100, height: 40 },
13+
enabled: true,
14+
hittable: true,
15+
};
16+
17+
test('isSupportedPredicate validates supported predicates', () => {
18+
assert.equal(isSupportedPredicate('visible'), true);
19+
assert.equal(isSupportedPredicate('text'), true);
20+
assert.equal(isSupportedPredicate('checked'), false);
21+
});
22+
23+
test('evaluateIsPredicate visible and hidden', () => {
24+
const visible = evaluateIsPredicate({
25+
predicate: 'visible',
26+
node: baseNode,
27+
platform: 'ios',
28+
});
29+
const hidden = evaluateIsPredicate({
30+
predicate: 'hidden',
31+
node: { ...baseNode, rect: { ...baseNode.rect, width: 0 }, hittable: false },
32+
platform: 'ios',
33+
});
34+
assert.equal(visible.pass, true);
35+
assert.equal(hidden.pass, true);
36+
});
37+
38+
test('evaluateIsPredicate editable and selected', () => {
39+
const editable = evaluateIsPredicate({
40+
predicate: 'editable',
41+
node: baseNode,
42+
platform: 'ios',
43+
});
44+
const selected = evaluateIsPredicate({
45+
predicate: 'selected',
46+
node: { ...baseNode, selected: true },
47+
platform: 'ios',
48+
});
49+
assert.equal(editable.pass, true);
50+
assert.equal(selected.pass, true);
51+
});
52+
53+
test('evaluateIsPredicate text uses equality', () => {
54+
const match = evaluateIsPredicate({
55+
predicate: 'text',
56+
node: baseNode,
57+
expectedText: 'Email',
58+
platform: 'ios',
59+
});
60+
const mismatch = evaluateIsPredicate({
61+
predicate: 'text',
62+
node: baseNode,
63+
expectedText: 'email',
64+
platform: 'ios',
65+
});
66+
assert.equal(match.pass, true);
67+
assert.equal(mismatch.pass, false);
68+
});

0 commit comments

Comments
 (0)