@run-cloud/sdk 0.12.1 → 0.14.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.
- package/README.md +77 -2
- package/dist/index.d.ts +342 -4
- package/dist/index.js +682 -44
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,255 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
export const SIMULATOR_KEYS = [
|
|
4
|
+
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
|
|
5
|
+
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
|
|
6
|
+
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
|
7
|
+
"enter", "escape", "backspace", "tab", "space", "minus", "equal",
|
|
8
|
+
"bracketLeft", "bracketRight", "backslash", "semicolon", "quote", "backquote",
|
|
9
|
+
"comma", "period", "slash", "capsLock",
|
|
10
|
+
"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12",
|
|
11
|
+
"printScreen", "scrollLock", "pause", "insert", "home", "pageUp", "delete", "end",
|
|
12
|
+
"pageDown", "arrowRight", "arrowLeft", "arrowDown", "arrowUp", "numLock",
|
|
13
|
+
"numpadDivide", "numpadMultiply", "numpadSubtract", "numpadAdd", "numpadEnter",
|
|
14
|
+
"numpad0", "numpad1", "numpad2", "numpad3", "numpad4", "numpad5", "numpad6",
|
|
15
|
+
"numpad7", "numpad8", "numpad9", "numpadDecimal",
|
|
16
|
+
];
|
|
17
|
+
export const SIMULATOR_BUTTONS = [
|
|
18
|
+
"home",
|
|
19
|
+
"back",
|
|
20
|
+
"appSwitcher",
|
|
21
|
+
"recents",
|
|
22
|
+
"power",
|
|
23
|
+
"volumeUp",
|
|
24
|
+
"volumeDown",
|
|
25
|
+
"menu",
|
|
26
|
+
"sideButton",
|
|
27
|
+
"actionButton",
|
|
28
|
+
"digitalCrown",
|
|
29
|
+
];
|
|
30
|
+
export const SIMULATOR_RENDER_DEBUG_OPTIONS = [
|
|
31
|
+
"colorBlendedLayers",
|
|
32
|
+
"colorCopiedImages",
|
|
33
|
+
"colorMisalignedImages",
|
|
34
|
+
"colorOffscreenRendered",
|
|
35
|
+
"slowAnimations",
|
|
36
|
+
];
|
|
37
|
+
export const SIMULATOR_INTERACTION_ERROR_CODES = [
|
|
38
|
+
"invalid_interaction",
|
|
39
|
+
"active_session_not_found",
|
|
40
|
+
"simulator_capacity_unavailable",
|
|
41
|
+
"unsupported_action",
|
|
42
|
+
"duplicate_request",
|
|
43
|
+
"interaction_cancelled",
|
|
44
|
+
"interaction_timeout",
|
|
45
|
+
"interaction_transport_error",
|
|
46
|
+
"interaction_invalid_response",
|
|
47
|
+
"interaction_failed",
|
|
48
|
+
];
|
|
49
|
+
export const SIMULATOR_SCREENSHOT_ERROR_CODES = [
|
|
50
|
+
"invalid_screenshot_request",
|
|
51
|
+
"active_session_not_found",
|
|
52
|
+
"simulator_capacity_unavailable",
|
|
53
|
+
"screenshot_cancelled",
|
|
54
|
+
"screenshot_timeout",
|
|
55
|
+
"screenshot_transport_error",
|
|
56
|
+
"screenshot_invalid_response",
|
|
57
|
+
"screenshot_failed",
|
|
58
|
+
];
|
|
59
|
+
export const DEFAULT_SIMULATOR_INTERACTION_TIMEOUT_MS = 15_000;
|
|
60
|
+
const SIMULATOR_RESPONSE_MARGIN_MS = 250;
|
|
61
|
+
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
62
|
+
function assertNormalizedPoint(point, name) {
|
|
63
|
+
if (!Number.isFinite(point.x)
|
|
64
|
+
|| point.x < 0
|
|
65
|
+
|| point.x > 1
|
|
66
|
+
|| !Number.isFinite(point.y)
|
|
67
|
+
|| point.y < 0
|
|
68
|
+
|| point.y > 1)
|
|
69
|
+
throw new RangeError(`${name} coordinates must be finite numbers from 0 to 1`);
|
|
70
|
+
}
|
|
71
|
+
function assertIntegerRange(value, min, max, name) {
|
|
72
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
73
|
+
throw new RangeError(`${name} must be an integer from ${min} to ${max}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function validatedRecordingIdempotencyKey(value) {
|
|
77
|
+
if (value === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
const key = value.trim();
|
|
80
|
+
const hasControlCharacter = [...key].some((character) => {
|
|
81
|
+
const code = character.charCodeAt(0);
|
|
82
|
+
return code <= 31 || code === 127;
|
|
83
|
+
});
|
|
84
|
+
if (!key || key.length > 200 || hasControlCharacter) {
|
|
85
|
+
throw new TypeError("idempotencyKey must contain 1-200 printable characters");
|
|
86
|
+
}
|
|
87
|
+
return key;
|
|
88
|
+
}
|
|
89
|
+
export function validateSimulatorInteraction(interaction, timeoutMs = DEFAULT_SIMULATOR_INTERACTION_TIMEOUT_MS) {
|
|
90
|
+
assertIntegerRange(timeoutMs, 100, 60_000, "timeoutMs");
|
|
91
|
+
switch (interaction.action) {
|
|
92
|
+
case "tap":
|
|
93
|
+
assertNormalizedPoint(interaction, "tap");
|
|
94
|
+
return;
|
|
95
|
+
case "swipe":
|
|
96
|
+
assertNormalizedPoint(interaction.from, "swipe from");
|
|
97
|
+
assertNormalizedPoint(interaction.to, "swipe to");
|
|
98
|
+
if (interaction.durationMs !== undefined) {
|
|
99
|
+
assertIntegerRange(interaction.durationMs, 50, 30_000, "swipe durationMs");
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
case "gesture": {
|
|
103
|
+
if (interaction.steps.length < 2 || interaction.steps.length > 1_000) {
|
|
104
|
+
throw new RangeError("gesture steps must contain from 2 to 1000 entries");
|
|
105
|
+
}
|
|
106
|
+
let pointCount;
|
|
107
|
+
let delayTotal = 0;
|
|
108
|
+
for (const [index, step] of interaction.steps.entries()) {
|
|
109
|
+
const expectedPhase = index === 0
|
|
110
|
+
? "begin"
|
|
111
|
+
: index === interaction.steps.length - 1
|
|
112
|
+
? "end"
|
|
113
|
+
: "move";
|
|
114
|
+
if (step.phase !== expectedPhase) {
|
|
115
|
+
throw new TypeError(`gesture step ${index} phase must be ${expectedPhase}`);
|
|
116
|
+
}
|
|
117
|
+
if ((index === 0) !== (step.phase === "begin")) {
|
|
118
|
+
throw new TypeError("gesture must start with begin and contain no later begin phase");
|
|
119
|
+
}
|
|
120
|
+
if ((index === interaction.steps.length - 1) !== (step.phase === "end")) {
|
|
121
|
+
throw new TypeError("gesture must end with end and contain no earlier end phase");
|
|
122
|
+
}
|
|
123
|
+
if (step.points.length !== 1 && step.points.length !== 2) {
|
|
124
|
+
throw new RangeError("each gesture step must contain one or two points");
|
|
125
|
+
}
|
|
126
|
+
if (pointCount === undefined)
|
|
127
|
+
pointCount = step.points.length;
|
|
128
|
+
else if (pointCount !== step.points.length) {
|
|
129
|
+
throw new TypeError("every gesture step must use the same number of points");
|
|
130
|
+
}
|
|
131
|
+
for (const point of step.points)
|
|
132
|
+
assertNormalizedPoint(point, "gesture");
|
|
133
|
+
if (step.delayMs !== undefined) {
|
|
134
|
+
assertIntegerRange(step.delayMs, 0, 5_000, "gesture delayMs");
|
|
135
|
+
if (index === interaction.steps.length - 1 && step.delayMs !== 0) {
|
|
136
|
+
throw new RangeError("final gesture end step delayMs must be 0 because there is no next step");
|
|
137
|
+
}
|
|
138
|
+
delayTotal += step.delayMs;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (delayTotal > timeoutMs)
|
|
142
|
+
throw new RangeError("gesture delays cannot exceed timeoutMs");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
case "typeText":
|
|
146
|
+
if (interaction.text.length < 1 || interaction.text.length > 10_000) {
|
|
147
|
+
throw new RangeError("text must contain from 1 to 10000 characters");
|
|
148
|
+
}
|
|
149
|
+
if (!/^[\x09\x0a\x20-\x7e]+$/.test(interaction.text)) {
|
|
150
|
+
throw new TypeError("text must contain only US ASCII characters, tabs, and line feeds");
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
case "pressKey":
|
|
154
|
+
if (!SIMULATOR_KEYS.includes(interaction.key)) {
|
|
155
|
+
throw new TypeError(`unknown simulator key: ${interaction.key}`);
|
|
156
|
+
}
|
|
157
|
+
if (interaction.durationMs !== undefined) {
|
|
158
|
+
assertIntegerRange(interaction.durationMs, 20, 30_000, "key durationMs");
|
|
159
|
+
}
|
|
160
|
+
if (interaction.modifiers) {
|
|
161
|
+
const known = new Set(["shift", "control", "alt", "meta"]);
|
|
162
|
+
if (interaction.modifiers.some((modifier) => !known.has(modifier))) {
|
|
163
|
+
throw new TypeError("key modifiers must be shift, control, alt, or meta");
|
|
164
|
+
}
|
|
165
|
+
if (new Set(interaction.modifiers).size !== interaction.modifiers.length) {
|
|
166
|
+
throw new TypeError("key modifiers must be unique");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
case "pressButton":
|
|
171
|
+
if (!SIMULATOR_BUTTONS.includes(interaction.button)) {
|
|
172
|
+
throw new TypeError(`unknown simulator button: ${interaction.button}`);
|
|
173
|
+
}
|
|
174
|
+
if (interaction.durationMs !== undefined) {
|
|
175
|
+
assertIntegerRange(interaction.durationMs, 20, 30_000, "button durationMs");
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
case "rotate":
|
|
179
|
+
if (!["portrait", "portrait_upside_down", "landscape_left", "landscape_right"].includes(interaction.orientation)) {
|
|
180
|
+
throw new TypeError(`unknown simulator orientation: ${interaction.orientation}`);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
case "reload":
|
|
184
|
+
case "toggleSoftwareKeyboard":
|
|
185
|
+
case "simulateMemoryWarning":
|
|
186
|
+
return;
|
|
187
|
+
case "scroll":
|
|
188
|
+
if (!Number.isFinite(interaction.deltaX)
|
|
189
|
+
|| interaction.deltaX < -1
|
|
190
|
+
|| interaction.deltaX > 1
|
|
191
|
+
|| !Number.isFinite(interaction.deltaY)
|
|
192
|
+
|| interaction.deltaY < -1
|
|
193
|
+
|| interaction.deltaY > 1)
|
|
194
|
+
throw new RangeError("scroll deltaX and deltaY must be finite numbers from -1 to 1");
|
|
195
|
+
if (interaction.deltaX === 0 && interaction.deltaY === 0) {
|
|
196
|
+
throw new RangeError("at least one scroll delta must be nonzero");
|
|
197
|
+
}
|
|
198
|
+
if ((interaction.x === undefined) !== (interaction.y === undefined)) {
|
|
199
|
+
throw new TypeError("scroll x and y must be provided together");
|
|
200
|
+
}
|
|
201
|
+
if (interaction.x !== undefined && interaction.y !== undefined) {
|
|
202
|
+
assertNormalizedPoint({ x: interaction.x, y: interaction.y }, "scroll anchor");
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
case "rotateDigitalCrown":
|
|
206
|
+
if (!Number.isFinite(interaction.delta) || interaction.delta === 0 || Math.abs(interaction.delta) > 10_000) {
|
|
207
|
+
throw new RangeError("digital crown delta must be nonzero and between -10000 and 10000");
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
case "setRenderDebug":
|
|
211
|
+
if (!SIMULATOR_RENDER_DEBUG_OPTIONS.includes(interaction.option)) {
|
|
212
|
+
throw new TypeError(`unknown render debug option: ${interaction.option}`);
|
|
213
|
+
}
|
|
214
|
+
if (typeof interaction.enabled !== "boolean") {
|
|
215
|
+
throw new TypeError("render debug enabled must be a boolean");
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function requestAbort(options) {
|
|
221
|
+
const { signal, timeoutMs } = options;
|
|
222
|
+
if (timeoutMs === undefined && !signal) {
|
|
223
|
+
return { signal: undefined, timedOut: () => false, cleanup: () => undefined };
|
|
224
|
+
}
|
|
225
|
+
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
|
226
|
+
throw new RangeError("timeoutMs must be a positive finite number");
|
|
227
|
+
}
|
|
228
|
+
const controller = new AbortController();
|
|
229
|
+
let didTimeOut = false;
|
|
230
|
+
const abortFromCaller = () => {
|
|
231
|
+
controller.abort(signal?.reason ?? new DOMException("The operation was aborted", "AbortError"));
|
|
232
|
+
};
|
|
233
|
+
if (signal?.aborted)
|
|
234
|
+
abortFromCaller();
|
|
235
|
+
else
|
|
236
|
+
signal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
237
|
+
const timer = timeoutMs === undefined
|
|
238
|
+
? undefined
|
|
239
|
+
: setTimeout(() => {
|
|
240
|
+
didTimeOut = true;
|
|
241
|
+
controller.abort(new DOMException(options.timeoutMessage ?? "The operation timed out", "TimeoutError"));
|
|
242
|
+
}, timeoutMs);
|
|
243
|
+
return {
|
|
244
|
+
signal: controller.signal,
|
|
245
|
+
timedOut: () => didTimeOut,
|
|
246
|
+
cleanup: () => {
|
|
247
|
+
if (timer !== undefined)
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
signal?.removeEventListener("abort", abortFromCaller);
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
3
253
|
const FILE_CHUNK_BYTES = 512 * 1024;
|
|
4
254
|
const MAX_CONCURRENT_ASSET_UPLOADS = 6;
|
|
5
255
|
const MAX_ASSET_UPLOAD_ATTEMPTS = 3;
|
|
@@ -28,10 +278,33 @@ function normalizeSandbox(raw) {
|
|
|
28
278
|
export class RunCloudError extends Error {
|
|
29
279
|
status;
|
|
30
280
|
detail;
|
|
31
|
-
|
|
281
|
+
code;
|
|
282
|
+
retryable;
|
|
283
|
+
details;
|
|
284
|
+
requestId;
|
|
285
|
+
sessionId;
|
|
286
|
+
platform;
|
|
287
|
+
action;
|
|
288
|
+
interactionStatus;
|
|
289
|
+
acceptedAt;
|
|
290
|
+
completedAt;
|
|
291
|
+
durationMs;
|
|
292
|
+
constructor(status, detail, options = {}) {
|
|
32
293
|
super(`run.cloud API ${status}: ${detail}`);
|
|
33
294
|
this.status = status;
|
|
34
295
|
this.detail = detail;
|
|
296
|
+
this.name = "RunCloudError";
|
|
297
|
+
this.code = options.code;
|
|
298
|
+
this.retryable = options.retryable;
|
|
299
|
+
this.details = options.details;
|
|
300
|
+
this.requestId = options.requestId;
|
|
301
|
+
this.sessionId = options.sessionId;
|
|
302
|
+
this.platform = options.platform;
|
|
303
|
+
this.action = options.action;
|
|
304
|
+
this.interactionStatus = options.interactionStatus;
|
|
305
|
+
this.acceptedAt = options.acceptedAt;
|
|
306
|
+
this.completedAt = options.completedAt;
|
|
307
|
+
this.durationMs = options.durationMs;
|
|
35
308
|
}
|
|
36
309
|
}
|
|
37
310
|
function env(name) {
|
|
@@ -47,24 +320,108 @@ function apiKeyFromEnv() {
|
|
|
47
320
|
function apiUrlFromEnv() {
|
|
48
321
|
return (env("RUN_CLOUD_API_URL") ?? "https://api.run.cloud").replace(/\/+$/, "");
|
|
49
322
|
}
|
|
50
|
-
|
|
323
|
+
function optionalString(value) {
|
|
324
|
+
return typeof value === "string" ? value : undefined;
|
|
325
|
+
}
|
|
326
|
+
function optionalFiniteNumber(value) {
|
|
327
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
328
|
+
}
|
|
329
|
+
const simulatorInteractionErrorCodeSet = new Set(SIMULATOR_INTERACTION_ERROR_CODES);
|
|
330
|
+
const simulatorScreenshotErrorCodeSet = new Set(SIMULATOR_SCREENSHOT_ERROR_CODES);
|
|
331
|
+
function normalizedInteractionErrorCode(value) {
|
|
332
|
+
if (typeof value === "string" && simulatorInteractionErrorCodeSet.has(value)) {
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
switch (value) {
|
|
336
|
+
case "timeout":
|
|
337
|
+
case "timed_out":
|
|
338
|
+
case "interaction_transport_timeout":
|
|
339
|
+
return "interaction_timeout";
|
|
340
|
+
case "cancelled":
|
|
341
|
+
return "interaction_cancelled";
|
|
342
|
+
case "invalid_request":
|
|
343
|
+
return "invalid_interaction";
|
|
344
|
+
case "simulator_session_ended":
|
|
345
|
+
return "active_session_not_found";
|
|
346
|
+
case "unsupported_on_platform":
|
|
347
|
+
return "unsupported_action";
|
|
348
|
+
case "invalid_host_response":
|
|
349
|
+
case "invalid_interaction_result":
|
|
350
|
+
case "interaction_platform_mismatch":
|
|
351
|
+
return "interaction_invalid_response";
|
|
352
|
+
default:
|
|
353
|
+
return "interaction_failed";
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function normalizedScreenshotErrorCode(value) {
|
|
357
|
+
return typeof value === "string" && simulatorScreenshotErrorCodeSet.has(value)
|
|
358
|
+
? value
|
|
359
|
+
: "screenshot_failed";
|
|
360
|
+
}
|
|
361
|
+
async function parseApiError(response) {
|
|
51
362
|
const text = await response.text();
|
|
52
363
|
try {
|
|
53
364
|
const parsed = JSON.parse(text);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
365
|
+
const nested = typeof parsed.error === "object" && parsed.error !== null
|
|
366
|
+
? parsed.error
|
|
367
|
+
: undefined;
|
|
368
|
+
const message = nested?.message ?? parsed.detail ?? parsed.error ?? parsed.message;
|
|
369
|
+
const detail = typeof message === "string"
|
|
370
|
+
? message
|
|
371
|
+
: message !== undefined
|
|
372
|
+
? JSON.stringify(message)
|
|
373
|
+
: response.statusText;
|
|
374
|
+
const interactionStatus = parsed.status === "failed"
|
|
375
|
+
|| parsed.status === "cancelled"
|
|
376
|
+
|| parsed.status === "timed_out"
|
|
377
|
+
? parsed.status
|
|
378
|
+
: undefined;
|
|
379
|
+
const platform = parsed.platform === "ios" || parsed.platform === "android"
|
|
380
|
+
? parsed.platform
|
|
381
|
+
: undefined;
|
|
382
|
+
const retryable = nested?.retryable ?? parsed.retryable;
|
|
383
|
+
const action = optionalString(parsed.action);
|
|
384
|
+
const rawCode = nested?.code ?? parsed.code;
|
|
385
|
+
const code = action === "screenshot"
|
|
386
|
+
? normalizedScreenshotErrorCode(rawCode)
|
|
387
|
+
: action !== undefined || interactionStatus !== undefined
|
|
388
|
+
? normalizedInteractionErrorCode(rawCode)
|
|
389
|
+
: optionalString(rawCode);
|
|
390
|
+
return {
|
|
391
|
+
detail,
|
|
392
|
+
code,
|
|
393
|
+
retryable: typeof retryable === "boolean" ? retryable : undefined,
|
|
394
|
+
details: nested?.details ?? parsed.details,
|
|
395
|
+
requestId: optionalString(parsed.requestId),
|
|
396
|
+
sessionId: optionalString(parsed.sessionId),
|
|
397
|
+
platform,
|
|
398
|
+
action,
|
|
399
|
+
interactionStatus,
|
|
400
|
+
acceptedAt: optionalString(parsed.acceptedAt),
|
|
401
|
+
completedAt: optionalString(parsed.completedAt),
|
|
402
|
+
durationMs: optionalFiniteNumber(parsed.durationMs),
|
|
403
|
+
};
|
|
60
404
|
}
|
|
61
405
|
catch {
|
|
62
|
-
|
|
406
|
+
if (/<(?:!doctype|html)\b/i.test(text)) {
|
|
407
|
+
return {
|
|
408
|
+
detail: "the API edge returned an HTML error page; retry the request or use the streaming/direct-transfer API",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return { detail: text || response.statusText };
|
|
63
412
|
}
|
|
64
|
-
|
|
65
|
-
|
|
413
|
+
}
|
|
414
|
+
async function apiError(response, fallback = {}) {
|
|
415
|
+
const { detail, ...options } = await parseApiError(response);
|
|
416
|
+
const merged = { ...fallback };
|
|
417
|
+
for (const [key, value] of Object.entries(options)) {
|
|
418
|
+
if (value !== undefined)
|
|
419
|
+
Object.assign(merged, { [key]: value });
|
|
66
420
|
}
|
|
67
|
-
|
|
421
|
+
if (merged.action !== undefined && merged.retryable === undefined) {
|
|
422
|
+
merged.retryable = response.status === 408 || response.status === 429 || response.status >= 500;
|
|
423
|
+
}
|
|
424
|
+
return new RunCloudError(response.status, detail, merged);
|
|
68
425
|
}
|
|
69
426
|
async function md5Hex(blob) {
|
|
70
427
|
const hash = createHash("md5");
|
|
@@ -126,24 +483,53 @@ export class Client {
|
|
|
126
483
|
meteringStatus: account.meteringStatus ?? account.run_cloud_metering_status,
|
|
127
484
|
};
|
|
128
485
|
}
|
|
129
|
-
async request(method, path, body, headers) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
486
|
+
async request(method, path, body, headers, abortOptions = {}) {
|
|
487
|
+
const operation = requestAbort(abortOptions);
|
|
488
|
+
try {
|
|
489
|
+
const response = await this.fetchImpl(`${this.apiUrl}${path}`, {
|
|
490
|
+
method,
|
|
491
|
+
headers: {
|
|
492
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
493
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
494
|
+
...headers,
|
|
495
|
+
},
|
|
496
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
497
|
+
signal: operation.signal,
|
|
498
|
+
});
|
|
499
|
+
if (!response.ok)
|
|
500
|
+
throw await apiError(response, abortOptions.errorContext);
|
|
501
|
+
if (response.status === 204)
|
|
502
|
+
return undefined;
|
|
503
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
504
|
+
return contentType.includes("application/json")
|
|
505
|
+
? (await response.json())
|
|
506
|
+
: (await response.text());
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
if (operation.timedOut()) {
|
|
510
|
+
throw new RunCloudError(0, abortOptions.timeoutMessage ?? "the request timed out", {
|
|
511
|
+
...abortOptions.timeoutContext,
|
|
512
|
+
code: abortOptions.timeoutCode ?? "interaction_timeout",
|
|
513
|
+
retryable: true,
|
|
514
|
+
interactionStatus: "timed_out",
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
if (abortOptions.errorContext
|
|
518
|
+
&& !abortOptions.signal?.aborted
|
|
519
|
+
&& error instanceof TypeError) {
|
|
520
|
+
throw new RunCloudError(0, abortOptions.transportErrorMessage
|
|
521
|
+
?? "simulator interaction could not reach the run.cloud API", {
|
|
522
|
+
...abortOptions.errorContext,
|
|
523
|
+
code: abortOptions.transportErrorCode ?? "interaction_transport_error",
|
|
524
|
+
retryable: true,
|
|
525
|
+
interactionStatus: "failed",
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
operation.cleanup();
|
|
532
|
+
}
|
|
147
533
|
}
|
|
148
534
|
async uploadForm(path, form) {
|
|
149
535
|
const response = await this.fetchImpl(`${this.apiUrl}${path}`, {
|
|
@@ -152,17 +538,48 @@ export class Client {
|
|
|
152
538
|
body: form,
|
|
153
539
|
});
|
|
154
540
|
if (!response.ok)
|
|
155
|
-
throw
|
|
541
|
+
throw await apiError(response);
|
|
156
542
|
return (await response.json());
|
|
157
543
|
}
|
|
158
|
-
async requestBytes(path) {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
544
|
+
async requestBytes(path, abortOptions = {}) {
|
|
545
|
+
const operation = requestAbort(abortOptions);
|
|
546
|
+
try {
|
|
547
|
+
const response = await this.fetchImpl(`${this.apiUrl}${path}`, {
|
|
548
|
+
method: "GET",
|
|
549
|
+
headers: {
|
|
550
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
551
|
+
...abortOptions.headers,
|
|
552
|
+
},
|
|
553
|
+
signal: operation.signal,
|
|
554
|
+
});
|
|
555
|
+
if (!response.ok)
|
|
556
|
+
throw await apiError(response, abortOptions.errorContext);
|
|
557
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
if (operation.timedOut()) {
|
|
561
|
+
throw new RunCloudError(0, abortOptions.timeoutMessage ?? "the request timed out", {
|
|
562
|
+
...abortOptions.timeoutContext,
|
|
563
|
+
code: abortOptions.timeoutCode ?? "screenshot_timeout",
|
|
564
|
+
retryable: true,
|
|
565
|
+
interactionStatus: "timed_out",
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
if (abortOptions.errorContext
|
|
569
|
+
&& !abortOptions.signal?.aborted
|
|
570
|
+
&& error instanceof TypeError) {
|
|
571
|
+
throw new RunCloudError(0, abortOptions.transportErrorMessage ?? "simulator screenshot could not reach the run.cloud API", {
|
|
572
|
+
...abortOptions.errorContext,
|
|
573
|
+
code: abortOptions.transportErrorCode ?? "screenshot_transport_error",
|
|
574
|
+
retryable: true,
|
|
575
|
+
interactionStatus: "failed",
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
throw error;
|
|
579
|
+
}
|
|
580
|
+
finally {
|
|
581
|
+
operation.cleanup();
|
|
582
|
+
}
|
|
166
583
|
}
|
|
167
584
|
async *requestTextStream(path, signal) {
|
|
168
585
|
const response = await this.fetchImpl(`${this.apiUrl}${path}`, {
|
|
@@ -174,7 +591,7 @@ export class Client {
|
|
|
174
591
|
signal,
|
|
175
592
|
});
|
|
176
593
|
if (!response.ok)
|
|
177
|
-
throw
|
|
594
|
+
throw await apiError(response);
|
|
178
595
|
if (!response.body)
|
|
179
596
|
throw new RunCloudError(0, "simulator log stream returned no body");
|
|
180
597
|
const contentType = response.headers.get("content-type") ?? "";
|
|
@@ -205,7 +622,7 @@ export class Client {
|
|
|
205
622
|
async putSigned(url, body, headers) {
|
|
206
623
|
const response = await this.fetchImpl(url, { method: "PUT", headers, body });
|
|
207
624
|
if (!response.ok)
|
|
208
|
-
throw
|
|
625
|
+
throw await apiError(response);
|
|
209
626
|
}
|
|
210
627
|
async execStream(path, request, callbacks = {}) {
|
|
211
628
|
const url = new URL(`${this.apiUrl}${path}`);
|
|
@@ -336,6 +753,150 @@ export class SimulatorClient {
|
|
|
336
753
|
async openUrl(id, url) {
|
|
337
754
|
return await this.client.request("POST", `/run-cloud/${this.platform}/${encodeURIComponent(id)}/open-url`, { url });
|
|
338
755
|
}
|
|
756
|
+
async interact(id, interaction, options = {}) {
|
|
757
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SIMULATOR_INTERACTION_TIMEOUT_MS;
|
|
758
|
+
const requestId = options.requestId ?? `interaction_${randomUUID()}`;
|
|
759
|
+
if (!REQUEST_ID_PATTERN.test(requestId)) {
|
|
760
|
+
throw new TypeError("requestId must match /^[A-Za-z0-9._:-]{1,128}$/");
|
|
761
|
+
}
|
|
762
|
+
validateSimulatorInteraction(interaction, timeoutMs);
|
|
763
|
+
return await this.client.request("POST", `/run-cloud/${this.platform}/${encodeURIComponent(id)}/interactions`, { ...interaction, requestId, timeoutMs }, undefined, {
|
|
764
|
+
signal: options.signal,
|
|
765
|
+
timeoutMs: timeoutMs + SIMULATOR_RESPONSE_MARGIN_MS,
|
|
766
|
+
timeoutMessage: `simulator ${interaction.action} did not complete within ${timeoutMs} ms`,
|
|
767
|
+
timeoutCode: "interaction_timeout",
|
|
768
|
+
transportErrorCode: "interaction_transport_error",
|
|
769
|
+
timeoutContext: {
|
|
770
|
+
requestId,
|
|
771
|
+
sessionId: id,
|
|
772
|
+
platform: this.platform,
|
|
773
|
+
action: interaction.action,
|
|
774
|
+
},
|
|
775
|
+
errorContext: {
|
|
776
|
+
requestId,
|
|
777
|
+
sessionId: id,
|
|
778
|
+
platform: this.platform,
|
|
779
|
+
action: interaction.action,
|
|
780
|
+
code: "interaction_failed",
|
|
781
|
+
interactionStatus: "failed",
|
|
782
|
+
},
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
async tap(id, point, options = {}) {
|
|
786
|
+
return await this.interact(id, { action: "tap", ...point }, options);
|
|
787
|
+
}
|
|
788
|
+
async swipe(id, from, to, options = {}) {
|
|
789
|
+
const { durationMs, ...requestOptions } = options;
|
|
790
|
+
return await this.interact(id, { action: "swipe", from, to, ...(durationMs === undefined ? {} : { durationMs }) }, requestOptions);
|
|
791
|
+
}
|
|
792
|
+
async gesture(id, steps, options = {}) {
|
|
793
|
+
return await this.interact(id, { action: "gesture", steps }, options);
|
|
794
|
+
}
|
|
795
|
+
async typeText(id, text, options = {}) {
|
|
796
|
+
return await this.interact(id, { action: "typeText", text }, options);
|
|
797
|
+
}
|
|
798
|
+
async pressKey(id, key, options = {}) {
|
|
799
|
+
const { modifiers, durationMs, ...requestOptions } = options;
|
|
800
|
+
return await this.interact(id, {
|
|
801
|
+
action: "pressKey",
|
|
802
|
+
key,
|
|
803
|
+
...(modifiers?.length ? { modifiers } : {}),
|
|
804
|
+
...(durationMs === undefined ? {} : { durationMs }),
|
|
805
|
+
}, requestOptions);
|
|
806
|
+
}
|
|
807
|
+
async pressButton(id, button, options = {}) {
|
|
808
|
+
const { durationMs, ...requestOptions } = options;
|
|
809
|
+
return await this.interact(id, {
|
|
810
|
+
action: "pressButton",
|
|
811
|
+
button,
|
|
812
|
+
...(durationMs === undefined ? {} : { durationMs }),
|
|
813
|
+
}, requestOptions);
|
|
814
|
+
}
|
|
815
|
+
async rotate(id, orientation, options = {}) {
|
|
816
|
+
return await this.interact(id, { action: "rotate", orientation }, options);
|
|
817
|
+
}
|
|
818
|
+
async reload(id, options = {}) {
|
|
819
|
+
return await this.interact(id, { action: "reload" }, options);
|
|
820
|
+
}
|
|
821
|
+
async scroll(id, input, options = {}) {
|
|
822
|
+
return await this.interact(id, { action: "scroll", ...input }, options);
|
|
823
|
+
}
|
|
824
|
+
async toggleSoftwareKeyboard(id, options = {}) {
|
|
825
|
+
return await this.interact(id, { action: "toggleSoftwareKeyboard" }, options);
|
|
826
|
+
}
|
|
827
|
+
async simulateMemoryWarning(id, options = {}) {
|
|
828
|
+
return await this.interact(id, { action: "simulateMemoryWarning" }, options);
|
|
829
|
+
}
|
|
830
|
+
async rotateDigitalCrown(id, delta, options = {}) {
|
|
831
|
+
return await this.interact(id, { action: "rotateDigitalCrown", delta }, options);
|
|
832
|
+
}
|
|
833
|
+
async setRenderDebug(id, option, enabled, options = {}) {
|
|
834
|
+
return await this.interact(id, { action: "setRenderDebug", option, enabled }, options);
|
|
835
|
+
}
|
|
836
|
+
async screenshot(id, options = {}) {
|
|
837
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SIMULATOR_INTERACTION_TIMEOUT_MS;
|
|
838
|
+
const requestId = options.requestId ?? `screenshot_${randomUUID()}`;
|
|
839
|
+
if (!REQUEST_ID_PATTERN.test(requestId)) {
|
|
840
|
+
throw new TypeError("requestId must match /^[A-Za-z0-9._:-]{1,128}$/");
|
|
841
|
+
}
|
|
842
|
+
assertIntegerRange(timeoutMs, 100, 60_000, "timeoutMs");
|
|
843
|
+
const context = {
|
|
844
|
+
requestId,
|
|
845
|
+
sessionId: id,
|
|
846
|
+
platform: this.platform,
|
|
847
|
+
action: "screenshot",
|
|
848
|
+
};
|
|
849
|
+
const bytes = await this.client.requestBytes(`/run-cloud/${this.platform}/${encodeURIComponent(id)}/screenshot`, {
|
|
850
|
+
signal: options.signal,
|
|
851
|
+
timeoutMs: timeoutMs + SIMULATOR_RESPONSE_MARGIN_MS,
|
|
852
|
+
timeoutMessage: `simulator screenshot did not complete within ${timeoutMs} ms`,
|
|
853
|
+
timeoutCode: "screenshot_timeout",
|
|
854
|
+
transportErrorCode: "screenshot_transport_error",
|
|
855
|
+
transportErrorMessage: "simulator screenshot could not reach the run.cloud API",
|
|
856
|
+
headers: { "X-Run-Cloud-Request-ID": requestId },
|
|
857
|
+
timeoutContext: context,
|
|
858
|
+
errorContext: {
|
|
859
|
+
...context,
|
|
860
|
+
code: "screenshot_failed",
|
|
861
|
+
interactionStatus: "failed",
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
865
|
+
if (pngSignature.some((byte, index) => bytes[index] !== byte)) {
|
|
866
|
+
throw new RunCloudError(502, "simulator screenshot response was not a valid PNG image", {
|
|
867
|
+
...context,
|
|
868
|
+
code: "screenshot_invalid_response",
|
|
869
|
+
retryable: true,
|
|
870
|
+
interactionStatus: "failed",
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
return bytes;
|
|
874
|
+
}
|
|
875
|
+
async startRecording(id, options = {}) {
|
|
876
|
+
const idempotencyKey = validatedRecordingIdempotencyKey(options.idempotencyKey);
|
|
877
|
+
return await this.client.request("POST", `/run-cloud/${this.platform}/${encodeURIComponent(id)}/recordings`, undefined, idempotencyKey ? { "Idempotency-Key": idempotencyKey } : undefined, { signal: options.signal });
|
|
878
|
+
}
|
|
879
|
+
async listRecordings(id, options = {}) {
|
|
880
|
+
return await this.client.request("GET", `/run-cloud/${this.platform}/${encodeURIComponent(id)}/recordings`, undefined, undefined, { signal: options.signal });
|
|
881
|
+
}
|
|
882
|
+
async getRecording(id, recordingId, options = {}) {
|
|
883
|
+
return await this.client.request("GET", `/run-cloud/${this.platform}/${encodeURIComponent(id)}/recordings/${encodeURIComponent(recordingId)}`, undefined, undefined, { signal: options.signal });
|
|
884
|
+
}
|
|
885
|
+
async stopRecording(id, recordingId, options = {}) {
|
|
886
|
+
return await this.client.request("POST", `/run-cloud/${this.platform}/${encodeURIComponent(id)}/recordings/${encodeURIComponent(recordingId)}/stop`, undefined, undefined, { signal: options.signal });
|
|
887
|
+
}
|
|
888
|
+
async downloadRecording(id, recordingId, options = {}) {
|
|
889
|
+
const bytes = await this.client.requestBytes(`/run-cloud/${this.platform}/${encodeURIComponent(id)}/recordings/${encodeURIComponent(recordingId)}/download`, { signal: options.signal });
|
|
890
|
+
if (bytes.length < 8 || String.fromCharCode(...bytes.subarray(4, 8)) !== "ftyp") {
|
|
891
|
+
throw new RunCloudError(502, "simulator recording response was not a valid MP4 file", {
|
|
892
|
+
code: "recording_invalid_response",
|
|
893
|
+
retryable: true,
|
|
894
|
+
sessionId: id,
|
|
895
|
+
platform: this.platform,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return bytes;
|
|
899
|
+
}
|
|
339
900
|
async logs(id, options = {}) {
|
|
340
901
|
const tail = options.tail ?? 200;
|
|
341
902
|
if (!Number.isInteger(tail) || tail < 1 || tail > 1_000) {
|
|
@@ -385,9 +946,6 @@ export class IosSimulatorClient extends SimulatorClient {
|
|
|
385
946
|
constructor(client) {
|
|
386
947
|
super(client, "ios");
|
|
387
948
|
}
|
|
388
|
-
async screenshot(id) {
|
|
389
|
-
return await this.client.requestBytes(`/run-cloud/ios/${encodeURIComponent(id)}/screenshot`);
|
|
390
|
-
}
|
|
391
949
|
async uploadVideo(id, video, options = {}) {
|
|
392
950
|
const contentType = video.type.toLowerCase();
|
|
393
951
|
if (contentType !== "video/mp4" && contentType !== "video/quicktime") {
|
|
@@ -466,6 +1024,86 @@ export class SimulatorsClient {
|
|
|
466
1024
|
async openUrl(id, url, options = {}) {
|
|
467
1025
|
return await new SimulatorClient(this.client, options.platform ?? "ios").openUrl(id, url);
|
|
468
1026
|
}
|
|
1027
|
+
async interact(id, interaction, options = {}) {
|
|
1028
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1029
|
+
return await new SimulatorClient(this.client, platform).interact(id, interaction, requestOptions);
|
|
1030
|
+
}
|
|
1031
|
+
async tap(id, point, options = {}) {
|
|
1032
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1033
|
+
return await new SimulatorClient(this.client, platform).tap(id, point, requestOptions);
|
|
1034
|
+
}
|
|
1035
|
+
async swipe(id, from, to, options = {}) {
|
|
1036
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1037
|
+
return await new SimulatorClient(this.client, platform).swipe(id, from, to, requestOptions);
|
|
1038
|
+
}
|
|
1039
|
+
async gesture(id, steps, options = {}) {
|
|
1040
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1041
|
+
return await new SimulatorClient(this.client, platform).gesture(id, steps, requestOptions);
|
|
1042
|
+
}
|
|
1043
|
+
async typeText(id, text, options = {}) {
|
|
1044
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1045
|
+
return await new SimulatorClient(this.client, platform).typeText(id, text, requestOptions);
|
|
1046
|
+
}
|
|
1047
|
+
async pressKey(id, key, options = {}) {
|
|
1048
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1049
|
+
return await new SimulatorClient(this.client, platform).pressKey(id, key, requestOptions);
|
|
1050
|
+
}
|
|
1051
|
+
async pressButton(id, button, options = {}) {
|
|
1052
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1053
|
+
return await new SimulatorClient(this.client, platform).pressButton(id, button, requestOptions);
|
|
1054
|
+
}
|
|
1055
|
+
async rotate(id, orientation, options = {}) {
|
|
1056
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1057
|
+
return await new SimulatorClient(this.client, platform).rotate(id, orientation, requestOptions);
|
|
1058
|
+
}
|
|
1059
|
+
async reload(id, options = {}) {
|
|
1060
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1061
|
+
return await new SimulatorClient(this.client, platform).reload(id, requestOptions);
|
|
1062
|
+
}
|
|
1063
|
+
async scroll(id, input, options = {}) {
|
|
1064
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1065
|
+
return await new SimulatorClient(this.client, platform).scroll(id, input, requestOptions);
|
|
1066
|
+
}
|
|
1067
|
+
async toggleSoftwareKeyboard(id, options = {}) {
|
|
1068
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1069
|
+
return await new SimulatorClient(this.client, platform).toggleSoftwareKeyboard(id, requestOptions);
|
|
1070
|
+
}
|
|
1071
|
+
async simulateMemoryWarning(id, options = {}) {
|
|
1072
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1073
|
+
return await new SimulatorClient(this.client, platform).simulateMemoryWarning(id, requestOptions);
|
|
1074
|
+
}
|
|
1075
|
+
async rotateDigitalCrown(id, delta, options = {}) {
|
|
1076
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1077
|
+
return await new SimulatorClient(this.client, platform).rotateDigitalCrown(id, delta, requestOptions);
|
|
1078
|
+
}
|
|
1079
|
+
async setRenderDebug(id, option, enabled, options = {}) {
|
|
1080
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1081
|
+
return await new SimulatorClient(this.client, platform).setRenderDebug(id, option, enabled, requestOptions);
|
|
1082
|
+
}
|
|
1083
|
+
async screenshot(id, options = {}) {
|
|
1084
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1085
|
+
return await new SimulatorClient(this.client, platform).screenshot(id, requestOptions);
|
|
1086
|
+
}
|
|
1087
|
+
async startRecording(id, options = {}) {
|
|
1088
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1089
|
+
return await new SimulatorClient(this.client, platform).startRecording(id, requestOptions);
|
|
1090
|
+
}
|
|
1091
|
+
async listRecordings(id, options = {}) {
|
|
1092
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1093
|
+
return await new SimulatorClient(this.client, platform).listRecordings(id, requestOptions);
|
|
1094
|
+
}
|
|
1095
|
+
async getRecording(id, recordingId, options = {}) {
|
|
1096
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1097
|
+
return await new SimulatorClient(this.client, platform).getRecording(id, recordingId, requestOptions);
|
|
1098
|
+
}
|
|
1099
|
+
async stopRecording(id, recordingId, options = {}) {
|
|
1100
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1101
|
+
return await new SimulatorClient(this.client, platform).stopRecording(id, recordingId, requestOptions);
|
|
1102
|
+
}
|
|
1103
|
+
async downloadRecording(id, recordingId, options = {}) {
|
|
1104
|
+
const { platform = "ios", ...requestOptions } = options;
|
|
1105
|
+
return await new SimulatorClient(this.client, platform).downloadRecording(id, recordingId, requestOptions);
|
|
1106
|
+
}
|
|
469
1107
|
}
|
|
470
1108
|
export class SandboxClient {
|
|
471
1109
|
client;
|