@limrun/api 0.45.1 → 0.45.3

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 (84) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/exec-client.d.mts +68 -9
  3. package/exec-client.d.mts.map +1 -1
  4. package/exec-client.d.ts +68 -9
  5. package/exec-client.d.ts.map +1 -1
  6. package/exec-client.js +19 -5
  7. package/exec-client.js.map +1 -1
  8. package/exec-client.mjs +19 -5
  9. package/exec-client.mjs.map +1 -1
  10. package/http-proxy.d.mts +17 -0
  11. package/http-proxy.d.mts.map +1 -1
  12. package/http-proxy.d.ts +17 -0
  13. package/http-proxy.d.ts.map +1 -1
  14. package/http-proxy.js +114 -0
  15. package/http-proxy.js.map +1 -1
  16. package/http-proxy.mjs +113 -0
  17. package/http-proxy.mjs.map +1 -1
  18. package/index.d.mts +3 -2
  19. package/index.d.mts.map +1 -1
  20. package/index.d.ts +3 -2
  21. package/index.d.ts.map +1 -1
  22. package/index.js +9 -1
  23. package/index.js.map +1 -1
  24. package/index.mjs +2 -1
  25. package/index.mjs.map +1 -1
  26. package/ios-client.d.mts +11 -0
  27. package/ios-client.d.mts.map +1 -1
  28. package/ios-client.d.ts +11 -0
  29. package/ios-client.d.ts.map +1 -1
  30. package/ios-client.js +13 -0
  31. package/ios-client.js.map +1 -1
  32. package/ios-client.mjs +14 -1
  33. package/ios-client.mjs.map +1 -1
  34. package/ios-maestro.d.mts +34 -0
  35. package/ios-maestro.d.mts.map +1 -0
  36. package/ios-maestro.d.ts +34 -0
  37. package/ios-maestro.d.ts.map +1 -0
  38. package/ios-maestro.js +134 -0
  39. package/ios-maestro.js.map +1 -0
  40. package/ios-maestro.mjs +127 -0
  41. package/ios-maestro.mjs.map +1 -0
  42. package/ios-shim.js +69 -8
  43. package/ios-shim.js.map +1 -1
  44. package/ios-shim.mjs +69 -8
  45. package/ios-shim.mjs.map +1 -1
  46. package/package.json +11 -1
  47. package/resources/gradle-instances-helpers.d.mts +8 -3
  48. package/resources/gradle-instances-helpers.d.mts.map +1 -1
  49. package/resources/gradle-instances-helpers.d.ts +8 -3
  50. package/resources/gradle-instances-helpers.d.ts.map +1 -1
  51. package/resources/gradle-instances-helpers.js +1 -0
  52. package/resources/gradle-instances-helpers.js.map +1 -1
  53. package/resources/gradle-instances-helpers.mjs +1 -0
  54. package/resources/gradle-instances-helpers.mjs.map +1 -1
  55. package/resources/xcode-instances-helpers.d.mts +7 -7
  56. package/resources/xcode-instances-helpers.d.mts.map +1 -1
  57. package/resources/xcode-instances-helpers.d.ts +7 -7
  58. package/resources/xcode-instances-helpers.d.ts.map +1 -1
  59. package/resources/xcode-instances-helpers.js +3 -1
  60. package/resources/xcode-instances-helpers.js.map +1 -1
  61. package/resources/xcode-instances-helpers.mjs +3 -1
  62. package/resources/xcode-instances-helpers.mjs.map +1 -1
  63. package/src/exec-client.ts +85 -13
  64. package/src/http-proxy.ts +138 -0
  65. package/src/index.ts +20 -3
  66. package/src/ios-client.ts +32 -1
  67. package/src/ios-maestro.ts +154 -0
  68. package/src/ios-shim.ts +70 -8
  69. package/src/resources/gradle-instances-helpers.ts +9 -2
  70. package/src/resources/xcode-instances-helpers.ts +10 -8
  71. package/src/tunnel.ts +56 -6
  72. package/src/version.ts +1 -1
  73. package/tunnel.d.mts +6 -0
  74. package/tunnel.d.mts.map +1 -1
  75. package/tunnel.d.ts +6 -0
  76. package/tunnel.d.ts.map +1 -1
  77. package/tunnel.js +53 -6
  78. package/tunnel.js.map +1 -1
  79. package/tunnel.mjs +53 -6
  80. package/tunnel.mjs.map +1 -1
  81. package/version.d.mts +1 -1
  82. package/version.d.ts +1 -1
  83. package/version.js +1 -1
  84. package/version.mjs +1 -1
@@ -37,7 +37,8 @@ export type XcodeBuildExecRequest = {
37
37
  certificatePassword?: string;
38
38
  provisioningProfileBase64?: string;
39
39
  };
40
- testflight?: TestflightUploadConfig;
40
+ /** Wire name retained for compatibility with the limbuild exec API. */
41
+ testflight?: AppStoreUploadConfig;
41
42
  buildSettings?: Record<string, string>;
42
43
  gitInit?: boolean;
43
44
  signedUploadUrl?: string;
@@ -110,12 +111,16 @@ export type GradleSigningConfig = {
110
111
 
111
112
  export type GradleBuildExecRequest = {
112
113
  command: 'gradlebuild';
113
- /** Gradle tasks to run. Omit for the server default (assembleDebug). */
114
+ /**
115
+ * Gradle tasks to run. Omit for the server default (assembleDebug, or
116
+ * bundleRelease when signing is set).
117
+ */
114
118
  tasks?: string[];
115
119
  /** Relative path to the Gradle root when auto-discovery is ambiguous. */
116
120
  projectPath?: string;
117
121
  reactNative?: GradleReactNativeConfig;
118
122
  signing?: GradleSigningConfig;
123
+ playstore?: GradlePlaystoreConfig;
119
124
  signedUploadUrl?: string;
120
125
  webhook?: WebhookConfig;
121
126
  additionalMetadata?: {
@@ -123,6 +128,45 @@ export type GradleBuildExecRequest = {
123
128
  };
124
129
  };
125
130
 
131
+ /**
132
+ * Publish the built AAB to a Google Play track after a successful build.
133
+ * Requires signing. Exactly one of accessToken or serviceAccountJsonBase64
134
+ * must be set; the credential is held in memory for the build duration
135
+ * only. Progress and failure causes stream as `playstore` SSE events.
136
+ */
137
+ export type GradlePlaystoreConfig = {
138
+ /** OAuth bearer token carrying the androidpublisher scope. */
139
+ accessToken?: string;
140
+ /** Base64-encoded service-account JSON key invited in Play Console. */
141
+ serviceAccountJsonBase64?: string;
142
+ /**
143
+ * Play track ID, passed to Google verbatim: internal (default), alpha,
144
+ * beta, production, or a custom closed-testing track. Publishing
145
+ * replaces the track's existing releases, including an in-progress
146
+ * staged rollout on that track.
147
+ */
148
+ track?: string;
149
+ /**
150
+ * completed (default) makes the release live on the track; draft
151
+ * commits it without rollout. Publishing to the production track
152
+ * requires setting it explicitly.
153
+ */
154
+ releaseStatus?: 'draft' | 'completed';
155
+ /** Package name to publish under. Omit to read it from the built AAB. */
156
+ packageName?: string;
157
+ /**
158
+ * Set the versionCode to one more than the highest already known to
159
+ * Google Play (1 for an app with no artifacts), so repeat publishes
160
+ * never collide. Resolved with the publish credential before the build
161
+ * and stamped into the workspace copy: android.versionCode in the Expo
162
+ * config (requires a static app.json), or the single literal
163
+ * versionCode in a native project's conventional app/ module build
164
+ * script (computed or flavor-split versionCodes are rejected at
165
+ * request time).
166
+ */
167
+ autoIncrementVersionCode?: boolean;
168
+ };
169
+
126
170
  export type RunExecRequest = {
127
171
  command: 'run';
128
172
  commandLine: string;
@@ -143,10 +187,16 @@ export type ExecResult = {
143
187
  status: 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
144
188
  signedDownloadUrl?: string;
145
189
  /**
146
- * Last TestFlight state streamed by the server. Absent when the build ran
147
- * without a testflight request, or when the server predates the feature.
190
+ * Last App Store Connect upload state streamed by the server. Absent when
191
+ * the build ran without an App Store upload, or when the server predates
192
+ * the feature.
148
193
  */
149
- testflight?: TestflightEvent;
194
+ appstore?: AppStoreEvent;
195
+ /**
196
+ * Last Play Store state streamed by the server. Absent when the build ran
197
+ * without a playstore request, or when the server predates the feature.
198
+ */
199
+ playstore?: PlaystoreEvent;
150
200
  /**
151
201
  * True when the client gave up waiting for the build's event stream. The
152
202
  * exit code is fabricated in that case; the remote build may still be
@@ -155,14 +205,24 @@ export type ExecResult = {
155
205
  timedOut?: boolean;
156
206
  };
157
207
 
158
- export type TestflightEvent = {
159
- /** 'unknown' means a testflight event arrived but its payload was unreadable. */
208
+ export type AppStoreEvent = {
209
+ /** 'unknown' means an App Store upload event arrived but its payload was unreadable. */
160
210
  state: 'uploading' | 'processing' | 'accepted' | 'failed' | 'unknown';
161
211
  uploadId?: string;
162
212
  buildId?: string;
163
213
  };
164
214
 
165
- export type TestflightUploadConfig = {
215
+ export type PlaystoreEvent = {
216
+ /** 'unknown' means a playstore event arrived but its payload was unreadable. */
217
+ state: 'uploading' | 'accepted' | 'failed' | 'unknown';
218
+ versionCode?: number;
219
+ track?: string;
220
+ /** Machine-readable failure code; present only on failed. */
221
+ code?: string;
222
+ message?: string;
223
+ };
224
+
225
+ export type AppStoreUploadConfig = {
166
226
  /** App Store Connect API key ID, e.g. 2X9R4HXF34. */
167
227
  apiKeyId: string;
168
228
  /** Issuer ID for team API keys. Omit for individual API keys. */
@@ -255,7 +315,8 @@ export class ExecChildProcess implements PromiseLike<ExecResult> {
255
315
  private sseConnection: EventSourceClient | null = null;
256
316
  private killed = false;
257
317
  private detached = false;
258
- private testflightEvent: TestflightEvent | null = null;
318
+ private appStoreEvent: AppStoreEvent | null = null;
319
+ private playstoreEvent: PlaystoreEvent | null = null;
259
320
  private readonly options: ExecOptions;
260
321
  private readonly log: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
261
322
 
@@ -475,7 +536,8 @@ export class ExecChildProcess implements PromiseLike<ExecResult> {
475
536
  execId: this.execId!,
476
537
  status,
477
538
  ...('additionalMetadata' in request ? request.additionalMetadata ?? {} : {}),
478
- ...(this.testflightEvent ? { testflight: this.testflightEvent } : {}),
539
+ ...(this.appStoreEvent ? { appstore: this.appStoreEvent } : {}),
540
+ ...(this.playstoreEvent ? { playstore: this.playstoreEvent } : {}),
479
541
  ...(timedOut ? { timedOut } : {}),
480
542
  };
481
543
 
@@ -511,13 +573,23 @@ export class ExecChildProcess implements PromiseLike<ExecResult> {
511
573
  this.stderr.emit('data', data);
512
574
  } else if (eventType === 'testflight') {
513
575
  try {
514
- this.testflightEvent = JSON.parse(data) as TestflightEvent;
576
+ this.appStoreEvent = JSON.parse(data) as AppStoreEvent;
515
577
  } catch {
516
- // The event itself proves the server ran the TestFlight step,
578
+ // The wire event itself proves the server ran the App Store upload,
517
579
  // so never let a payload glitch look like a missing feature.
518
- this.testflightEvent = { state: 'unknown' };
580
+ this.appStoreEvent = { state: 'unknown' };
519
581
  this.log('warn', `SSE testflight event has invalid data: ${data}`);
520
582
  }
583
+ } else if (eventType === 'playstore') {
584
+ try {
585
+ this.playstoreEvent = JSON.parse(data) as PlaystoreEvent;
586
+ } catch {
587
+ // Same contract as the App Store upload event: its presence proves the server
588
+ // ran the Play Store step, so a payload glitch must not
589
+ // read as a missing feature.
590
+ this.playstoreEvent = { state: 'unknown' };
591
+ this.log('warn', `SSE playstore event has invalid data: ${data}`);
592
+ }
521
593
  } else if (eventType === 'exitCode') {
522
594
  const exitCode = parseInt(data, 10);
523
595
  if (Number.isNaN(exitCode)) {
package/src/http-proxy.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import http from 'http';
2
2
  import https from 'https';
3
+ import { pipeline } from 'stream';
3
4
 
4
5
  export type HttpProxy = {
5
6
  port: number;
@@ -12,6 +13,10 @@ export type StartHttpProxyOptions = {
12
13
  headers?: Record<string, string>;
13
14
  };
14
15
 
16
+ /**
17
+ * Legacy origin-form reverse proxy: forwards every request on the local port
18
+ * to `remoteBaseUrl`. New callers should prefer `startForwardHttpProxy`.
19
+ */
15
20
  export async function startHttpProxy({
16
21
  localPort = 0,
17
22
  remoteBaseUrl,
@@ -58,6 +63,139 @@ export async function startHttpProxy({
58
63
  };
59
64
  }
60
65
 
66
+ export type StartForwardHttpProxyOptions = {
67
+ /** Loopback requests to this port are rewritten to remoteBaseUrl; everything else passes through. */
68
+ matchPort: number;
69
+ remoteBaseUrl: string;
70
+ headers?: Record<string, string>;
71
+ };
72
+
73
+ // Hop-by-hop headers must not be forwarded by an HTTP proxy (RFC 9110 §7.6.1).
74
+ const HOP_BY_HOP_HEADERS = new Set([
75
+ 'connection',
76
+ 'proxy-connection',
77
+ 'keep-alive',
78
+ 'proxy-authorization',
79
+ 'proxy-authenticate',
80
+ 'te',
81
+ 'trailer',
82
+ 'upgrade',
83
+ ]);
84
+
85
+ function withoutHopByHop(headers: http.IncomingHttpHeaders): http.IncomingHttpHeaders {
86
+ return Object.fromEntries(Object.entries(headers).filter(([name]) => !HOP_BY_HOP_HEADERS.has(name)));
87
+ }
88
+
89
+ /**
90
+ * Single-destination HTTP forward proxy (absolute-form request targets, as sent
91
+ * by clients configured with e.g. JVM -Dhttp.proxyHost). Loopback requests on
92
+ * `matchPort` are forwarded to `remoteBaseUrl`; every other target is refused,
93
+ * so the proxy can only ever reach the destination it was configured with.
94
+ */
95
+ export async function startForwardHttpProxy({
96
+ matchPort,
97
+ remoteBaseUrl,
98
+ headers = {},
99
+ }: StartForwardHttpProxyOptions): Promise<HttpProxy> {
100
+ const base = trimTrailingSlashes(remoteBaseUrl);
101
+ // The driver polls the runner continuously, so upstream connections must be
102
+ // reused instead of paying a TCP/TLS handshake per driver call.
103
+ const agents = {
104
+ 'http:': new http.Agent({ keepAlive: true }),
105
+ 'https:': new https.Agent({ keepAlive: true }),
106
+ };
107
+ const server = http.createServer((req, res) => {
108
+ let target: URL | undefined;
109
+ try {
110
+ target = new URL(req.url ?? '');
111
+ } catch {
112
+ // Falls through to the guard below.
113
+ }
114
+ if (!target || (target.protocol !== 'http:' && target.protocol !== 'https:')) {
115
+ res.writeHead(400, { 'content-type': 'text/plain' });
116
+ res.end('Limrun forward proxy only accepts absolute-form request targets.');
117
+ return;
118
+ }
119
+
120
+ // matchPort is always explicit (an ephemeral port or 7001), so a target
121
+ // without a port can never match. Anything else is refused rather than
122
+ // forwarded: the requested host never selects the destination, so this
123
+ // proxy cannot be pointed at another host.
124
+ if (!isLoopbackHost(target.hostname) || Number(target.port) !== matchPort) {
125
+ res.writeHead(403, { 'content-type': 'text/plain' });
126
+ res.end(`Limrun forward proxy only forwards 127.0.0.1:${matchPort}.`);
127
+ return;
128
+ }
129
+
130
+ const upstreamUrl = new URL(`${base}${target.pathname}${target.search}`);
131
+ const transport = upstreamUrl.protocol === 'https:' ? https : http;
132
+ const upstream = transport.request(
133
+ upstreamUrl,
134
+ {
135
+ agent: agents[upstreamUrl.protocol as keyof typeof agents],
136
+ method: req.method,
137
+ headers: {
138
+ ...withoutHopByHop(req.headers),
139
+ host: upstreamUrl.host,
140
+ ...headers,
141
+ },
142
+ },
143
+ (upstreamResponse) => {
144
+ res.writeHead(upstreamResponse.statusCode ?? 502, withoutHopByHop(upstreamResponse.headers));
145
+ // pipeline tears down whichever side is still alive when the other one
146
+ // dies, instead of leaking it or raising an unhandled 'error'.
147
+ pipeline(upstreamResponse, res, () => {});
148
+ },
149
+ );
150
+
151
+ upstream.on('error', (error) => {
152
+ if (res.headersSent) {
153
+ // Mid-stream failure: abort instead of appending error text to a
154
+ // partial body.
155
+ res.destroy(error);
156
+ return;
157
+ }
158
+ res.writeHead(502, { 'content-type': 'text/plain' });
159
+ res.end(error.message);
160
+ });
161
+ // A client that aborts mid-call must not leak the upstream socket.
162
+ res.on('close', () => {
163
+ if (!res.writableFinished) {
164
+ upstream.destroy();
165
+ }
166
+ });
167
+ pipeline(req, upstream, () => {});
168
+ });
169
+
170
+ // HTTPS clients open CONNECT tunnels; we deliberately keep TLS traffic off this
171
+ // proxy (only http.proxyHost is set on the JVM side), so refuse loudly instead
172
+ // of tunneling blind.
173
+ server.on('connect', (_req, socket) => {
174
+ socket.end('HTTP/1.1 501 Not Implemented\r\n\r\nLimrun forward proxy does not tunnel CONNECT.\r\n');
175
+ });
176
+
177
+ await listen(server, 0);
178
+ const address = server.address();
179
+ if (!address || typeof address === 'string') {
180
+ throw new Error('Failed to start HTTP forward proxy.');
181
+ }
182
+ return {
183
+ port: address.port,
184
+ close: async () => {
185
+ await closeServer(server);
186
+ agents['http:'].destroy();
187
+ agents['https:'].destroy();
188
+ },
189
+ };
190
+ }
191
+
192
+ function isLoopbackHost(hostname: string): boolean {
193
+ // URL.hostname keeps brackets around IPv6 hosts.
194
+ return (
195
+ hostname === 'localhost' || hostname === '[::1]' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)
196
+ );
197
+ }
198
+
61
199
  function trimTrailingSlashes(value: string): string {
62
200
  return value.replace(/\/+$/, '');
63
201
  }
package/src/index.ts CHANGED
@@ -8,7 +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 { startHttpProxy, type HttpProxy, type StartHttpProxyOptions } from './http-proxy';
11
+ export {
12
+ startHttpProxy,
13
+ startForwardHttpProxy,
14
+ type HttpProxy,
15
+ type StartHttpProxyOptions,
16
+ type StartForwardHttpProxyOptions,
17
+ } from './http-proxy';
18
+ export {
19
+ prepareMaestroRun,
20
+ isMaestroRunnerRunning,
21
+ waitForMaestroRunner,
22
+ maestroSpawnOptions,
23
+ MAESTRO_RUNNER_BUNDLE_ID,
24
+ MAESTRO_RUNNER_PORT,
25
+ type MaestroRun,
26
+ } from './ios-maestro';
12
27
  export { buildSettingKeyPattern, parseBuildSettingEntries, validateBuildSettings } from './build-settings';
13
28
  export {
14
29
  exec,
@@ -16,13 +31,15 @@ export {
16
31
  type XcodeBuildExecRequest,
17
32
  type GradleAndroidABI,
18
33
  type GradleBuildExecRequest,
34
+ type GradlePlaystoreConfig,
19
35
  type GradleReactNativeConfig,
20
36
  type GradleSigningConfig,
21
37
  type ExecOptions,
22
38
  type ExecResult,
23
39
  type ExecChildProcess,
24
- type TestflightEvent,
25
- type TestflightUploadConfig,
40
+ type AppStoreEvent,
41
+ type AppStoreUploadConfig,
42
+ type PlaystoreEvent,
26
43
  type WebhookConfig,
27
44
  } from './exec-client';
28
45
  export {
package/src/ios-client.ts CHANGED
@@ -10,7 +10,11 @@ import { createIgnoreFn } from './folder-sync-ignore';
10
10
  import { prepareAppBundlePath, watchAppArchive } from './app-archive';
11
11
  import { downloadFileToLocalPath } from './internal/download-file';
12
12
  import { nodeProxyTransport } from './internal/proxy-transport';
13
- import { startHttpProxy as startLocalHttpProxy } from './http-proxy';
13
+ import {
14
+ startHttpProxy as startLocalHttpProxy,
15
+ startForwardHttpProxy as startLocalForwardHttpProxy,
16
+ type StartForwardHttpProxyOptions,
17
+ } from './http-proxy';
14
18
  import { startXcrunShim as startClientXcrunShim } from './ios-shim';
15
19
 
16
20
  /**
@@ -210,6 +214,8 @@ export type HttpProxyOptions = {
210
214
  localPort: number;
211
215
  };
212
216
 
217
+ export type ForwardHttpProxyOptions = Omit<StartForwardHttpProxyOptions, 'headers'>;
218
+
213
219
  /**
214
220
  * Targets a file operation at an installed app's container instead of the
215
221
  * sandbox staging folder. Mirrors `simctl get_app_container` semantics.
@@ -749,6 +755,16 @@ export type InstanceClient = {
749
755
  */
750
756
  startHttpProxy: (options: HttpProxyOptions) => Promise<number>;
751
757
 
758
+ /**
759
+ * Start a local HTTP forward proxy (for clients driven by JVM/system proxy
760
+ * settings, like Maestro) that rewrites loopback requests on `matchPort` to a
761
+ * port exposed by the iOS instance and passes other targets through.
762
+ *
763
+ * Returns the local port the proxy is listening on. The proxy is closed when
764
+ * the client disconnects.
765
+ */
766
+ startForwardHttpProxy: (options: ForwardHttpProxyOptions) => Promise<number>;
767
+
752
768
  /**
753
769
  * Disconnect from the Limrun instance
754
770
  */
@@ -1861,6 +1877,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1861
1877
  softReset,
1862
1878
  startReverseTunnel,
1863
1879
  startHttpProxy,
1880
+ startForwardHttpProxy,
1864
1881
  disconnect,
1865
1882
  getConnectionState,
1866
1883
  onConnectionStateChange,
@@ -2507,6 +2524,20 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
2507
2524
  return proxy.port;
2508
2525
  };
2509
2526
 
2527
+ const startForwardHttpProxy = async (proxyOptions: ForwardHttpProxyOptions): Promise<number> => {
2528
+ assertPort(proxyOptions.matchPort, 'matchPort', 1, 65535);
2529
+
2530
+ const proxy = await startLocalForwardHttpProxy({
2531
+ matchPort: proxyOptions.matchPort,
2532
+ remoteBaseUrl: proxyOptions.remoteBaseUrl,
2533
+ headers: {
2534
+ authorization: `Bearer ${options.token}`,
2535
+ },
2536
+ });
2537
+ httpProxyCleanups.push(proxy.close);
2538
+ return proxy.port;
2539
+ };
2540
+
2510
2541
  const disconnect = (): void => {
2511
2542
  intentionalDisconnect = true;
2512
2543
  cleanup();
@@ -0,0 +1,154 @@
1
+ import path from 'path';
2
+ import net from 'net';
3
+ import { execFile } from 'child_process';
4
+
5
+ import type { InstanceClient } from './ios-client';
6
+
7
+ export const MAESTRO_RUNNER_BUNDLE_ID = 'dev.mobile.maestro-driver-iosUITests.xctrunner';
8
+ /** Port the patched Maestro XCTest runner listens on inside the simulator. */
9
+ export const MAESTRO_RUNNER_PORT = 22087;
10
+
11
+ // Maestro installs as a .bat/.cmd wrapper on Windows, which spawn can only
12
+ // execute through a shell.
13
+ export const maestroSpawnOptions = { shell: process.platform === 'win32' } as const;
14
+
15
+ export type MaestroRun = {
16
+ maestroVersion: string;
17
+ driverPort: number;
18
+ /** Environment overrides for the `maestro` process; merge over process.env. */
19
+ env: Record<string, string>;
20
+ /** Arguments to place between `maestro test` and the flow arguments. */
21
+ args: string[];
22
+ };
23
+
24
+ /**
25
+ * Wire a locally installed stock Maestro CLI to this instance's remote XCTest
26
+ * runner: starts the xcrun shim and an HTTP forward proxy, and returns the
27
+ * environment and `maestro test` arguments that route the driver traffic to
28
+ * the remote runner. Both are torn down when the client disconnects.
29
+ *
30
+ * Maestro's driver talks plain HTTP to 127.0.0.1:<driverPort>. Nothing listens
31
+ * there: the JVM proxy settings route those requests (absolute-form) to the
32
+ * forward proxy, which rewrites them to the remote runner and passes other
33
+ * targets through.
34
+ */
35
+ export async function prepareMaestroRun(
36
+ client: InstanceClient,
37
+ options: { runnerUrl: string },
38
+ ): Promise<MaestroRun> {
39
+ const [maestroVersion, shimDir] = await Promise.all([detectMaestroVersion(), client.startXcrunShim()]);
40
+ // Maestro 2.5.x hardcodes driver port 7001 and has no --driver-host-port
41
+ // flag; 2.6+ picks a random port unless the flag is passed.
42
+ const withDriverHostPortFlag = supportsDriverHostPort(maestroVersion);
43
+ const driverPort = withDriverHostPortFlag ? await findFreePort() : 7001;
44
+ const proxyPort = await client.startForwardHttpProxy({
45
+ remoteBaseUrl: options.runnerUrl,
46
+ matchPort: driverPort,
47
+ });
48
+ const maestroOpts = [
49
+ process.env['MAESTRO_OPTS'],
50
+ `-Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort=${proxyPort} -Dhttp.nonProxyHosts=`,
51
+ ]
52
+ .filter(Boolean)
53
+ .join(' ');
54
+ return {
55
+ maestroVersion,
56
+ driverPort,
57
+ env: {
58
+ MAESTRO_OPTS: maestroOpts,
59
+ PATH: `${shimDir}${path.delimiter}${process.env['PATH'] ?? ''}`,
60
+ USE_XCODE_TEST_RUNNER: '1',
61
+ },
62
+ args: [
63
+ '--platform',
64
+ 'ios',
65
+ '--device',
66
+ client.deviceInfo.udid,
67
+ '--no-reinstall-driver',
68
+ ...(withDriverHostPortFlag ? ['--driver-host-port', String(driverPort)] : []),
69
+ ],
70
+ };
71
+ }
72
+
73
+ /** Poll the remote Maestro XCTest runner until it responds, e.g. right after launching it. */
74
+ export async function waitForMaestroRunner(
75
+ runnerUrl: string,
76
+ token: string,
77
+ timeoutMs = 15000,
78
+ ): Promise<boolean> {
79
+ const deadline = Date.now() + timeoutMs;
80
+ while (Date.now() < deadline) {
81
+ if (await isMaestroRunnerRunning(runnerUrl, token)) {
82
+ return true;
83
+ }
84
+ await new Promise((resolve) => setTimeout(resolve, 500));
85
+ }
86
+ return false;
87
+ }
88
+
89
+ /** Probe the remote Maestro XCTest runner's /status endpoint. */
90
+ export async function isMaestroRunnerRunning(runnerUrl: string, token: string): Promise<boolean> {
91
+ const controller = new AbortController();
92
+ const timeout = setTimeout(() => controller.abort(), 3000);
93
+ try {
94
+ const response = await fetch(`${runnerUrl}/status`, {
95
+ headers: { Authorization: `Bearer ${token}` },
96
+ signal: controller.signal,
97
+ });
98
+ return response.ok;
99
+ } catch {
100
+ return false;
101
+ } finally {
102
+ // Also on failure: a pending timer keeps the event loop alive.
103
+ clearTimeout(timeout);
104
+ }
105
+ }
106
+
107
+ function detectMaestroVersion(): Promise<string> {
108
+ return new Promise((resolve, reject) => {
109
+ execFile('maestro', ['--version'], { encoding: 'utf8', ...maestroSpawnOptions }, (error, stdout) => {
110
+ if (error) {
111
+ reject(
112
+ new Error(
113
+ 'Failed to run `maestro --version`. Install the Maestro CLI first: https://docs.maestro.dev/getting-started/installing-maestro',
114
+ ),
115
+ );
116
+ return;
117
+ }
118
+ // stdout only: update notices can also start with a version string and
119
+ // land on stderr or after the real version.
120
+ const version = stdout
121
+ .split('\n')
122
+ .map((line) => line.trim())
123
+ .find((line) => /^\d+\.\d+\.\d+/.test(line));
124
+ if (!version) {
125
+ reject(new Error('Could not parse the Maestro CLI version from `maestro --version` output.'));
126
+ return;
127
+ }
128
+ resolve(version);
129
+ });
130
+ });
131
+ }
132
+
133
+ function supportsDriverHostPort(version: string): boolean {
134
+ const [major = 0, minor = 0] = version.split('.').map((part) => Number.parseInt(part, 10) || 0);
135
+ return major > 2 || (major === 2 && minor >= 6);
136
+ }
137
+
138
+ // Finds a free port and releases it immediately: Maestro validates that the
139
+ // driver port is bindable, so it must stay unbound on our side.
140
+ function findFreePort(): Promise<number> {
141
+ return new Promise((resolve, reject) => {
142
+ const server = net.createServer();
143
+ server.once('error', reject);
144
+ server.listen(0, '127.0.0.1', () => {
145
+ const address = server.address();
146
+ if (!address || typeof address === 'string') {
147
+ server.close();
148
+ reject(new Error('Failed to find a free port.'));
149
+ return;
150
+ }
151
+ server.close(() => resolve(address.port));
152
+ });
153
+ });
154
+ }