agent-device 0.1.0

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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/bin/agent-device.mjs +14 -0
  4. package/bin/axsnapshot +0 -0
  5. package/dist/src/861.js +1 -0
  6. package/dist/src/bin.js +50 -0
  7. package/dist/src/daemon.js +5 -0
  8. package/ios-runner/AXSnapshot/Package.swift +18 -0
  9. package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +167 -0
  10. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.swift +17 -0
  11. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AccentColor.colorset/Contents.json +11 -0
  12. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/Contents.json +36 -0
  13. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/logo.jpg +0 -0
  14. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Contents.json +6 -0
  15. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Logo.imageset/Contents.json +21 -0
  16. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Logo.imageset/logo.jpg +0 -0
  17. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/PoweredBy.imageset/Contents.json +21 -0
  18. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/PoweredBy.imageset/powered-by.png +0 -0
  19. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/ContentView.swift +34 -0
  20. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +461 -0
  21. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  22. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +102 -0
  23. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +696 -0
  24. package/ios-runner/README.md +11 -0
  25. package/package.json +66 -0
  26. package/src/bin.ts +3 -0
  27. package/src/cli.ts +160 -0
  28. package/src/core/dispatch.ts +259 -0
  29. package/src/daemon-client.ts +166 -0
  30. package/src/daemon.ts +842 -0
  31. package/src/platforms/android/devices.ts +59 -0
  32. package/src/platforms/android/index.ts +442 -0
  33. package/src/platforms/ios/ax-snapshot.ts +154 -0
  34. package/src/platforms/ios/devices.ts +65 -0
  35. package/src/platforms/ios/index.ts +218 -0
  36. package/src/platforms/ios/runner-client.ts +534 -0
  37. package/src/utils/args.ts +175 -0
  38. package/src/utils/device.ts +84 -0
  39. package/src/utils/errors.ts +35 -0
  40. package/src/utils/exec.ts +229 -0
  41. package/src/utils/interactive.ts +4 -0
  42. package/src/utils/interactors.ts +72 -0
  43. package/src/utils/output.ts +146 -0
  44. package/src/utils/snapshot.ts +63 -0
@@ -0,0 +1,84 @@
1
+ import { AppError } from './errors.ts';
2
+ import { isInteractive } from './interactive.ts';
3
+ import { isCancel, select } from '@clack/prompts';
4
+
5
+ export type Platform = 'ios' | 'android';
6
+ export type DeviceKind = 'simulator' | 'emulator' | 'device';
7
+
8
+ export type DeviceInfo = {
9
+ platform: Platform;
10
+ id: string;
11
+ name: string;
12
+ kind: DeviceKind;
13
+ booted?: boolean;
14
+ };
15
+
16
+ export type DeviceSelector = {
17
+ platform?: Platform;
18
+ deviceName?: string;
19
+ udid?: string;
20
+ serial?: string;
21
+ };
22
+
23
+ export async function selectDevice(
24
+ devices: DeviceInfo[],
25
+ selector: DeviceSelector,
26
+ ): Promise<DeviceInfo> {
27
+ let candidates = devices;
28
+ const normalize = (value: string): string =>
29
+ value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
30
+
31
+ if (selector.platform) {
32
+ candidates = candidates.filter((d) => d.platform === selector.platform);
33
+ }
34
+
35
+ if (selector.udid) {
36
+ const match = candidates.find((d) => d.id === selector.udid && d.platform === 'ios');
37
+ if (!match) throw new AppError('DEVICE_NOT_FOUND', `No iOS device with UDID ${selector.udid}`);
38
+ return match;
39
+ }
40
+
41
+ if (selector.serial) {
42
+ const match = candidates.find((d) => d.id === selector.serial && d.platform === 'android');
43
+ if (!match)
44
+ throw new AppError('DEVICE_NOT_FOUND', `No Android device with serial ${selector.serial}`);
45
+ return match;
46
+ }
47
+
48
+ if (selector.deviceName) {
49
+ const target = normalize(selector.deviceName);
50
+ const match = candidates.find((d) => normalize(d.name) === target);
51
+ if (!match) {
52
+ throw new AppError('DEVICE_NOT_FOUND', `No device named ${selector.deviceName}`);
53
+ }
54
+ return match;
55
+ }
56
+
57
+ if (candidates.length === 1) return candidates[0];
58
+
59
+ if (candidates.length === 0) {
60
+ throw new AppError('DEVICE_NOT_FOUND', 'No devices found', { selector });
61
+ }
62
+
63
+ const booted = candidates.filter((d) => d.booted);
64
+ if (booted.length === 1) return booted[0];
65
+
66
+ if (isInteractive()) {
67
+ const choice = await select({
68
+ message: 'Multiple devices available. Choose a device to continue:',
69
+ options: (booted.length > 0 ? booted : candidates).map((device) => ({
70
+ label: `${device.name} (${device.platform}${device.kind ? `, ${device.kind}` : ''}${device.booted ? ', booted' : ''})`,
71
+ value: device.id,
72
+ })),
73
+ });
74
+ if (isCancel(choice)) {
75
+ throw new AppError('INVALID_ARGS', 'Device selection cancelled');
76
+ }
77
+ if (choice) {
78
+ const match = candidates.find((d) => d.id === choice);
79
+ if (match) return match;
80
+ }
81
+ }
82
+
83
+ return booted[0] ?? candidates[0];
84
+ }
@@ -0,0 +1,35 @@
1
+ export type ErrorCode =
2
+ | 'INVALID_ARGS'
3
+ | 'DEVICE_NOT_FOUND'
4
+ | 'TOOL_MISSING'
5
+ | 'APP_NOT_INSTALLED'
6
+ | 'UNSUPPORTED_PLATFORM'
7
+ | 'UNSUPPORTED_OPERATION'
8
+ | 'COMMAND_FAILED'
9
+ | 'UNKNOWN';
10
+
11
+ export class AppError extends Error {
12
+ code: ErrorCode;
13
+ details?: Record<string, unknown>;
14
+ cause?: unknown;
15
+
16
+ constructor(
17
+ code: ErrorCode,
18
+ message: string,
19
+ details?: Record<string, unknown>,
20
+ cause?: unknown,
21
+ ) {
22
+ super(message);
23
+ this.code = code;
24
+ this.details = details;
25
+ this.cause = cause;
26
+ }
27
+ }
28
+
29
+ export function asAppError(err: unknown): AppError {
30
+ if (err instanceof AppError) return err;
31
+ if (err instanceof Error) {
32
+ return new AppError('UNKNOWN', err.message, undefined, err);
33
+ }
34
+ return new AppError('UNKNOWN', 'Unknown error', { err });
35
+ }
@@ -0,0 +1,229 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { AppError } from './errors.ts';
3
+
4
+ export type ExecResult = {
5
+ stdout: string;
6
+ stderr: string;
7
+ exitCode: number;
8
+ stdoutBuffer?: Buffer;
9
+ };
10
+
11
+ export type ExecOptions = {
12
+ cwd?: string;
13
+ env?: NodeJS.ProcessEnv;
14
+ allowFailure?: boolean;
15
+ binaryStdout?: boolean;
16
+ };
17
+
18
+ export type ExecStreamOptions = ExecOptions & {
19
+ onStdoutChunk?: (chunk: string) => void;
20
+ onStderrChunk?: (chunk: string) => void;
21
+ };
22
+
23
+ export async function runCmd(
24
+ cmd: string,
25
+ args: string[],
26
+ options: ExecOptions = {},
27
+ ): Promise<ExecResult> {
28
+ return new Promise((resolve, reject) => {
29
+ const child = spawn(cmd, args, {
30
+ cwd: options.cwd,
31
+ env: options.env,
32
+ stdio: ['ignore', 'pipe', 'pipe'],
33
+ });
34
+
35
+ let stdout = '';
36
+ let stdoutBuffer: Buffer | undefined = options.binaryStdout ? Buffer.alloc(0) : undefined;
37
+ let stderr = '';
38
+
39
+ if (!options.binaryStdout) child.stdout.setEncoding('utf8');
40
+ child.stderr.setEncoding('utf8');
41
+
42
+ child.stdout.on('data', (chunk) => {
43
+ if (options.binaryStdout) {
44
+ stdoutBuffer = Buffer.concat([
45
+ stdoutBuffer ?? Buffer.alloc(0),
46
+ Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
47
+ ]);
48
+ } else {
49
+ stdout += chunk;
50
+ }
51
+ });
52
+
53
+ child.stderr.on('data', (chunk) => {
54
+ stderr += chunk;
55
+ });
56
+
57
+ child.on('error', (err) => {
58
+ const code = (err as NodeJS.ErrnoException).code;
59
+ if (code === 'ENOENT') {
60
+ reject(new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, err));
61
+ return;
62
+ }
63
+ reject(new AppError('COMMAND_FAILED', `Failed to run ${cmd}`, { cmd, args }, err));
64
+ });
65
+
66
+ child.on('close', (code) => {
67
+ const exitCode = code ?? 1;
68
+ if (exitCode !== 0 && !options.allowFailure) {
69
+ reject(
70
+ new AppError('COMMAND_FAILED', `${cmd} exited with code ${exitCode}`, {
71
+ cmd,
72
+ args,
73
+ stdout,
74
+ stderr,
75
+ exitCode,
76
+ }),
77
+ );
78
+ return;
79
+ }
80
+ resolve({ stdout, stderr, exitCode, stdoutBuffer });
81
+ });
82
+ });
83
+ }
84
+
85
+ export async function whichCmd(cmd: string): Promise<boolean> {
86
+ try {
87
+ const { shell, args } = resolveWhichArgs(cmd);
88
+ const result = await runCmd(shell, args, { allowFailure: true });
89
+ return result.exitCode === 0 && result.stdout.trim().length > 0;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ export function runCmdSync(cmd: string, args: string[], options: ExecOptions = {}): ExecResult {
96
+ const result = spawnSync(cmd, args, {
97
+ cwd: options.cwd,
98
+ env: options.env,
99
+ stdio: ['ignore', 'pipe', 'pipe'],
100
+ encoding: options.binaryStdout ? undefined : 'utf8',
101
+ });
102
+
103
+ if (result.error) {
104
+ const code = (result.error as NodeJS.ErrnoException).code;
105
+ if (code === 'ENOENT') {
106
+ throw new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, result.error);
107
+ }
108
+ throw new AppError('COMMAND_FAILED', `Failed to run ${cmd}`, { cmd, args }, result.error);
109
+ }
110
+
111
+ const stdoutBuffer = options.binaryStdout
112
+ ? Buffer.isBuffer(result.stdout)
113
+ ? result.stdout
114
+ : Buffer.from(result.stdout ?? '')
115
+ : undefined;
116
+ const stdout = options.binaryStdout
117
+ ? ''
118
+ : typeof result.stdout === 'string'
119
+ ? result.stdout
120
+ : (result.stdout ?? '').toString();
121
+ const stderr =
122
+ typeof result.stderr === 'string' ? result.stderr : (result.stderr ?? '').toString();
123
+ const exitCode = result.status ?? 1;
124
+
125
+ if (exitCode !== 0 && !options.allowFailure) {
126
+ throw new AppError('COMMAND_FAILED', `${cmd} exited with code ${exitCode}`, {
127
+ cmd,
128
+ args,
129
+ stdout,
130
+ stderr,
131
+ exitCode,
132
+ });
133
+ }
134
+
135
+ return { stdout, stderr, exitCode, stdoutBuffer };
136
+ }
137
+
138
+ export function runCmdDetached(cmd: string, args: string[], options: ExecOptions = {}): void {
139
+ const child = spawn(cmd, args, {
140
+ cwd: options.cwd,
141
+ env: options.env,
142
+ stdio: 'ignore',
143
+ detached: true,
144
+ });
145
+ child.unref();
146
+ }
147
+
148
+ export async function runCmdStreaming(
149
+ cmd: string,
150
+ args: string[],
151
+ options: ExecStreamOptions = {},
152
+ ): Promise<ExecResult> {
153
+ return new Promise((resolve, reject) => {
154
+ const child = spawn(cmd, args, {
155
+ cwd: options.cwd,
156
+ env: options.env,
157
+ stdio: ['ignore', 'pipe', 'pipe'],
158
+ });
159
+
160
+ let stdout = '';
161
+ let stderr = '';
162
+ let stdoutBuffer: Buffer | undefined = options.binaryStdout ? Buffer.alloc(0) : undefined;
163
+
164
+ if (!options.binaryStdout) child.stdout.setEncoding('utf8');
165
+ child.stderr.setEncoding('utf8');
166
+
167
+ child.stdout.on('data', (chunk) => {
168
+ if (options.binaryStdout) {
169
+ stdoutBuffer = Buffer.concat([
170
+ stdoutBuffer ?? Buffer.alloc(0),
171
+ Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
172
+ ]);
173
+ return;
174
+ }
175
+ const text = String(chunk);
176
+ stdout += text;
177
+ options.onStdoutChunk?.(text);
178
+ });
179
+
180
+ child.stderr.on('data', (chunk) => {
181
+ const text = String(chunk);
182
+ stderr += text;
183
+ options.onStderrChunk?.(text);
184
+ });
185
+
186
+ child.on('error', (err) => {
187
+ const code = (err as NodeJS.ErrnoException).code;
188
+ if (code === 'ENOENT') {
189
+ reject(new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, err));
190
+ return;
191
+ }
192
+ reject(new AppError('COMMAND_FAILED', `Failed to run ${cmd}`, { cmd, args }, err));
193
+ });
194
+
195
+ child.on('close', (code) => {
196
+ const exitCode = code ?? 1;
197
+ if (exitCode !== 0 && !options.allowFailure) {
198
+ reject(
199
+ new AppError('COMMAND_FAILED', `${cmd} exited with code ${exitCode}`, {
200
+ cmd,
201
+ args,
202
+ stdout,
203
+ stderr,
204
+ exitCode,
205
+ }),
206
+ );
207
+ return;
208
+ }
209
+ resolve({ stdout, stderr, exitCode, stdoutBuffer });
210
+ });
211
+ });
212
+ }
213
+
214
+ export function whichCmdSync(cmd: string): boolean {
215
+ try {
216
+ const { shell, args } = resolveWhichArgs(cmd);
217
+ const result = runCmdSync(shell, args, { allowFailure: true });
218
+ return result.exitCode === 0 && result.stdout.trim().length > 0;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ function resolveWhichArgs(cmd: string): { shell: string; args: string[] } {
225
+ if (process.platform === 'win32') {
226
+ return { shell: 'cmd.exe', args: ['/c', 'where', cmd] };
227
+ }
228
+ return { shell: 'bash', args: ['-lc', `command -v ${cmd}`] };
229
+ }
@@ -0,0 +1,4 @@
1
+ export function isInteractive(): boolean {
2
+ if (process.env.CI) return false;
3
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
4
+ }
@@ -0,0 +1,72 @@
1
+ import { AppError } from './errors.ts';
2
+ import type { DeviceInfo } from './device.ts';
3
+ import {
4
+ closeAndroidApp,
5
+ fillAndroid,
6
+ focusAndroid,
7
+ longPressAndroid,
8
+ openAndroidApp,
9
+ pressAndroid,
10
+ scrollAndroid,
11
+ scrollIntoViewAndroid,
12
+ screenshotAndroid,
13
+ typeAndroid,
14
+ } from '../platforms/android/index.ts';
15
+ import {
16
+ closeIosApp,
17
+ fillIos,
18
+ focusIos,
19
+ longPressIos,
20
+ openIosApp,
21
+ pressIos,
22
+ scrollIos,
23
+ scrollIntoViewIos,
24
+ screenshotIos,
25
+ typeIos,
26
+ } from '../platforms/ios/index.ts';
27
+
28
+ export type Interactor = {
29
+ open(app: string): Promise<void>;
30
+ close(app: string): Promise<void>;
31
+ tap(x: number, y: number): Promise<void>;
32
+ longPress(x: number, y: number, durationMs?: number): Promise<void>;
33
+ focus(x: number, y: number): Promise<void>;
34
+ type(text: string): Promise<void>;
35
+ fill(x: number, y: number, text: string): Promise<void>;
36
+ scroll(direction: string, amount?: number): Promise<void>;
37
+ scrollIntoView(text: string): Promise<void>;
38
+ screenshot(outPath: string): Promise<void>;
39
+ };
40
+
41
+ export function getInteractor(device: DeviceInfo): Interactor {
42
+ switch (device.platform) {
43
+ case 'android':
44
+ return {
45
+ open: (app) => openAndroidApp(device, app),
46
+ close: (app) => closeAndroidApp(device, app),
47
+ tap: (x, y) => pressAndroid(device, x, y),
48
+ longPress: (x, y, durationMs) => longPressAndroid(device, x, y, durationMs),
49
+ focus: (x, y) => focusAndroid(device, x, y),
50
+ type: (text) => typeAndroid(device, text),
51
+ fill: (x, y, text) => fillAndroid(device, x, y, text),
52
+ scroll: (direction, amount) => scrollAndroid(device, direction, amount),
53
+ scrollIntoView: (text) => scrollIntoViewAndroid(device, text),
54
+ screenshot: (outPath) => screenshotAndroid(device, outPath),
55
+ };
56
+ case 'ios':
57
+ return {
58
+ open: (app) => openIosApp(device, app),
59
+ close: (app) => closeIosApp(device, app),
60
+ tap: (x, y) => pressIos(device, x, y),
61
+ longPress: (x, y, durationMs) => longPressIos(device, x, y, durationMs),
62
+ focus: (x, y) => focusIos(device, x, y),
63
+ type: (text) => typeIos(device, text),
64
+ fill: (x, y, text) => fillIos(device, x, y, text),
65
+ scroll: (direction, amount) => scrollIos(device, direction, amount),
66
+ scrollIntoView: (text) => scrollIntoViewIos(text),
67
+ screenshot: (outPath) => screenshotIos(device, outPath),
68
+ };
69
+ default:
70
+ throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${device.platform}`);
71
+ }
72
+ }
@@ -0,0 +1,146 @@
1
+ import { AppError } from './errors.ts';
2
+
3
+ export type JsonResult =
4
+ | { success: true; data?: Record<string, unknown> }
5
+ | { success: false; error: { code: string; message: string; details?: Record<string, unknown> } };
6
+
7
+ export function printJson(result: JsonResult): void {
8
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
9
+ }
10
+
11
+ export function printHumanError(err: AppError): void {
12
+ const details = err.details ? `\n${JSON.stringify(err.details, null, 2)}` : '';
13
+ process.stderr.write(`Error (${err.code}): ${err.message}${details}\n`);
14
+ }
15
+
16
+ type SnapshotRect = { x: number; y: number; width: number; height: number };
17
+ type SnapshotNode = {
18
+ ref?: string;
19
+ depth?: number;
20
+ type?: string;
21
+ label?: string;
22
+ value?: string;
23
+ identifier?: string;
24
+ rect?: SnapshotRect;
25
+ hittable?: boolean;
26
+ enabled?: boolean;
27
+ };
28
+
29
+ export function formatSnapshotText(
30
+ data: Record<string, unknown>,
31
+ options: { raw?: boolean } = {},
32
+ ): string {
33
+ const nodes = (data.nodes ?? []) as SnapshotNode[];
34
+ const truncated = Boolean(data.truncated);
35
+ const appName = typeof data.appName === 'string' ? data.appName : undefined;
36
+ const appBundleId = typeof data.appBundleId === 'string' ? data.appBundleId : undefined;
37
+ const meta: string[] = [];
38
+ if (appName) meta.push(`Page: ${appName}`);
39
+ if (appBundleId) meta.push(`App: ${appBundleId}`);
40
+ const header = `Snapshot: ${nodes.length} nodes${truncated ? ' (truncated)' : ''}`;
41
+ const prefix = meta.length > 0 ? `${meta.join('\n')}\n` : '';
42
+ if (!Array.isArray(nodes) || nodes.length === 0) {
43
+ return `${prefix}${header}\n`;
44
+ }
45
+ if (options.raw) {
46
+ const rawLines = nodes.map((node) => JSON.stringify(node));
47
+ return `${prefix}${header}\n${rawLines.join('\n')}\n`;
48
+ }
49
+ const hiddenGroupDepths: number[] = [];
50
+ const lines: string[] = [];
51
+ for (const node of nodes) {
52
+ const depth = node.depth ?? 0;
53
+ while (hiddenGroupDepths.length > 0 && depth <= hiddenGroupDepths[hiddenGroupDepths.length - 1]) {
54
+ hiddenGroupDepths.pop();
55
+ }
56
+ const label = node.label?.trim() || node.value?.trim() || node.identifier?.trim() || '';
57
+ const type = formatRole(node.type ?? 'Element');
58
+ const isHiddenGroup = type === 'group' && !label;
59
+ if (isHiddenGroup) {
60
+ hiddenGroupDepths.push(depth);
61
+ }
62
+ const adjustedDepth = isHiddenGroup
63
+ ? depth
64
+ : Math.max(0, depth - hiddenGroupDepths.length);
65
+ const indent = ' '.repeat(adjustedDepth);
66
+ const ref = node.ref ? `@${node.ref}` : '';
67
+ const flags = [
68
+ node.enabled === false ? 'disabled' : null,
69
+ ]
70
+ .filter(Boolean)
71
+ .join(', ');
72
+ const flagText = flags ? ` [${flags}]` : '';
73
+ const textPart = label ? ` "${label}"` : '';
74
+ if (isHiddenGroup) {
75
+ lines.push(`${indent}${ref} [${type}]${flagText}`.trimEnd());
76
+ continue;
77
+ }
78
+ lines.push(`${indent}${ref} [${type}]${textPart}${flagText}`.trimEnd());
79
+ }
80
+ return `${prefix}${header}\n${lines.join('\n')}\n`;
81
+ }
82
+
83
+ function formatRole(type: string): string {
84
+ let normalized = type.replace(/XCUIElementType/gi, '').toLowerCase();
85
+ if (normalized.startsWith("ax")) {
86
+ normalized = normalized.replace(/^ax/, "");
87
+ }
88
+ switch (normalized) {
89
+ case 'application':
90
+ return 'application';
91
+ case 'navigationbar':
92
+ return 'navigation-bar';
93
+ case 'tabbar':
94
+ return 'tab-bar';
95
+ case 'button':
96
+ return 'button';
97
+ case 'link':
98
+ return 'link';
99
+ case 'cell':
100
+ return 'cell';
101
+ case 'statictext':
102
+ return 'text';
103
+ case 'textfield':
104
+ return 'text-field';
105
+ case 'textview':
106
+ return 'text-view';
107
+ case 'switch':
108
+ return 'switch';
109
+ case 'slider':
110
+ return 'slider';
111
+ case 'image':
112
+ return 'image';
113
+ case 'table':
114
+ return 'list';
115
+ case 'collectionview':
116
+ return 'collection';
117
+ case 'searchfield':
118
+ return 'search';
119
+ case 'segmentedcontrol':
120
+ return 'segmented-control';
121
+ case 'group':
122
+ return 'group';
123
+ case 'window':
124
+ return 'window';
125
+ case 'statictext':
126
+ return 'text';
127
+ case 'textfield':
128
+ return 'text-field';
129
+ case 'textarea':
130
+ return 'text-view';
131
+ case 'checkbox':
132
+ return 'checkbox';
133
+ case 'radio':
134
+ return 'radio';
135
+ case 'menuitem':
136
+ return 'menu-item';
137
+ case 'toolbar':
138
+ return 'toolbar';
139
+ case 'scrollarea':
140
+ return 'scroll-area';
141
+ case 'table':
142
+ return 'table';
143
+ default:
144
+ return normalized || 'element';
145
+ }
146
+ }
@@ -0,0 +1,63 @@
1
+ export type Rect = {
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ };
7
+
8
+ export type SnapshotOptions = {
9
+ interactiveOnly?: boolean;
10
+ compact?: boolean;
11
+ depth?: number;
12
+ scope?: string;
13
+ raw?: boolean;
14
+ };
15
+
16
+ export type RawSnapshotNode = {
17
+ index: number;
18
+ type?: string;
19
+ label?: string;
20
+ value?: string;
21
+ identifier?: string;
22
+ rect?: Rect;
23
+ enabled?: boolean;
24
+ hittable?: boolean;
25
+ depth?: number;
26
+ parentIndex?: number;
27
+ };
28
+
29
+ export type SnapshotNode = RawSnapshotNode & {
30
+ ref: string;
31
+ };
32
+
33
+ export type SnapshotState = {
34
+ nodes: SnapshotNode[];
35
+ createdAt: number;
36
+ truncated?: boolean;
37
+ backend?: 'ax' | 'xctest' | 'android';
38
+ };
39
+
40
+ export function attachRefs(nodes: RawSnapshotNode[]): SnapshotNode[] {
41
+ return nodes.map((node, idx) => ({ ...node, ref: `e${idx + 1}` }));
42
+ }
43
+
44
+ export function normalizeRef(input: string): string | null {
45
+ const trimmed = input.trim();
46
+ if (trimmed.startsWith('@')) {
47
+ const ref = trimmed.slice(1);
48
+ return ref ? ref : null;
49
+ }
50
+ if (trimmed.startsWith('e')) return trimmed;
51
+ return null;
52
+ }
53
+
54
+ export function findNodeByRef(nodes: SnapshotNode[], ref: string): SnapshotNode | null {
55
+ return nodes.find((node) => node.ref === ref) ?? null;
56
+ }
57
+
58
+ export function centerOfRect(rect: Rect): { x: number; y: number } {
59
+ return {
60
+ x: Math.round(rect.x + rect.width / 2),
61
+ y: Math.round(rect.y + rect.height / 2),
62
+ };
63
+ }