@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/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
- constructor(status, detail) {
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
- async function errorDetail(response) {
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
- for (const value of [parsed.detail, parsed.error, parsed.message]) {
55
- if (typeof value === "string")
56
- return value;
57
- if (value !== undefined)
58
- return JSON.stringify(value);
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
- // plain text
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
- if (/<(?:!doctype|html)\b/i.test(text)) {
65
- return "the API edge returned an HTML error page; retry the request or use the streaming/direct-transfer API";
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
- return text || response.statusText;
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 response = await this.fetchImpl(`${this.apiUrl}${path}`, {
131
- method,
132
- headers: {
133
- Authorization: `Bearer ${this.apiKey}`,
134
- ...(body === undefined ? {} : { "Content-Type": "application/json" }),
135
- ...headers,
136
- },
137
- body: body === undefined ? undefined : JSON.stringify(body),
138
- });
139
- if (!response.ok)
140
- throw new RunCloudError(response.status, await errorDetail(response));
141
- if (response.status === 204)
142
- return undefined;
143
- const contentType = response.headers.get("content-type") ?? "";
144
- return contentType.includes("application/json")
145
- ? (await response.json())
146
- : (await response.text());
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 new RunCloudError(response.status, await errorDetail(response));
541
+ throw await apiError(response);
156
542
  return (await response.json());
157
543
  }
158
- async requestBytes(path) {
159
- const response = await this.fetchImpl(`${this.apiUrl}${path}`, {
160
- method: "GET",
161
- headers: { Authorization: `Bearer ${this.apiKey}` },
162
- });
163
- if (!response.ok)
164
- throw new RunCloudError(response.status, await errorDetail(response));
165
- return new Uint8Array(await response.arrayBuffer());
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 new RunCloudError(response.status, await errorDetail(response));
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 new RunCloudError(response.status, await errorDetail(response));
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;