@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.
- package/CHANGELOG.md +12 -0
- package/exec-client.d.mts +68 -9
- package/exec-client.d.mts.map +1 -1
- package/exec-client.d.ts +68 -9
- package/exec-client.d.ts.map +1 -1
- package/exec-client.js +19 -5
- package/exec-client.js.map +1 -1
- package/exec-client.mjs +19 -5
- package/exec-client.mjs.map +1 -1
- package/http-proxy.d.mts +17 -0
- package/http-proxy.d.mts.map +1 -1
- package/http-proxy.d.ts +17 -0
- package/http-proxy.d.ts.map +1 -1
- package/http-proxy.js +114 -0
- package/http-proxy.js.map +1 -1
- package/http-proxy.mjs +113 -0
- package/http-proxy.mjs.map +1 -1
- package/index.d.mts +3 -2
- package/index.d.mts.map +1 -1
- package/index.d.ts +3 -2
- package/index.d.ts.map +1 -1
- package/index.js +9 -1
- package/index.js.map +1 -1
- package/index.mjs +2 -1
- package/index.mjs.map +1 -1
- package/ios-client.d.mts +11 -0
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +11 -0
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +13 -0
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +14 -1
- package/ios-client.mjs.map +1 -1
- package/ios-maestro.d.mts +34 -0
- package/ios-maestro.d.mts.map +1 -0
- package/ios-maestro.d.ts +34 -0
- package/ios-maestro.d.ts.map +1 -0
- package/ios-maestro.js +134 -0
- package/ios-maestro.js.map +1 -0
- package/ios-maestro.mjs +127 -0
- package/ios-maestro.mjs.map +1 -0
- package/ios-shim.js +69 -8
- package/ios-shim.js.map +1 -1
- package/ios-shim.mjs +69 -8
- package/ios-shim.mjs.map +1 -1
- package/package.json +11 -1
- package/resources/gradle-instances-helpers.d.mts +8 -3
- package/resources/gradle-instances-helpers.d.mts.map +1 -1
- package/resources/gradle-instances-helpers.d.ts +8 -3
- package/resources/gradle-instances-helpers.d.ts.map +1 -1
- package/resources/gradle-instances-helpers.js +1 -0
- package/resources/gradle-instances-helpers.js.map +1 -1
- package/resources/gradle-instances-helpers.mjs +1 -0
- package/resources/gradle-instances-helpers.mjs.map +1 -1
- package/resources/xcode-instances-helpers.d.mts +7 -7
- package/resources/xcode-instances-helpers.d.mts.map +1 -1
- package/resources/xcode-instances-helpers.d.ts +7 -7
- package/resources/xcode-instances-helpers.d.ts.map +1 -1
- package/resources/xcode-instances-helpers.js +3 -1
- package/resources/xcode-instances-helpers.js.map +1 -1
- package/resources/xcode-instances-helpers.mjs +3 -1
- package/resources/xcode-instances-helpers.mjs.map +1 -1
- package/src/exec-client.ts +85 -13
- package/src/http-proxy.ts +138 -0
- package/src/index.ts +20 -3
- package/src/ios-client.ts +32 -1
- package/src/ios-maestro.ts +154 -0
- package/src/ios-shim.ts +70 -8
- package/src/resources/gradle-instances-helpers.ts +9 -2
- package/src/resources/xcode-instances-helpers.ts +10 -8
- package/src/tunnel.ts +56 -6
- package/src/version.ts +1 -1
- package/tunnel.d.mts +6 -0
- package/tunnel.d.mts.map +1 -1
- package/tunnel.d.ts +6 -0
- package/tunnel.d.ts.map +1 -1
- package/tunnel.js +53 -6
- package/tunnel.js.map +1 -1
- package/tunnel.mjs +53 -6
- package/tunnel.mjs.map +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- package/version.mjs +1 -1
package/src/exec-client.ts
CHANGED
|
@@ -37,7 +37,8 @@ export type XcodeBuildExecRequest = {
|
|
|
37
37
|
certificatePassword?: string;
|
|
38
38
|
provisioningProfileBase64?: string;
|
|
39
39
|
};
|
|
40
|
-
|
|
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
|
-
/**
|
|
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
|
|
147
|
-
* without
|
|
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
|
-
|
|
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
|
|
159
|
-
/** 'unknown' means
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
576
|
+
this.appStoreEvent = JSON.parse(data) as AppStoreEvent;
|
|
515
577
|
} catch {
|
|
516
|
-
// The event itself proves the server ran the
|
|
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.
|
|
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 {
|
|
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
|
|
25
|
-
type
|
|
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 {
|
|
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
|
+
}
|