@limrun/api 0.19.2 → 0.20.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 (68) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/client.d.mts +1 -0
  3. package/client.d.mts.map +1 -1
  4. package/client.d.ts +1 -0
  5. package/client.d.ts.map +1 -1
  6. package/client.js +10 -2
  7. package/client.js.map +1 -1
  8. package/client.mjs +10 -2
  9. package/client.mjs.map +1 -1
  10. package/exec-client.d.mts +101 -0
  11. package/exec-client.d.mts.map +1 -0
  12. package/exec-client.d.ts +101 -0
  13. package/exec-client.d.ts.map +1 -0
  14. package/exec-client.js +265 -0
  15. package/exec-client.js.map +1 -0
  16. package/exec-client.mjs +259 -0
  17. package/exec-client.mjs.map +1 -0
  18. package/folder-sync.d.mts +16 -2
  19. package/folder-sync.d.mts.map +1 -1
  20. package/folder-sync.d.ts +16 -2
  21. package/folder-sync.d.ts.map +1 -1
  22. package/folder-sync.js +43 -14
  23. package/folder-sync.js.map +1 -1
  24. package/folder-sync.mjs +43 -13
  25. package/folder-sync.mjs.map +1 -1
  26. package/index.d.mts +2 -0
  27. package/index.d.mts.map +1 -1
  28. package/index.d.ts +2 -0
  29. package/index.d.ts.map +1 -1
  30. package/index.js +5 -1
  31. package/index.js.map +1 -1
  32. package/index.mjs +2 -0
  33. package/index.mjs.map +1 -1
  34. package/internal/parse.d.mts.map +1 -1
  35. package/internal/parse.d.ts.map +1 -1
  36. package/internal/parse.js +5 -0
  37. package/internal/parse.js.map +1 -1
  38. package/internal/parse.mjs +5 -0
  39. package/internal/parse.mjs.map +1 -1
  40. package/ios-client.d.mts +60 -1
  41. package/ios-client.d.mts.map +1 -1
  42. package/ios-client.d.ts +60 -1
  43. package/ios-client.d.ts.map +1 -1
  44. package/ios-client.js +131 -2
  45. package/ios-client.js.map +1 -1
  46. package/ios-client.mjs +129 -1
  47. package/ios-client.mjs.map +1 -1
  48. package/package.json +23 -1
  49. package/sandbox-client.d.mts +124 -0
  50. package/sandbox-client.d.mts.map +1 -0
  51. package/sandbox-client.d.ts +124 -0
  52. package/sandbox-client.d.ts.map +1 -0
  53. package/sandbox-client.js +149 -0
  54. package/sandbox-client.js.map +1 -0
  55. package/sandbox-client.mjs +146 -0
  56. package/sandbox-client.mjs.map +1 -0
  57. package/src/client.ts +10 -2
  58. package/src/exec-client.ts +333 -0
  59. package/src/folder-sync.ts +66 -18
  60. package/src/index.ts +16 -0
  61. package/src/internal/parse.ts +6 -0
  62. package/src/ios-client.ts +207 -2
  63. package/src/sandbox-client.ts +267 -0
  64. package/src/version.ts +1 -1
  65. package/version.d.mts +1 -1
  66. package/version.d.ts +1 -1
  67. package/version.js +1 -1
  68. package/version.mjs +1 -1
@@ -6,6 +6,7 @@ import { spawn } from 'child_process';
6
6
  import { watchFolderTree } from './folder-sync-watcher';
7
7
  import { Readable } from 'stream';
8
8
  import * as zlib from 'zlib';
9
+ import ignore, { type Ignore } from 'ignore';
9
10
 
10
11
  // =============================================================================
11
12
  // Folder Sync (HTTP batch)
@@ -20,7 +21,7 @@ export type FolderSyncOptions = {
20
21
  * Used to store the last-synced “basis” copies of files (and related sync metadata) so we can compute xdelta patches
21
22
  * on subsequent syncs without re-downloading server state.
22
23
  *
23
- * Can be absolute or relative to process.cwd(). Defaults to `.lim-metadata-cache/`.
24
+ * Can be absolute or relative to process.cwd(). Defaults to `.limsync-cache/`.
24
25
  */
25
26
  basisCacheDir?: string;
26
27
  install?: boolean;
@@ -31,6 +32,21 @@ export type FolderSyncOptions = {
31
32
  maxPatchBytes?: number;
32
33
  /** Controls logging verbosity */
33
34
  log?: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
35
+ /**
36
+ * Optional filter function to include/exclude files and directories.
37
+ * Called with the relative path from localFolderPath (using forward slashes).
38
+ * For directories, the path ends with '/'.
39
+ * Return true to include, false to exclude.
40
+ *
41
+ * @example
42
+ * // Exclude build folder
43
+ * filter: (path) => !path.startsWith('build/')
44
+ *
45
+ * @example
46
+ * // Only include source files
47
+ * filter: (path) => path.startsWith('src/') || path.endsWith('.json')
48
+ */
49
+ filter?: (relativePath: string) => boolean;
34
50
  };
35
51
 
36
52
  export type SyncFolderResult = {
@@ -63,6 +79,10 @@ type FolderSyncHttpMeta = {
63
79
  type FolderSyncHttpResponse = {
64
80
  ok: boolean;
65
81
  needFull?: string[];
82
+ // Timing fields
83
+ syncDurationMs?: number;
84
+ installDurationMs?: number; // limulator only
85
+ // Install result fields (limulator only)
66
86
  installedAppPath?: string;
67
87
  bundleId?: string;
68
88
  error?: string;
@@ -123,7 +143,7 @@ async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promis
123
143
  }
124
144
 
125
145
  function folderSyncHttpUrl(apiUrl: string): string {
126
- return `${apiUrl}/folder-sync`;
146
+ return `${apiUrl}/sync`;
127
147
  }
128
148
 
129
149
  function u32be(n: number): Buffer {
@@ -226,23 +246,42 @@ async function sha256FileHex(filePath: string): Promise<string> {
226
246
  });
227
247
  }
228
248
 
229
- async function walkFiles(root: string): Promise<FileEntry[]> {
230
- const out: FileEntry[] = [];
231
- const stack: string[] = [root];
249
+ async function walkFiles(root: string, filter?: (relativePath: string) => boolean): Promise<FileEntry[]> {
232
250
  const rootResolved = path.resolve(root);
251
+
252
+ // Load .gitignore if it exists
253
+ const ig = await loadGitignore(rootResolved);
254
+
255
+ const out: FileEntry[] = [];
256
+ const stack: string[] = [rootResolved];
233
257
  while (stack.length) {
234
258
  const dir = stack.pop()!;
235
259
  const entries = await fs.promises.readdir(dir, { withFileTypes: true });
236
260
  for (const ent of entries) {
237
- if (ent.name === '.DS_Store') continue;
261
+ // Always skip .git folder and .DS_Store
262
+ if (ent.name === '.DS_Store' || ent.name === '.git') continue;
263
+
238
264
  const abs = path.join(dir, ent.name);
265
+ const rel = path.relative(rootResolved, abs).split(path.sep).join('/');
266
+
267
+ // Check if ignored by .gitignore
268
+ if (ig.ignores(rel)) continue;
269
+
239
270
  if (ent.isDirectory()) {
271
+ // For directories, check with trailing slash
272
+ const relDir = rel + '/';
273
+ if (ig.ignores(relDir)) continue;
274
+ // Check custom filter (directories have trailing slash)
275
+ if (filter && !filter(relDir)) continue;
240
276
  stack.push(abs);
241
277
  continue;
242
278
  }
243
279
  if (!ent.isFile()) continue;
280
+
281
+ // Check custom filter for files
282
+ if (filter && !filter(rel)) continue;
283
+
244
284
  const st = await fs.promises.stat(abs);
245
- const rel = path.relative(rootResolved, abs).split(path.sep).join('/');
246
285
  const sha256 = await sha256FileHex(abs);
247
286
  // Preserve POSIX permission bits (including +x). Mask out file-type bits.
248
287
  const mode = st.mode & 0o7777;
@@ -253,6 +292,22 @@ async function walkFiles(root: string): Promise<FileEntry[]> {
253
292
  return out;
254
293
  }
255
294
 
295
+ /**
296
+ * Load and parse .gitignore file if it exists.
297
+ * Returns an Ignore instance that can be used to test paths.
298
+ */
299
+ async function loadGitignore(rootDir: string): Promise<Ignore> {
300
+ const ig = ignore();
301
+ const gitignorePath = path.join(rootDir, '.gitignore');
302
+ try {
303
+ const content = await fs.promises.readFile(gitignorePath, 'utf-8');
304
+ ig.add(content);
305
+ } catch {
306
+ // No .gitignore file, return empty ignore instance
307
+ }
308
+ return ig;
309
+ }
310
+
256
311
  let xdelta3Ready: Promise<void> | null = null;
257
312
  async function ensureXdelta3(): Promise<void> {
258
313
  if (!xdelta3Ready) {
@@ -291,7 +346,7 @@ function localBasisCacheRoot(opts: FolderSyncOptions, localFolderPath: string):
291
346
  const rootOverride =
292
347
  opts.basisCacheDir ?
293
348
  path.resolve(process.cwd(), opts.basisCacheDir)
294
- : path.join(process.cwd(), '.lim-metadata-cache');
349
+ : path.join(process.cwd(), '.limsync-cache');
295
350
  // Include folder identity to avoid collisions between different roots.
296
351
  return path.join(rootOverride, 'folder-sync', hostKey, opts.udid, `${base}-${hash}`);
297
352
  }
@@ -310,7 +365,8 @@ export type SyncAppResult = SyncFolderResult;
310
365
 
311
366
  export async function syncApp(localFolderPath: string, opts: FolderSyncOptions): Promise<SyncFolderResult> {
312
367
  if (!opts.watch) {
313
- return await syncFolderOnce(localFolderPath, opts);
368
+ const result = await syncFolderOnce(localFolderPath, opts);
369
+ return result;
314
370
  }
315
371
  // Initial sync, then watch for changes and re-run sync in the background.
316
372
  const first = await syncFolderOnce(localFolderPath, opts, 'startup');
@@ -353,14 +409,6 @@ export async function syncApp(localFolderPath: string, opts: FolderSyncOptions):
353
409
  };
354
410
  }
355
411
 
356
- // Back-compat alias (older callers)
357
- export async function syncFolder(
358
- localFolderPath: string,
359
- opts: FolderSyncOptions,
360
- ): Promise<SyncFolderResult> {
361
- return await syncApp(localFolderPath, opts);
362
- }
363
-
364
412
  async function syncFolderOnce(
365
413
  localFolderPath: string,
366
414
  opts: FolderSyncOptions,
@@ -377,7 +425,7 @@ async function syncFolderOnce(
377
425
  const tEnsureMs = nowMs() - tEnsureStart;
378
426
 
379
427
  const tWalkStart = nowMs();
380
- const files = await walkFiles(localFolderPath);
428
+ const files = await walkFiles(localFolderPath, opts.filter);
381
429
  const tWalkMs = nowMs() - tWalkStart;
382
430
  const fileMap = new Map(files.map((f) => [f.path, f]));
383
431
 
package/src/index.ts CHANGED
@@ -8,6 +8,22 @@ export { Limrun, type ClientOptions } from './client';
8
8
  export { PagePromise } from './core/pagination';
9
9
  export * from './instance-client';
10
10
  export * as Ios from './ios-client';
11
+ export {
12
+ createXCodeSandboxClient,
13
+ type XCodeSandboxClient,
14
+ type CreateXCodeSandboxClientOptions,
15
+ type SimulatorConfig,
16
+ type SyncOptions,
17
+ type SyncResult,
18
+ type XcodeBuildConfig,
19
+ } from './sandbox-client';
20
+ export {
21
+ exec,
22
+ type ExecRequest,
23
+ type ExecOptions,
24
+ type ExecResult,
25
+ type ExecChildProcess,
26
+ } from './exec-client';
11
27
  export {
12
28
  LimrunError,
13
29
  APIError,
@@ -29,6 +29,12 @@ export async function defaultParseResponse<T>(client: Limrun, props: APIResponse
29
29
  const mediaType = contentType?.split(';')[0]?.trim();
30
30
  const isJSON = mediaType?.includes('application/json') || mediaType?.endsWith('+json');
31
31
  if (isJSON) {
32
+ const contentLength = response.headers.get('content-length');
33
+ if (contentLength === '0') {
34
+ // if there is no content we can't do anything
35
+ return undefined as T;
36
+ }
37
+
32
38
  const json = await response.json();
33
39
  return json as T;
34
40
  }
package/src/ios-client.ts CHANGED
@@ -153,12 +153,22 @@ export type InstanceClient = {
153
153
  elementTree: (point?: AccessibilityPoint) => Promise<string>;
154
154
 
155
155
  /**
156
- * Tap at the specified coordinates
156
+ * Tap at the specified coordinates (uses device's native screen dimensions)
157
157
  * @param x X coordinate in points
158
158
  * @param y Y coordinate in points
159
159
  */
160
160
  tap: (x: number, y: number) => Promise<void>;
161
161
 
162
+ /**
163
+ * Tap at coordinates with explicit screen size.
164
+ * Use this when coordinates are in a different coordinate space than the device's native dimensions.
165
+ * @param x X coordinate in the provided screen coordinate space
166
+ * @param y Y coordinate in the provided screen coordinate space
167
+ * @param screenWidth Width of the coordinate space
168
+ * @param screenHeight Height of the coordinate space
169
+ */
170
+ tapWithScreenSize: (x: number, y: number, screenWidth: number, screenHeight: number) => Promise<void>;
171
+
162
172
  /**
163
173
  * Tap an accessibility element by selector
164
174
  * @param selector The selector criteria to find the element
@@ -209,6 +219,26 @@ export type InstanceClient = {
209
219
  */
210
220
  launchApp: (bundleId: string) => Promise<void>;
211
221
 
222
+ /**
223
+ * Fetch the last N lines of app logs (combined stdout/stderr)
224
+ * @param bundleId Bundle identifier of the app
225
+ * @param lines Number of lines to return (clamped to server limit)
226
+ */
227
+ appLogTail: (bundleId: string, lines: number) => Promise<string>;
228
+
229
+ /**
230
+ * Stream app logs for a bundle ID (batched lines every ~500ms)
231
+ * @param bundleId Bundle identifier of the app
232
+ * @returns LogStream handle (use .stop() to unsubscribe)
233
+ */
234
+ streamAppLog: (bundleId: string) => LogStream;
235
+
236
+ /**
237
+ * Stream syslog (batched lines every ~500ms)
238
+ * @returns LogStream handle (use .stop() to unsubscribe)
239
+ */
240
+ streamSyslog: () => LogStream;
241
+
212
242
  /**
213
243
  * List installed apps on the simulator
214
244
  * @returns Array of installed apps with bundleId, name, and installType
@@ -479,6 +509,10 @@ type ServerResponse = {
479
509
  stdout?: string;
480
510
  stderr?: string;
481
511
  exitCode?: number;
512
+ // Log tail fields
513
+ logs?: string;
514
+ // App log streaming fields
515
+ lines?: string[];
482
516
  };
483
517
 
484
518
  /**
@@ -653,6 +687,134 @@ export class SimctlExecution extends EventEmitter {
653
687
  }
654
688
  }
655
689
 
690
+ /**
691
+ * Handle for a running log stream subscription (app logs or syslog).
692
+ *
693
+ * Uses a dedicated WebSocket connection separate from the main signaling connection.
694
+ * Emits batched log lines every ~500ms when new lines arrive.
695
+ */
696
+ export interface LogStreamEvents {
697
+ lines: (lines: string[]) => void;
698
+ line: (line: string) => void;
699
+ error: (error: Error) => void;
700
+ close: () => void;
701
+ }
702
+
703
+ /** @internal - Message from log stream WebSocket */
704
+ type LogStreamMessage = {
705
+ type: string;
706
+ id: string;
707
+ lines?: string[];
708
+ error?: string;
709
+ };
710
+
711
+ /**
712
+ * Log stream with dedicated WebSocket connection.
713
+ * Each LogStream opens its own WebSocket to isolate log traffic from signaling.
714
+ */
715
+ export class LogStream extends EventEmitter {
716
+ private ws: WebSocket | null = null;
717
+ private subscriptionId: string;
718
+ private stopped = false;
719
+ private terminateMessageType: string;
720
+
721
+ /** @internal */
722
+ constructor(
723
+ private wsUrl: string,
724
+ private subscribeMessage: object,
725
+ terminateMessageType: string,
726
+ subscriptionId: string,
727
+ ) {
728
+ super();
729
+ this.terminateMessageType = terminateMessageType;
730
+ this.subscriptionId = subscriptionId;
731
+ this._connect();
732
+ }
733
+
734
+ override on<E extends keyof LogStreamEvents>(event: E, listener: LogStreamEvents[E]): this {
735
+ return super.on(event, listener as any);
736
+ }
737
+
738
+ override once<E extends keyof LogStreamEvents>(event: E, listener: LogStreamEvents[E]): this {
739
+ return super.once(event, listener as any);
740
+ }
741
+
742
+ override off<E extends keyof LogStreamEvents>(event: E, listener: LogStreamEvents[E]): this {
743
+ return super.off(event, listener as any);
744
+ }
745
+
746
+ /** Stop the log stream and close the dedicated WebSocket connection */
747
+ stop(): void {
748
+ if (this.stopped) return;
749
+ this.stopped = true;
750
+
751
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
752
+ // Send terminate message before closing
753
+ const terminateMsg = { type: this.terminateMessageType, id: this.subscriptionId };
754
+ try {
755
+ this.ws.send(JSON.stringify(terminateMsg));
756
+ } catch {
757
+ // Ignore send errors during shutdown
758
+ }
759
+ this.ws.close();
760
+ }
761
+ this.ws = null;
762
+ this.emit('close');
763
+ }
764
+
765
+ /** @internal - Establish the dedicated WebSocket connection */
766
+ private _connect(): void {
767
+ this.ws = new WebSocket(this.wsUrl);
768
+
769
+ this.ws.on('open', () => {
770
+ if (this.stopped) {
771
+ this.ws?.close();
772
+ return;
773
+ }
774
+ // Send subscription message
775
+ this.ws?.send(JSON.stringify(this.subscribeMessage), (err?: Error) => {
776
+ if (err) {
777
+ this.emit('error', err);
778
+ this.stop();
779
+ }
780
+ });
781
+ });
782
+
783
+ this.ws.on('message', (data: Data) => {
784
+ if (this.stopped) return;
785
+ try {
786
+ const message: LogStreamMessage = JSON.parse(data.toString());
787
+ if (message.error) {
788
+ this.emit('error', new Error(message.error));
789
+ return;
790
+ }
791
+ if (message.lines && message.lines.length > 0) {
792
+ this.emit('lines', message.lines);
793
+ for (const line of message.lines) {
794
+ this.emit('line', line);
795
+ }
796
+ }
797
+ } catch (e) {
798
+ // Ignore parse errors for non-JSON messages
799
+ }
800
+ });
801
+
802
+ this.ws.on('error', (err: Error) => {
803
+ if (!this.stopped) {
804
+ this.emit('error', err);
805
+ }
806
+ });
807
+
808
+ this.ws.on('close', () => {
809
+ if (!this.stopped) {
810
+ this.stopped = true;
811
+ this.emit('close');
812
+ }
813
+ this.ws = null;
814
+ });
815
+ }
816
+ }
817
+
656
818
  /**
657
819
  * Creates a client for interacting with a Limrun iOS instance
658
820
  * @param options Configuration options including webrtcUrl, token and log level
@@ -835,6 +997,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
835
997
  typeTextResult: () => undefined,
836
998
  pressKeyResult: () => undefined,
837
999
  launchAppResult: () => undefined,
1000
+ appLogTailResult: (msg) => msg.logs ?? '',
838
1001
  listAppsResult: (msg) => JSON.parse(msg.apps || '[]') as InstalledApp[],
839
1002
  listOpenFilesResult: (msg) => msg.files || [],
840
1003
  deviceInfoResult: (msg) => ({
@@ -1003,6 +1166,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1003
1166
  screenshot,
1004
1167
  elementTree,
1005
1168
  tap,
1169
+ tapWithScreenSize,
1006
1170
  tapElement,
1007
1171
  incrementElement,
1008
1172
  decrementElement,
@@ -1010,6 +1174,9 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1010
1174
  typeText,
1011
1175
  pressKey,
1012
1176
  launchApp,
1177
+ appLogTail,
1178
+ streamAppLog,
1179
+ streamSyslog,
1013
1180
  listApps,
1014
1181
  openUrl,
1015
1182
  installApp,
@@ -1043,7 +1210,29 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1043
1210
  };
1044
1211
 
1045
1212
  const tap = (x: number, y: number): Promise<void> => {
1046
- return sendRequest<void>('tap', { x, y });
1213
+ return sendRequest<void>('tap', {
1214
+ x,
1215
+ y,
1216
+ screenWidth: cachedDeviceInfo?.screenWidth,
1217
+ screenHeight: cachedDeviceInfo?.screenHeight,
1218
+ });
1219
+ };
1220
+
1221
+ /**
1222
+ * Tap at coordinates with explicit screen size.
1223
+ * Use this when coordinates are in a different coordinate space than the device's native dimensions.
1224
+ * @param x - X coordinate in the provided screen coordinate space
1225
+ * @param y - Y coordinate in the provided screen coordinate space
1226
+ * @param screenWidth - Width of the coordinate space
1227
+ * @param screenHeight - Height of the coordinate space
1228
+ */
1229
+ const tapWithScreenSize = (
1230
+ x: number,
1231
+ y: number,
1232
+ screenWidth: number,
1233
+ screenHeight: number,
1234
+ ): Promise<void> => {
1235
+ return sendRequest<void>('tap', { x, y, screenWidth, screenHeight });
1047
1236
  };
1048
1237
 
1049
1238
  const tapElement = (selector: AccessibilitySelector): Promise<TapElementResult> => {
@@ -1074,6 +1263,22 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1074
1263
  return sendRequest<void>('launchApp', { bundleId });
1075
1264
  };
1076
1265
 
1266
+ const appLogTail = (bundleId: string, lines: number): Promise<string> => {
1267
+ return sendRequest<string>('appLogTail', { bundleId, lines });
1268
+ };
1269
+
1270
+ const streamAppLog = (bundleId: string): LogStream => {
1271
+ const id = generateId();
1272
+ const subscribeMessage = { type: 'streamAppLog', id, bundleId };
1273
+ return new LogStream(endpointWebSocketUrl, subscribeMessage, 'streamAppLogTerminate', id);
1274
+ };
1275
+
1276
+ const streamSyslog = (): LogStream => {
1277
+ const id = generateId();
1278
+ const subscribeMessage = { type: 'streamSyslog', id };
1279
+ return new LogStream(endpointWebSocketUrl, subscribeMessage, 'streamSyslogTerminate', id);
1280
+ };
1281
+
1077
1282
  const listApps = (): Promise<InstalledApp[]> => {
1078
1283
  return sendRequest<InstalledApp[]>('listApps');
1079
1284
  };