@limrun/api 0.49.3 → 0.50.1
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 +27 -0
- package/exec-client.d.mts +25 -1
- package/exec-client.d.mts.map +1 -1
- package/exec-client.d.ts +25 -1
- package/exec-client.d.ts.map +1 -1
- package/exec-client.js +362 -197
- package/exec-client.js.map +1 -1
- package/exec-client.mjs +361 -197
- package/exec-client.mjs.map +1 -1
- package/folder-sync-ignore.d.mts +15 -12
- package/folder-sync-ignore.d.mts.map +1 -1
- package/folder-sync-ignore.d.ts +15 -12
- package/folder-sync-ignore.d.ts.map +1 -1
- package/folder-sync-ignore.js +16 -19
- package/folder-sync-ignore.js.map +1 -1
- package/folder-sync-ignore.mjs +16 -19
- package/folder-sync-ignore.mjs.map +1 -1
- package/index.d.mts +1 -1
- package/index.d.mts.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js.map +1 -1
- package/index.mjs.map +1 -1
- package/instance-client.d.mts +11 -0
- package/instance-client.d.mts.map +1 -1
- package/instance-client.d.ts +11 -0
- package/instance-client.d.ts.map +1 -1
- package/instance-client.js +10 -1
- package/instance-client.js.map +1 -1
- package/instance-client.mjs +7 -0
- package/instance-client.mjs.map +1 -1
- package/ios-client.d.mts +2 -33
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +2 -33
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +8 -99
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +4 -95
- package/ios-client.mjs.map +1 -1
- package/log-stream.d.mts +35 -0
- package/log-stream.d.mts.map +1 -0
- package/log-stream.d.ts +35 -0
- package/log-stream.d.ts.map +1 -0
- package/log-stream.js +103 -0
- package/log-stream.js.map +1 -0
- package/log-stream.mjs +99 -0
- package/log-stream.mjs.map +1 -0
- package/logcat-process.d.mts +40 -0
- package/logcat-process.d.mts.map +1 -0
- package/logcat-process.d.ts +40 -0
- package/logcat-process.d.ts.map +1 -0
- package/logcat-process.js +122 -0
- package/logcat-process.js.map +1 -0
- package/logcat-process.mjs +118 -0
- package/logcat-process.mjs.map +1 -0
- package/mise-tools.d.mts +2 -0
- package/mise-tools.d.mts.map +1 -0
- package/mise-tools.d.ts +2 -0
- package/mise-tools.d.ts.map +1 -0
- package/mise-tools.js +17 -0
- package/mise-tools.js.map +1 -0
- package/mise-tools.mjs +14 -0
- package/mise-tools.mjs.map +1 -0
- package/package.json +31 -1
- package/resources/daemon-client-shared.d.mts +11 -0
- package/resources/daemon-client-shared.d.mts.map +1 -1
- package/resources/daemon-client-shared.d.ts +11 -0
- package/resources/daemon-client-shared.d.ts.map +1 -1
- package/resources/daemon-client-shared.js +23 -0
- package/resources/daemon-client-shared.js.map +1 -1
- package/resources/daemon-client-shared.mjs +22 -0
- package/resources/daemon-client-shared.mjs.map +1 -1
- package/resources/gradle-instances-helpers.d.mts +12 -2
- package/resources/gradle-instances-helpers.d.mts.map +1 -1
- package/resources/gradle-instances-helpers.d.ts +12 -2
- package/resources/gradle-instances-helpers.d.ts.map +1 -1
- package/resources/gradle-instances-helpers.js +6 -0
- package/resources/gradle-instances-helpers.js.map +1 -1
- package/resources/gradle-instances-helpers.mjs +8 -2
- package/resources/gradle-instances-helpers.mjs.map +1 -1
- package/resources/xcode-instances-helpers.d.mts +6 -15
- package/resources/xcode-instances-helpers.d.mts.map +1 -1
- package/resources/xcode-instances-helpers.d.ts +6 -15
- package/resources/xcode-instances-helpers.d.ts.map +1 -1
- package/resources/xcode-instances-helpers.js +3 -18
- package/resources/xcode-instances-helpers.js.map +1 -1
- package/resources/xcode-instances-helpers.mjs +5 -20
- package/resources/xcode-instances-helpers.mjs.map +1 -1
- package/src/exec-client.ts +404 -198
- package/src/folder-sync-ignore.ts +21 -21
- package/src/index.ts +1 -0
- package/src/instance-client.ts +16 -0
- package/src/ios-client.ts +4 -129
- package/src/log-stream.ts +132 -0
- package/src/logcat-process.ts +151 -0
- package/src/mise-tools.ts +13 -0
- package/src/resources/daemon-client-shared.ts +33 -0
- package/src/resources/gradle-instances-helpers.ts +23 -0
- package/src/resources/xcode-instances-helpers.ts +13 -33
- package/src/version.ts +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/exec-client.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { createEventSource } from 'eventsource-client';
|
|
8
8
|
import { nodeProxyTransport } from "./internal/proxy-transport.mjs";
|
|
9
9
|
import { sseFetch } from "./internal/sse-fetch.mjs";
|
|
10
|
+
import { directInstanceHttpError } from "./internal/direct-instance-errors.mjs";
|
|
10
11
|
/**
|
|
11
12
|
* Give-up policy for the exec event stream. The EventSource reconnects
|
|
12
13
|
* forever on its own, so a dead exec (deleted instance, expired record)
|
|
@@ -63,6 +64,177 @@ export class ReadableStream {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
/** Follow execution events until exitCode, with bounded retries and replay deduplication. */
|
|
68
|
+
function followExecEventStream(options) {
|
|
69
|
+
let connection = null;
|
|
70
|
+
const { eventsUrl, signal } = options;
|
|
71
|
+
const result = new Promise((resolve, reject) => {
|
|
72
|
+
if (signal?.aborted) {
|
|
73
|
+
reject(options.abortError());
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Settle-once pair. Both paths close the source: the EventSource
|
|
77
|
+
// reconnects on its own otherwise, even after the promise is done.
|
|
78
|
+
let settled = false;
|
|
79
|
+
let lastSeenEventId = -1;
|
|
80
|
+
let streamDeadSince = 0;
|
|
81
|
+
let eventSource = null;
|
|
82
|
+
let connectedAt = 0;
|
|
83
|
+
let lastCycleAt = 0;
|
|
84
|
+
let lastCycleMono = 0;
|
|
85
|
+
let proofOfLifeThisCycle = false;
|
|
86
|
+
let cleanResponseThisCycle = false;
|
|
87
|
+
let lastStreamError;
|
|
88
|
+
const cleanup = () => {
|
|
89
|
+
eventSource?.close();
|
|
90
|
+
signal?.removeEventListener('abort', onAbort);
|
|
91
|
+
};
|
|
92
|
+
const succeed = (exitCode) => {
|
|
93
|
+
if (settled)
|
|
94
|
+
return;
|
|
95
|
+
settled = true;
|
|
96
|
+
cleanup();
|
|
97
|
+
resolve(exitCode);
|
|
98
|
+
};
|
|
99
|
+
const fail = (error) => {
|
|
100
|
+
if (settled)
|
|
101
|
+
return;
|
|
102
|
+
settled = true;
|
|
103
|
+
cleanup();
|
|
104
|
+
reject(error);
|
|
105
|
+
};
|
|
106
|
+
const onAbort = () => fail(options.abortError());
|
|
107
|
+
// Stream health is judged at the fetch surface, where the response
|
|
108
|
+
// status is visible; the library fires onConnect for error responses
|
|
109
|
+
// too, so an error page held open must not read as a healthy
|
|
110
|
+
// connection. An HTTP 204 makes the EventSource close for good
|
|
111
|
+
// without reporting anything, so it must fail here. A rejected fetch
|
|
112
|
+
// never reaches onDisconnect (the hazard sseFetch exists for):
|
|
113
|
+
// capture it for the give-up message and let the clock decide, so
|
|
114
|
+
// one transient refusal does not kill a live build.
|
|
115
|
+
const fetchWithStreamPolicy = async (input, init) => {
|
|
116
|
+
const response = await nodeProxyTransport.fetch(input, init);
|
|
117
|
+
if (response.status === 204) {
|
|
118
|
+
fail(new ExecStreamClosedError(`event stream to ${eventsUrl} was closed by the server (HTTP 204)`));
|
|
119
|
+
}
|
|
120
|
+
else if (options.failOnClientError &&
|
|
121
|
+
response.status >= 400 &&
|
|
122
|
+
response.status < 500 &&
|
|
123
|
+
response.status !== 408 &&
|
|
124
|
+
response.status !== 429) {
|
|
125
|
+
fail(directInstanceHttpError('GET exec logs', response.status, await response.text(), response.headers));
|
|
126
|
+
}
|
|
127
|
+
else if (!response.ok) {
|
|
128
|
+
lastStreamError = new Error(`server answered HTTP ${response.status}`);
|
|
129
|
+
}
|
|
130
|
+
cleanResponseThisCycle = response.ok;
|
|
131
|
+
return response;
|
|
132
|
+
};
|
|
133
|
+
eventSource = createEventSource({
|
|
134
|
+
url: eventsUrl,
|
|
135
|
+
fetch: sseFetch(fetchWithStreamPolicy, (err) => {
|
|
136
|
+
lastStreamError = err instanceof Error ? err : new Error(String(err));
|
|
137
|
+
}),
|
|
138
|
+
headers: { Authorization: `Bearer ${options.token}` },
|
|
139
|
+
onConnect: () => {
|
|
140
|
+
connectedAt = Date.now();
|
|
141
|
+
},
|
|
142
|
+
// Comments count as proof of life, so a server-side keepalive
|
|
143
|
+
// works without a client change (onMessage never sees them).
|
|
144
|
+
onComment: () => {
|
|
145
|
+
proofOfLifeThisCycle = true;
|
|
146
|
+
},
|
|
147
|
+
// Fires once per broken cycle, before the retry timer is armed, on
|
|
148
|
+
// both failure paths (request rejected, stream ended). This is
|
|
149
|
+
// where the give-up clock runs.
|
|
150
|
+
onScheduleReconnect: () => {
|
|
151
|
+
if (settled || signal?.aborted) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const mono = performance.now();
|
|
156
|
+
const livedMs = connectedAt > 0 ? now - connectedAt : 0;
|
|
157
|
+
connectedAt = 0;
|
|
158
|
+
// Date.now() is wall clock: a laptop waking from sleep (or a
|
|
159
|
+
// clock step) would arrive with the whole window already
|
|
160
|
+
// "elapsed" and fail on its first attempt. Sleep is the wall
|
|
161
|
+
// clock advancing while the monotonic clock stands still; a
|
|
162
|
+
// large drift between the two restarts the streak. Long server
|
|
163
|
+
// retry delays and hanging connects advance both clocks equally
|
|
164
|
+
// and keep counting.
|
|
165
|
+
if (streamDeadSince > 0 && lastCycleAt > 0) {
|
|
166
|
+
const wallGapMs = now - lastCycleAt;
|
|
167
|
+
const monoGapMs = mono - lastCycleMono;
|
|
168
|
+
if (wallGapMs - monoGapMs > 30000) {
|
|
169
|
+
streamDeadSince = now;
|
|
170
|
+
options.onStreamDeadSince?.(streamDeadSince);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
lastCycleAt = now;
|
|
174
|
+
lastCycleMono = mono;
|
|
175
|
+
const healthy = proofOfLifeThisCycle || (cleanResponseThisCycle && livedMs >= sseStreamPolicy.healthyConnectionMs);
|
|
176
|
+
proofOfLifeThisCycle = false;
|
|
177
|
+
cleanResponseThisCycle = false;
|
|
178
|
+
if (healthy) {
|
|
179
|
+
streamDeadSince = 0;
|
|
180
|
+
options.onStreamDeadSince?.(0);
|
|
181
|
+
lastStreamError = undefined;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (streamDeadSince === 0) {
|
|
185
|
+
streamDeadSince = now;
|
|
186
|
+
options.onStreamDeadSince?.(streamDeadSince);
|
|
187
|
+
options.log?.('warn', 'SSE disconnected; reconnecting');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (now - streamDeadSince >= sseStreamPolicy.giveUpAfterMs) {
|
|
191
|
+
const seconds = Math.round((now - streamDeadSince) / 1000);
|
|
192
|
+
const cause = lastStreamError ? `; last error: ${lastStreamError.message}` : '';
|
|
193
|
+
fail(new ExecStreamLostError(`event stream to ${eventsUrl} kept failing for ${seconds}s without delivering events${cause}; ` +
|
|
194
|
+
'the execution may no longer exist (instance deleted or record expired)'));
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
onMessage: (message) => {
|
|
198
|
+
if (settled)
|
|
199
|
+
return;
|
|
200
|
+
streamDeadSince = 0;
|
|
201
|
+
options.onStreamDeadSince?.(0);
|
|
202
|
+
proofOfLifeThisCycle = true;
|
|
203
|
+
lastStreamError = undefined;
|
|
204
|
+
const eventId = message.id !== undefined ? Number.parseInt(message.id, 10) : NaN;
|
|
205
|
+
if (!Number.isNaN(eventId)) {
|
|
206
|
+
if (eventId <= lastSeenEventId)
|
|
207
|
+
return;
|
|
208
|
+
lastSeenEventId = eventId;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const event = {
|
|
212
|
+
...(message.id !== undefined && { id: message.id }),
|
|
213
|
+
type: message.event || 'message',
|
|
214
|
+
data: typeof message.data === 'string' ? message.data : String(message.data ?? ''),
|
|
215
|
+
};
|
|
216
|
+
options.onEvent(event);
|
|
217
|
+
if (event.type === 'exitCode') {
|
|
218
|
+
const exitCode = parseExitCode(event.data);
|
|
219
|
+
options.log?.('debug', `Execution completed via SSE: exitCode=${exitCode}`);
|
|
220
|
+
succeed(exitCode);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
fail(error);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
connection = eventSource;
|
|
229
|
+
if (settled) {
|
|
230
|
+
eventSource.close();
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
return { connection, result };
|
|
237
|
+
}
|
|
66
238
|
/**
|
|
67
239
|
* A ChildProcess-like object similar to Node.js's ChildProcess.
|
|
68
240
|
*
|
|
@@ -100,11 +272,6 @@ export class ExecChildProcess {
|
|
|
100
272
|
this.playstoreEvent = null;
|
|
101
273
|
this.xctestCases = [];
|
|
102
274
|
this.xctestSummary = null;
|
|
103
|
-
// Highest server-assigned event id already dispatched. The server numbers
|
|
104
|
-
// every frame and replays the stream from the start on reconnect; skipping
|
|
105
|
-
// ids at or below this mark drops the replayed prefix for every event type
|
|
106
|
-
// at once, and stays correct if the server ever honors Last-Event-ID.
|
|
107
|
-
this.lastSeenEventId = -1;
|
|
108
275
|
this.options = options;
|
|
109
276
|
this.log = options.log ?? (() => { });
|
|
110
277
|
this.startedPromise = new Promise((resolve, reject) => {
|
|
@@ -356,215 +523,212 @@ export class ExecChildProcess {
|
|
|
356
523
|
* closes the stream for good, or when the give-up clock runs out.
|
|
357
524
|
*/
|
|
358
525
|
connectSSE(eventsUrl) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
let proofOfLifeThisCycle = false;
|
|
372
|
-
let cleanResponseThisCycle = false;
|
|
373
|
-
let lastStreamError;
|
|
374
|
-
const cleanup = () => {
|
|
375
|
-
eventSource.close();
|
|
376
|
-
signal.removeEventListener('abort', onAbort);
|
|
377
|
-
};
|
|
378
|
-
const succeed = (exitCode) => {
|
|
379
|
-
if (settled)
|
|
380
|
-
return;
|
|
381
|
-
settled = true;
|
|
382
|
-
cleanup();
|
|
383
|
-
resolve(exitCode);
|
|
384
|
-
};
|
|
385
|
-
const fail = (error) => {
|
|
386
|
-
if (settled)
|
|
387
|
-
return;
|
|
388
|
-
settled = true;
|
|
389
|
-
cleanup();
|
|
390
|
-
reject(error);
|
|
391
|
-
};
|
|
392
|
-
const onAbort = () => fail(new Error('killed'));
|
|
393
|
-
// Stream health is judged at the fetch surface, where the response
|
|
394
|
-
// status is visible; the library fires onConnect for error responses
|
|
395
|
-
// too, so an error page held open must not read as a healthy
|
|
396
|
-
// connection. An HTTP 204 makes the EventSource close for good
|
|
397
|
-
// without reporting anything, so it must fail here. A rejected fetch
|
|
398
|
-
// never reaches onDisconnect (the hazard sseFetch exists for):
|
|
399
|
-
// capture it for the give-up message and let the clock decide, so
|
|
400
|
-
// one transient refusal does not kill a live build.
|
|
401
|
-
const fetchWithStreamPolicy = async (input, init) => {
|
|
402
|
-
const response = await nodeProxyTransport.fetch(input, init);
|
|
403
|
-
if (response.status === 204) {
|
|
404
|
-
fail(new ExecStreamClosedError(`event stream to ${eventsUrl} was closed by the server (HTTP 204)`));
|
|
526
|
+
const stream = followExecEventStream({
|
|
527
|
+
eventsUrl,
|
|
528
|
+
token: this.options.token,
|
|
529
|
+
signal: this.abortController.signal,
|
|
530
|
+
abortError: () => new Error('killed'),
|
|
531
|
+
log: this.log,
|
|
532
|
+
onStreamDeadSince: (since) => {
|
|
533
|
+
this.streamDeadSince = since;
|
|
534
|
+
},
|
|
535
|
+
onEvent: ({ type: eventType, data }) => {
|
|
536
|
+
if (eventType === 'command') {
|
|
537
|
+
this.command.emit('data', data);
|
|
405
538
|
}
|
|
406
|
-
else if (
|
|
407
|
-
|
|
539
|
+
else if (eventType === 'stdout') {
|
|
540
|
+
this.stdout.emit('data', data);
|
|
408
541
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
lastStreamError = err instanceof Error ? err : new Error(String(err));
|
|
416
|
-
}),
|
|
417
|
-
headers: { Authorization: `Bearer ${this.options.token}` },
|
|
418
|
-
onConnect: () => {
|
|
419
|
-
connectedAt = Date.now();
|
|
420
|
-
},
|
|
421
|
-
// Comments count as proof of life, so a server-side keepalive
|
|
422
|
-
// works without a client change (onMessage never sees them).
|
|
423
|
-
onComment: () => {
|
|
424
|
-
proofOfLifeThisCycle = true;
|
|
425
|
-
},
|
|
426
|
-
// Fires once per broken cycle, before the retry timer is armed, on
|
|
427
|
-
// both failure paths (request rejected, stream ended). This is
|
|
428
|
-
// where the give-up clock runs.
|
|
429
|
-
onScheduleReconnect: () => {
|
|
430
|
-
if (settled || this.killed) {
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
const now = Date.now();
|
|
434
|
-
const mono = performance.now();
|
|
435
|
-
const livedMs = connectedAt > 0 ? now - connectedAt : 0;
|
|
436
|
-
connectedAt = 0;
|
|
437
|
-
// Date.now() is wall clock: a laptop waking from sleep (or a
|
|
438
|
-
// clock step) would arrive with the whole window already
|
|
439
|
-
// "elapsed" and fail on its first attempt. Sleep is the wall
|
|
440
|
-
// clock advancing while the monotonic clock stands still; a
|
|
441
|
-
// large drift between the two restarts the streak. Long server
|
|
442
|
-
// retry delays and hanging connects advance both clocks equally
|
|
443
|
-
// and keep counting.
|
|
444
|
-
if (this.streamDeadSince > 0 && lastCycleAt > 0) {
|
|
445
|
-
const wallGapMs = now - lastCycleAt;
|
|
446
|
-
const monoGapMs = mono - lastCycleMono;
|
|
447
|
-
if (wallGapMs - monoGapMs > 30000) {
|
|
448
|
-
this.streamDeadSince = now;
|
|
449
|
-
}
|
|
542
|
+
else if (eventType === 'stderr') {
|
|
543
|
+
this.stderr.emit('data', data);
|
|
544
|
+
}
|
|
545
|
+
else if (eventType === 'testflight') {
|
|
546
|
+
try {
|
|
547
|
+
this.appStoreEvent = JSON.parse(data);
|
|
450
548
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
cleanResponseThisCycle = false;
|
|
457
|
-
if (healthy) {
|
|
458
|
-
this.streamDeadSince = 0;
|
|
459
|
-
lastStreamError = undefined;
|
|
460
|
-
return;
|
|
549
|
+
catch {
|
|
550
|
+
// The wire event itself proves the server ran the App Store upload,
|
|
551
|
+
// so never let a payload glitch look like a missing feature.
|
|
552
|
+
this.appStoreEvent = { state: 'unknown' };
|
|
553
|
+
this.log('warn', `SSE testflight event has invalid data: ${data}`);
|
|
461
554
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
555
|
+
}
|
|
556
|
+
else if (eventType === 'playstore') {
|
|
557
|
+
try {
|
|
558
|
+
this.playstoreEvent = JSON.parse(data);
|
|
466
559
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
560
|
+
catch {
|
|
561
|
+
// Same contract as the App Store upload event: its presence proves the server
|
|
562
|
+
// ran the Play Store step, so a payload glitch must not
|
|
563
|
+
// read as a missing feature.
|
|
564
|
+
this.playstoreEvent = { state: 'unknown' };
|
|
565
|
+
this.log('warn', `SSE playstore event has invalid data: ${data}`);
|
|
472
566
|
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
// A reconnect replays the stream from the start; this frame was
|
|
482
|
-
// already dispatched on an earlier connection.
|
|
483
|
-
return;
|
|
567
|
+
}
|
|
568
|
+
else if (eventType === 'xctest') {
|
|
569
|
+
let event;
|
|
570
|
+
try {
|
|
571
|
+
const parsed = JSON.parse(data);
|
|
572
|
+
if (parsed?.type === 'case') {
|
|
573
|
+
this.xctestCases.push(parsed);
|
|
574
|
+
event = parsed;
|
|
484
575
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const eventType = message.event;
|
|
489
|
-
if (eventType === 'command') {
|
|
490
|
-
this.command.emit('data', data);
|
|
491
|
-
}
|
|
492
|
-
else if (eventType === 'stdout') {
|
|
493
|
-
this.stdout.emit('data', data);
|
|
494
|
-
}
|
|
495
|
-
else if (eventType === 'stderr') {
|
|
496
|
-
this.stderr.emit('data', data);
|
|
497
|
-
}
|
|
498
|
-
else if (eventType === 'testflight') {
|
|
499
|
-
try {
|
|
500
|
-
this.appStoreEvent = JSON.parse(data);
|
|
576
|
+
else if (parsed?.type === 'summary') {
|
|
577
|
+
this.xctestSummary = parsed;
|
|
578
|
+
event = parsed;
|
|
501
579
|
}
|
|
502
|
-
|
|
503
|
-
// The
|
|
504
|
-
//
|
|
505
|
-
this.
|
|
506
|
-
this.log('warn', `SSE testflight event has invalid data: ${data}`);
|
|
580
|
+
else {
|
|
581
|
+
// The simulator owns the frame vocabulary and may grow it; the
|
|
582
|
+
// callback's contract is cases and summaries only.
|
|
583
|
+
this.log('debug', `SSE xctest event of unknown type ignored: ${data}`);
|
|
507
584
|
}
|
|
508
585
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
this.playstoreEvent = JSON.parse(data);
|
|
512
|
-
}
|
|
513
|
-
catch {
|
|
514
|
-
// Same contract as the App Store upload event: its presence proves the server
|
|
515
|
-
// ran the Play Store step, so a payload glitch must not
|
|
516
|
-
// read as a missing feature.
|
|
517
|
-
this.playstoreEvent = { state: 'unknown' };
|
|
518
|
-
this.log('warn', `SSE playstore event has invalid data: ${data}`);
|
|
519
|
-
}
|
|
586
|
+
catch {
|
|
587
|
+
this.log('warn', `SSE xctest event has invalid data: ${data}`);
|
|
520
588
|
}
|
|
521
|
-
|
|
522
|
-
let event;
|
|
589
|
+
if (event) {
|
|
523
590
|
try {
|
|
524
|
-
|
|
525
|
-
if (parsed?.type === 'case') {
|
|
526
|
-
this.xctestCases.push(parsed);
|
|
527
|
-
event = parsed;
|
|
528
|
-
}
|
|
529
|
-
else if (parsed?.type === 'summary') {
|
|
530
|
-
this.xctestSummary = parsed;
|
|
531
|
-
event = parsed;
|
|
532
|
-
}
|
|
533
|
-
else {
|
|
534
|
-
// The simulator owns the frame vocabulary and may grow it; the
|
|
535
|
-
// callback's contract is cases and summaries only.
|
|
536
|
-
this.log('debug', `SSE xctest event of unknown type ignored: ${data}`);
|
|
537
|
-
}
|
|
591
|
+
this.options.onXctestEvent?.(event);
|
|
538
592
|
}
|
|
539
|
-
catch {
|
|
540
|
-
|
|
593
|
+
catch (err) {
|
|
594
|
+
// The consumer's callback, not the frame: the run must not be
|
|
595
|
+
// derailed and the log must not blame the payload.
|
|
596
|
+
this.log('warn', `onXctestEvent callback threw: ${String(err)}`);
|
|
541
597
|
}
|
|
542
|
-
if (event) {
|
|
543
|
-
try {
|
|
544
|
-
this.options.onXctestEvent?.(event);
|
|
545
|
-
}
|
|
546
|
-
catch (err) {
|
|
547
|
-
// The consumer's callback, not the frame: the run must not be
|
|
548
|
-
// derailed and the log must not blame the payload.
|
|
549
|
-
this.log('warn', `onXctestEvent callback threw: ${String(err)}`);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
else if (eventType === 'exitCode') {
|
|
554
|
-
const exitCode = parseInt(data, 10);
|
|
555
|
-
if (Number.isNaN(exitCode)) {
|
|
556
|
-
this.log('warn', `SSE exitCode event has invalid data: ${data}`);
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
this.log('debug', `Execution completed via SSE: exitCode=${exitCode}`);
|
|
560
|
-
succeed(exitCode);
|
|
561
598
|
}
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
this.sseConnection = stream.connection;
|
|
603
|
+
return stream.result;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Replay logs for an existing execution, optionally following it to completion.
|
|
608
|
+
* Snapshot mode uses a finite SSE response; follow mode consumes the same
|
|
609
|
+
* replayable stream until its terminal exitCode event.
|
|
610
|
+
*/
|
|
611
|
+
export async function observeExecLogs(execId, options) {
|
|
612
|
+
if (!execId.trim()) {
|
|
613
|
+
throw new Error('execId must not be empty');
|
|
614
|
+
}
|
|
615
|
+
const follow = options.follow ?? false;
|
|
616
|
+
// Resolve mutable aliases once so a reconnect cannot switch to another build.
|
|
617
|
+
if (follow && (execId === 'active' || execId === 'latest')) {
|
|
618
|
+
const snapshotEvents = [];
|
|
619
|
+
const snapshot = await observeExecLogs(execId, {
|
|
620
|
+
...options,
|
|
621
|
+
follow: false,
|
|
622
|
+
onEvent: (event) => snapshotEvents.push(event),
|
|
623
|
+
});
|
|
624
|
+
if (snapshot.status !== 'RUNNING') {
|
|
625
|
+
for (const event of snapshotEvents)
|
|
626
|
+
options.onEvent?.(event);
|
|
627
|
+
return snapshot;
|
|
628
|
+
}
|
|
629
|
+
if (snapshot.execId === execId) {
|
|
630
|
+
throw new Error(`The server did not resolve the ${execId} build alias.`);
|
|
631
|
+
}
|
|
632
|
+
return observeExecLogs(snapshot.execId, options);
|
|
633
|
+
}
|
|
634
|
+
let apiUrlEnd = options.apiUrl.length;
|
|
635
|
+
while (options.apiUrl[apiUrlEnd - 1] === '/')
|
|
636
|
+
apiUrlEnd--;
|
|
637
|
+
const eventsUrl = new URL(`${options.apiUrl.slice(0, apiUrlEnd)}/exec/${encodeURIComponent(execId)}/events`);
|
|
638
|
+
eventsUrl.searchParams.set('follow', String(follow));
|
|
639
|
+
if (!follow) {
|
|
640
|
+
const response = await nodeProxyTransport.fetch(eventsUrl, {
|
|
641
|
+
method: 'GET',
|
|
642
|
+
headers: { Authorization: `Bearer ${options.token}` },
|
|
643
|
+
...(options.signal && { signal: options.signal }),
|
|
566
644
|
});
|
|
645
|
+
if (!response.ok) {
|
|
646
|
+
const text = await response.text();
|
|
647
|
+
throw directInstanceHttpError('GET exec logs', response.status, text, response.headers);
|
|
648
|
+
}
|
|
649
|
+
const events = parseSSE(await response.text());
|
|
650
|
+
let exitCode;
|
|
651
|
+
let resolvedExecId = execId;
|
|
652
|
+
for (const event of events) {
|
|
653
|
+
resolvedExecId = execIdFromMeta(event, resolvedExecId);
|
|
654
|
+
options.onEvent?.(event);
|
|
655
|
+
if (event.type === 'exitCode') {
|
|
656
|
+
exitCode = parseExitCode(event.data);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return buildExecLogResult(resolvedExecId, exitCode);
|
|
660
|
+
}
|
|
661
|
+
let resolvedExecId = execId;
|
|
662
|
+
const stream = followExecEventStream({
|
|
663
|
+
eventsUrl: eventsUrl.toString(),
|
|
664
|
+
token: options.token,
|
|
665
|
+
...(options.signal && { signal: options.signal }),
|
|
666
|
+
failOnClientError: true,
|
|
667
|
+
log: options.log,
|
|
668
|
+
abortError: () => new Error(`exec log stream for ${execId} was aborted`),
|
|
669
|
+
onEvent: (event) => {
|
|
670
|
+
resolvedExecId = execIdFromMeta(event, resolvedExecId);
|
|
671
|
+
options.onEvent?.(event);
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
const exitCode = await stream.result;
|
|
675
|
+
return buildExecLogResult(resolvedExecId, exitCode);
|
|
676
|
+
}
|
|
677
|
+
function parseSSE(body) {
|
|
678
|
+
const normalized = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
679
|
+
const events = [];
|
|
680
|
+
for (const block of normalized.split('\n\n')) {
|
|
681
|
+
if (!block.trim())
|
|
682
|
+
continue;
|
|
683
|
+
let id;
|
|
684
|
+
let type = 'message';
|
|
685
|
+
const data = [];
|
|
686
|
+
for (const line of block.split('\n')) {
|
|
687
|
+
if (line.startsWith('id:')) {
|
|
688
|
+
id = line.slice(3).trimStart();
|
|
689
|
+
}
|
|
690
|
+
else if (line.startsWith('event:')) {
|
|
691
|
+
type = line.slice(6).trimStart();
|
|
692
|
+
}
|
|
693
|
+
else if (line.startsWith('data:')) {
|
|
694
|
+
data.push(line.slice(5).replace(/^ /, ''));
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (data.length > 0 || type !== 'message' || id !== undefined) {
|
|
698
|
+
events.push({ ...(id !== undefined && { id }), type, data: data.join('\n') });
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return events;
|
|
702
|
+
}
|
|
703
|
+
function parseExitCode(data) {
|
|
704
|
+
const exitCode = Number.parseInt(data, 10);
|
|
705
|
+
if (Number.isNaN(exitCode)) {
|
|
706
|
+
throw new Error(`invalid exec exit code: ${data}`);
|
|
567
707
|
}
|
|
708
|
+
return exitCode;
|
|
709
|
+
}
|
|
710
|
+
function execIdFromMeta(event, fallback) {
|
|
711
|
+
if (event.type !== 'meta')
|
|
712
|
+
return fallback;
|
|
713
|
+
try {
|
|
714
|
+
const meta = JSON.parse(event.data);
|
|
715
|
+
return typeof meta.id === 'string' && meta.id ? meta.id : fallback;
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
return fallback;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function buildExecLogResult(execId, exitCode) {
|
|
722
|
+
if (exitCode === undefined) {
|
|
723
|
+
return { execId, status: 'RUNNING' };
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
execId,
|
|
727
|
+
exitCode,
|
|
728
|
+
status: exitCode === 0 ? 'SUCCEEDED'
|
|
729
|
+
: exitCode === -1 ? 'CANCELLED'
|
|
730
|
+
: 'FAILED',
|
|
731
|
+
};
|
|
568
732
|
}
|
|
569
733
|
/**
|
|
570
734
|
* Execute a command on the limbuild server.
|