Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Minimal operating guide for AI coding agents in this repo.
- Put command logic in handler modules:
- session/apps/appstate/open/close/replay: `src/daemon/handlers/session.ts`
- click/fill/get/is: `src/daemon/handlers/interaction.ts`
- snapshot/wait/alert/settings: `src/daemon/handlers/snapshot.ts`
- snapshot/diff/wait/alert/settings: `src/daemon/handlers/snapshot.ts`
- find: `src/daemon/handlers/find.ts`
- record/trace: `src/daemon/handlers/record-trace.ts`
- Generic passthrough (press/scroll/type) is daemon fallback only after handlers return null.
Expand Down
1 change: 1 addition & 0 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
click: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
close: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
fill: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
diff: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
find: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
focus: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
get: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
Expand Down
111 changes: 111 additions & 0 deletions src/daemon/__tests__/snapshot-diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildSnapshotDiff, snapshotNodeToComparableLine } from '../snapshot-diff.ts';
import { attachRefs, type RawSnapshotNode } from '../../utils/snapshot.ts';

function nodes(raw: RawSnapshotNode[]) {
return attachRefs(raw);
}

test('snapshotNodeToComparableLine ignores volatile fields', () => {
const [node] = nodes([{
index: 0,
type: 'XCUIElementTypeTextField',
label: 'Email',
value: 'test@example.com',
identifier: 'email-input',
depth: 1,
rect: { x: 10, y: 20, width: 100, height: 20 },
}]);
assert.equal(
snapshotNodeToComparableLine(node),
' textfield label="Email" value="test@example.com" id="email-input"',
);
});

test('buildSnapshotDiff returns unchanged lines when snapshots match', () => {
const previous = nodes([
{ index: 0, type: 'button', label: 'Submit', depth: 0 },
{ index: 1, type: 'text', label: 'Create account', depth: 1 },
]);
const current = nodes([
{ index: 0, type: 'button', label: 'Submit', depth: 0 },
{ index: 1, type: 'text', label: 'Create account', depth: 1 },
]);
const diff = buildSnapshotDiff(previous, current);
assert.deepEqual(diff.summary, { additions: 0, removals: 0, unchanged: 2 });
assert.equal(diff.lines.length, 2);
assert.equal(diff.lines[0]?.kind, 'unchanged');
assert.equal(diff.lines[1]?.kind, 'unchanged');
});

test('buildSnapshotDiff reports removals and additions for value changes', () => {
const previous = nodes([
{ index: 0, type: 'textfield', label: 'Email', value: '', depth: 0 },
{ index: 1, type: 'button', label: 'Submit', depth: 0, enabled: true },
]);
const current = nodes([
{ index: 0, type: 'textfield', label: 'Email', value: 'test@example.com', depth: 0 },
{ index: 1, type: 'button', label: 'Submit', depth: 0, enabled: false },
]);
const diff = buildSnapshotDiff(previous, current);
assert.deepEqual(diff.summary, { additions: 2, removals: 2, unchanged: 0 });
assert.equal(diff.lines.length, 4);
const removed = diff.lines.filter((line) => line.kind === 'removed');
const added = diff.lines.filter((line) => line.kind === 'added');
assert.equal(removed.length, 2);
assert.equal(added.length, 2);
});

test('buildSnapshotDiff keeps stable order with unchanged context', () => {
const previous = nodes([
{ index: 0, type: 'heading', label: 'Sign Up', depth: 0 },
{ index: 1, type: 'text', label: 'Create account', depth: 0 },
{ index: 2, type: 'button', label: 'Submit', depth: 0, enabled: true },
]);
const current = nodes([
{ index: 0, type: 'heading', label: 'Sign Up', depth: 0 },
{ index: 1, type: 'text', label: 'Create account', depth: 0 },
{ index: 2, type: 'status', label: 'Sending...', depth: 0 },
{ index: 3, type: 'button', label: 'Submit', depth: 0, enabled: false },
]);
const diff = buildSnapshotDiff(previous, current);
assert.deepEqual(diff.summary, { additions: 2, removals: 1, unchanged: 2 });
const kinds = diff.lines.map((line) => line.kind);
assert.equal(kinds[0], 'unchanged');
assert.equal(kinds[1], 'unchanged');
assert.equal(diff.lines.filter((line) => line.kind === 'added').length, 2);
assert.equal(diff.lines.filter((line) => line.kind === 'removed').length, 1);
});

test('buildSnapshotDiff renders snapshot-style lines with refs and mapped roles', () => {
const previous = nodes([
{ index: 0, type: 'XCUIElementTypeOther', label: '67', depth: 1 },
{ index: 1, type: 'XCUIElementTypeStaticText', label: '67', depth: 2 },
{ index: 2, type: 'XCUIElementTypeButton', label: 'Increment', depth: 1 },
]);
const current = nodes([
{ index: 0, type: 'XCUIElementTypeOther', label: '134', depth: 1 },
{ index: 1, type: 'XCUIElementTypeStaticText', label: '134', depth: 2 },
{ index: 2, type: 'XCUIElementTypeButton', label: 'Increment', depth: 1 },
]);
const diff = buildSnapshotDiff(previous, current);
assert.equal(diff.lines.find((line) => line.kind === 'removed')?.text, ' @e1 [other] "67"');
assert.equal(diff.lines.find((line) => line.kind === 'added')?.text, ' @e1 [other] "134"');
assert.ok(diff.lines.some((line) => line.text === ' @e2 [text] "134"'));
});

test('buildSnapshotDiff uses linear fallback for very large snapshots', () => {
const previousRaw: RawSnapshotNode[] = [];
const currentRaw: RawSnapshotNode[] = [];
for (let index = 0; index < 2_100; index += 1) {
previousRaw.push({ index, type: 'text', label: `row-${index}`, depth: 0 });
currentRaw.push({ index, type: 'text', label: `row-${index}`, depth: 0 });
}
// Change one line so we still exercise add/remove behavior while crossing fallback threshold.
currentRaw[1_050] = { index: 1_050, type: 'text', label: 'row-1050-updated', depth: 0 };
const diff = buildSnapshotDiff(nodes(previousRaw), nodes(currentRaw));
assert.equal(diff.summary.additions, 1);
assert.equal(diff.summary.removals, 1);
assert.equal(diff.summary.unchanged, 2_099);
});
65 changes: 64 additions & 1 deletion src/daemon/handlers/__tests__/snapshot-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { handleSnapshotCommands } from '../snapshot.ts';
import { buildDiffSnapshotResponse, handleSnapshotCommands } from '../snapshot.ts';
import type { SnapshotState } from '../../../utils/snapshot.ts';
import { SessionStore } from '../../session-store.ts';
import type { SessionState } from '../../types.ts';

Expand Down Expand Up @@ -114,3 +115,65 @@ test('settings usage hint documents canonical faceid states', async () => {
assert.doesNotMatch(response.error.message, /validate\|unvalidate/);
}
});

test('diff rejects unsupported kind', async () => {
const sessionStore = makeSessionStore();
const response = await handleSnapshotCommands({
req: {
token: 't',
session: 'default',
command: 'diff',
positionals: ['screenshot'],
flags: {},
},
sessionName: 'default',
logPath: '/tmp/daemon.log',
sessionStore,
});

assert.ok(response);
assert.equal(response?.ok, false);
if (response && !response.ok) {
assert.equal(response.error.code, 'INVALID_ARGS');
assert.match(response.error.message, /supports only: snapshot/i);
}
});

test('buildDiffSnapshotResponse initializes baseline when previous snapshot is missing', () => {
const current: SnapshotState = {
nodes: [{ index: 0, ref: 'e1', label: 'Sign Up', type: 'heading', depth: 0 }],
createdAt: Date.now(),
backend: 'xctest',
};
const data = buildDiffSnapshotResponse(undefined, current);
assert.equal(data.baselineInitialized, true);
assert.deepEqual(data.summary, { additions: 0, removals: 0, unchanged: 1 });
assert.deepEqual(data.lines, []);
});

test('buildDiffSnapshotResponse returns additions/removals on changed snapshot', () => {
const previous: SnapshotState = {
nodes: [
{ index: 0, ref: 'e1', label: 'Sign Up', type: 'heading', depth: 0 },
{ index: 1, ref: 'e2', label: 'Submit', type: 'button', depth: 0, enabled: true },
],
createdAt: Date.now() - 100,
backend: 'xctest',
};
const current: SnapshotState = {
nodes: [
{ index: 0, ref: 'e1', label: 'Sign Up', type: 'heading', depth: 0 },
{ index: 1, ref: 'e2', label: 'Submit', type: 'button', depth: 0, enabled: false },
{ index: 2, ref: 'e3', label: 'Sending...', type: 'status', depth: 0 },
],
createdAt: Date.now(),
backend: 'xctest',
};
const data = buildDiffSnapshotResponse(previous, current);
assert.equal(data.baselineInitialized, false);
assert.equal(data.summary.additions, 2);
assert.equal(data.summary.removals, 1);
assert.equal(data.summary.unchanged, 1);
assert.ok(data.lines.some((line) => line.kind === 'added'));
assert.ok(data.lines.some((line) => line.kind === 'removed'));
});
Loading