runcloud 0.1.107 → 0.1.109
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 +23 -0
- package/dist/api.js +111 -37
- package/dist/commands/run-cloud.js +591 -8
- package/dist/version.js +1 -1
- package/examples/eight-device-mosaic/demo.mjs +12 -1
- package/examples/eight-device-mosaic/verify.mjs +5 -2
- package/examples/live-camera-relay/demo.mjs +8 -1
- package/examples/live-camera-relay/verify.mjs +5 -2
- package/package.json +1 -1
- package/runcloud-0.1.109.tgz +0 -0
- package/skills/run-cloud-ios-simulator/SKILL.md +101 -126
|
@@ -3,9 +3,91 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFil
|
|
|
3
3
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
-
import { createHash } from 'node:crypto';
|
|
7
|
-
import { ApiClient, friendlyApiError } from '../api.js';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { ApiClient, ApiError, ApiTransportError, friendlyApiError } from '../api.js';
|
|
8
8
|
import { requireCredentials } from '../config.js';
|
|
9
|
+
export const SIMULATOR_KEYS = [
|
|
10
|
+
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
|
|
11
|
+
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
|
|
12
|
+
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
|
13
|
+
'enter', 'escape', 'backspace', 'tab', 'space', 'minus', 'equal',
|
|
14
|
+
'bracketLeft', 'bracketRight', 'backslash', 'semicolon', 'quote', 'backquote',
|
|
15
|
+
'comma', 'period', 'slash', 'capsLock',
|
|
16
|
+
'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12',
|
|
17
|
+
'printScreen', 'scrollLock', 'pause', 'insert', 'home', 'pageUp', 'delete', 'end',
|
|
18
|
+
'pageDown', 'arrowRight', 'arrowLeft', 'arrowDown', 'arrowUp', 'numLock',
|
|
19
|
+
'numpadDivide', 'numpadMultiply', 'numpadSubtract', 'numpadAdd', 'numpadEnter',
|
|
20
|
+
'numpad0', 'numpad1', 'numpad2', 'numpad3', 'numpad4', 'numpad5', 'numpad6',
|
|
21
|
+
'numpad7', 'numpad8', 'numpad9', 'numpadDecimal',
|
|
22
|
+
];
|
|
23
|
+
export const SIMULATOR_BUTTONS = [
|
|
24
|
+
'home', 'back', 'appSwitcher', 'recents', 'power', 'volumeUp', 'volumeDown',
|
|
25
|
+
'menu', 'sideButton', 'actionButton', 'digitalCrown',
|
|
26
|
+
];
|
|
27
|
+
export const SIMULATOR_RENDER_DEBUG_OPTIONS = [
|
|
28
|
+
'colorBlendedLayers',
|
|
29
|
+
'colorCopiedImages',
|
|
30
|
+
'colorMisalignedImages',
|
|
31
|
+
'colorOffscreenRendered',
|
|
32
|
+
'slowAnimations',
|
|
33
|
+
];
|
|
34
|
+
export const SIMULATOR_INTERACTION_ERROR_CODES = [
|
|
35
|
+
'invalid_interaction',
|
|
36
|
+
'active_session_not_found',
|
|
37
|
+
'simulator_capacity_unavailable',
|
|
38
|
+
'unsupported_action',
|
|
39
|
+
'duplicate_request',
|
|
40
|
+
'interaction_cancelled',
|
|
41
|
+
'interaction_timeout',
|
|
42
|
+
'interaction_transport_error',
|
|
43
|
+
'interaction_invalid_response',
|
|
44
|
+
'interaction_failed',
|
|
45
|
+
];
|
|
46
|
+
export const SIMULATOR_SCREENSHOT_ERROR_CODES = [
|
|
47
|
+
'invalid_screenshot_request',
|
|
48
|
+
'active_session_not_found',
|
|
49
|
+
'simulator_capacity_unavailable',
|
|
50
|
+
'screenshot_cancelled',
|
|
51
|
+
'screenshot_timeout',
|
|
52
|
+
'screenshot_transport_error',
|
|
53
|
+
'screenshot_invalid_response',
|
|
54
|
+
'screenshot_failed',
|
|
55
|
+
];
|
|
56
|
+
const simulatorInteractionErrorCodeSet = new Set(SIMULATOR_INTERACTION_ERROR_CODES);
|
|
57
|
+
const simulatorScreenshotErrorCodeSet = new Set(SIMULATOR_SCREENSHOT_ERROR_CODES);
|
|
58
|
+
function normalizedInteractionErrorCode(value) {
|
|
59
|
+
if (typeof value === 'string' && simulatorInteractionErrorCodeSet.has(value)) {
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
switch (value) {
|
|
63
|
+
case 'timeout':
|
|
64
|
+
case 'timed_out':
|
|
65
|
+
case 'interaction_transport_timeout':
|
|
66
|
+
return 'interaction_timeout';
|
|
67
|
+
case 'cancelled':
|
|
68
|
+
return 'interaction_cancelled';
|
|
69
|
+
case 'invalid_request':
|
|
70
|
+
return 'invalid_interaction';
|
|
71
|
+
case 'simulator_session_ended':
|
|
72
|
+
return 'active_session_not_found';
|
|
73
|
+
case 'unsupported_on_platform':
|
|
74
|
+
return 'unsupported_action';
|
|
75
|
+
case 'invalid_host_response':
|
|
76
|
+
case 'invalid_interaction_result':
|
|
77
|
+
case 'interaction_platform_mismatch':
|
|
78
|
+
return 'interaction_invalid_response';
|
|
79
|
+
default:
|
|
80
|
+
return 'interaction_failed';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function normalizedScreenshotErrorCode(value) {
|
|
84
|
+
return typeof value === 'string' && simulatorScreenshotErrorCodeSet.has(value)
|
|
85
|
+
? value
|
|
86
|
+
: 'screenshot_failed';
|
|
87
|
+
}
|
|
88
|
+
const DEFAULT_INTERACTION_TIMEOUT_MS = 15_000;
|
|
89
|
+
const INTERACTION_RESPONSE_MARGIN_MS = 250;
|
|
90
|
+
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
9
91
|
export const RUN_CLOUD_SKILL_NAMES = [
|
|
10
92
|
'run-cloud',
|
|
11
93
|
'run-cloud-ios-simulator',
|
|
@@ -99,7 +181,7 @@ export function consumeSimulatorLogSse(raw) {
|
|
|
99
181
|
function writeSimulatorLogEntry(entry, json) {
|
|
100
182
|
console.log(json ? JSON.stringify(entry) : entry.message);
|
|
101
183
|
}
|
|
102
|
-
function
|
|
184
|
+
function parseTags(labels) {
|
|
103
185
|
const out = {};
|
|
104
186
|
for (const raw of labels ?? []) {
|
|
105
187
|
const idx = raw.indexOf('=');
|
|
@@ -109,6 +191,300 @@ function parseLabels(labels) {
|
|
|
109
191
|
}
|
|
110
192
|
return out;
|
|
111
193
|
}
|
|
194
|
+
function integerInRange(raw, name, min, max, fallback) {
|
|
195
|
+
if (raw === undefined && fallback !== undefined)
|
|
196
|
+
return fallback;
|
|
197
|
+
const value = Number(raw);
|
|
198
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
199
|
+
throw new Error(`${name} must be an integer from ${min} to ${max}`);
|
|
200
|
+
}
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
function finiteInRange(raw, name, min, max) {
|
|
204
|
+
const value = Number(raw);
|
|
205
|
+
if (!Number.isFinite(value) || value < min || value > max) {
|
|
206
|
+
throw new Error(`${name} must be a number from ${min} to ${max}`);
|
|
207
|
+
}
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
function normalizedPoint(x, y, name) {
|
|
211
|
+
return {
|
|
212
|
+
x: finiteInRange(x, `${name} x`, 0, 1),
|
|
213
|
+
y: finiteInRange(y, `${name} y`, 0, 1),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function simulatorKey(raw) {
|
|
217
|
+
if (SIMULATOR_KEYS.includes(raw))
|
|
218
|
+
return raw;
|
|
219
|
+
throw new Error(`Unknown simulator key: ${raw}. Run with --help to see supported semantic keys.`);
|
|
220
|
+
}
|
|
221
|
+
function simulatorButton(raw) {
|
|
222
|
+
if (SIMULATOR_BUTTONS.includes(raw))
|
|
223
|
+
return raw;
|
|
224
|
+
throw new Error(`Unknown simulator button: ${raw}. Choose one of: ${SIMULATOR_BUTTONS.join(', ')}.`);
|
|
225
|
+
}
|
|
226
|
+
function simulatorOrientation(raw) {
|
|
227
|
+
if (raw === 'portrait' || raw === 'portrait_upside_down' || raw === 'landscape_left' || raw === 'landscape_right') {
|
|
228
|
+
return raw;
|
|
229
|
+
}
|
|
230
|
+
throw new Error(`Unknown orientation: ${raw}. Choose portrait, portrait_upside_down, landscape_left, or landscape_right.`);
|
|
231
|
+
}
|
|
232
|
+
function renderDebugOption(raw) {
|
|
233
|
+
if (SIMULATOR_RENDER_DEBUG_OPTIONS.includes(raw)) {
|
|
234
|
+
return raw;
|
|
235
|
+
}
|
|
236
|
+
throw new Error(`Unknown render debug option: ${raw}. Choose one of: ${SIMULATOR_RENDER_DEBUG_OPTIONS.join(', ')}.`);
|
|
237
|
+
}
|
|
238
|
+
function booleanArgument(raw, name) {
|
|
239
|
+
if (raw === 'true')
|
|
240
|
+
return true;
|
|
241
|
+
if (raw === 'false')
|
|
242
|
+
return false;
|
|
243
|
+
throw new Error(`${name} must be true or false`);
|
|
244
|
+
}
|
|
245
|
+
function isUsAsciiText(text) {
|
|
246
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
247
|
+
const code = text.charCodeAt(index);
|
|
248
|
+
if (code !== 9 && code !== 10 && (code < 32 || code > 126))
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
function assertInteraction(interaction, timeoutMs) {
|
|
254
|
+
switch (interaction.action) {
|
|
255
|
+
case 'tap':
|
|
256
|
+
return;
|
|
257
|
+
case 'swipe':
|
|
258
|
+
if (interaction.durationMs !== undefined) {
|
|
259
|
+
integerInRange(String(interaction.durationMs), '--duration', 50, 30_000);
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
case 'gesture': {
|
|
263
|
+
if (interaction.steps.length < 2 || interaction.steps.length > 1_000) {
|
|
264
|
+
throw new Error('--steps must contain from 2 to 1000 gesture steps');
|
|
265
|
+
}
|
|
266
|
+
let pointCount;
|
|
267
|
+
let totalDelayMs = 0;
|
|
268
|
+
for (const [index, step] of interaction.steps.entries()) {
|
|
269
|
+
if ((index === 0) !== (step.phase === 'begin')) {
|
|
270
|
+
throw new Error('Gesture steps must start with begin and contain no later begin phase');
|
|
271
|
+
}
|
|
272
|
+
if ((index === interaction.steps.length - 1) !== (step.phase === 'end')) {
|
|
273
|
+
throw new Error('Gesture steps must end with end and contain no earlier end phase');
|
|
274
|
+
}
|
|
275
|
+
if (step.points.length !== 1 && step.points.length !== 2) {
|
|
276
|
+
throw new Error('Each gesture step must contain one or two points');
|
|
277
|
+
}
|
|
278
|
+
if (pointCount === undefined)
|
|
279
|
+
pointCount = step.points.length;
|
|
280
|
+
else if (pointCount !== step.points.length) {
|
|
281
|
+
throw new Error('Every gesture step must use the same number of points');
|
|
282
|
+
}
|
|
283
|
+
for (const point of step.points) {
|
|
284
|
+
finiteInRange(String(point.x), 'gesture x', 0, 1);
|
|
285
|
+
finiteInRange(String(point.y), 'gesture y', 0, 1);
|
|
286
|
+
}
|
|
287
|
+
if (step.delayMs !== undefined) {
|
|
288
|
+
if (index === interaction.steps.length - 1 && step.delayMs !== 0) {
|
|
289
|
+
throw new Error('Final gesture end step delayMs must be 0 because there is no next step');
|
|
290
|
+
}
|
|
291
|
+
totalDelayMs += integerInRange(String(step.delayMs), 'gesture delayMs', 0, 5_000);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (totalDelayMs > timeoutMs)
|
|
295
|
+
throw new Error('Gesture delays cannot exceed --timeout');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
case 'typeText':
|
|
299
|
+
if (interaction.text.length < 1 || interaction.text.length > 10_000) {
|
|
300
|
+
throw new Error('text must contain from 1 to 10000 characters');
|
|
301
|
+
}
|
|
302
|
+
if (!isUsAsciiText(interaction.text)) {
|
|
303
|
+
throw new Error('text must contain only US ASCII characters, tabs, and line feeds');
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
case 'pressKey':
|
|
307
|
+
case 'pressButton':
|
|
308
|
+
if (interaction.durationMs !== undefined) {
|
|
309
|
+
integerInRange(String(interaction.durationMs), '--duration', 20, 30_000);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
case 'scroll':
|
|
313
|
+
if (interaction.deltaX === 0 && interaction.deltaY === 0) {
|
|
314
|
+
throw new Error('At least one scroll delta must be nonzero');
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
case 'rotateDigitalCrown':
|
|
318
|
+
if (interaction.delta === 0)
|
|
319
|
+
throw new Error('digital crown delta must be nonzero');
|
|
320
|
+
return;
|
|
321
|
+
default:
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
export function parseSimulatorGestureSteps(raw, timeoutMs = DEFAULT_INTERACTION_TIMEOUT_MS) {
|
|
326
|
+
let value;
|
|
327
|
+
try {
|
|
328
|
+
value = JSON.parse(raw);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
throw new Error('--steps must be a valid JSON array');
|
|
332
|
+
}
|
|
333
|
+
if (!Array.isArray(value))
|
|
334
|
+
throw new Error('--steps must be a JSON array');
|
|
335
|
+
const steps = value.map((item, index) => {
|
|
336
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
337
|
+
throw new Error(`Gesture step ${index} must be an object`);
|
|
338
|
+
}
|
|
339
|
+
const record = item;
|
|
340
|
+
if (record.phase !== 'begin' && record.phase !== 'move' && record.phase !== 'end') {
|
|
341
|
+
throw new Error(`Gesture step ${index} phase must be begin, move, or end`);
|
|
342
|
+
}
|
|
343
|
+
if (!Array.isArray(record.points) || (record.points.length !== 1 && record.points.length !== 2)) {
|
|
344
|
+
throw new Error(`Gesture step ${index} must contain one or two points`);
|
|
345
|
+
}
|
|
346
|
+
const points = record.points.map((point, pointIndex) => {
|
|
347
|
+
if (!point || typeof point !== 'object' || Array.isArray(point)) {
|
|
348
|
+
throw new Error(`Gesture step ${index} point ${pointIndex} must contain x and y`);
|
|
349
|
+
}
|
|
350
|
+
const candidate = point;
|
|
351
|
+
return normalizedPoint(String(candidate.x), String(candidate.y), `gesture step ${index} point ${pointIndex}`);
|
|
352
|
+
});
|
|
353
|
+
const tuple = points.length === 1
|
|
354
|
+
? [points[0]]
|
|
355
|
+
: [points[0], points[1]];
|
|
356
|
+
const delayMs = record.delayMs === undefined
|
|
357
|
+
? undefined
|
|
358
|
+
: integerInRange(String(record.delayMs), `gesture step ${index} delayMs`, 0, 5_000);
|
|
359
|
+
return {
|
|
360
|
+
phase: record.phase,
|
|
361
|
+
points: tuple,
|
|
362
|
+
...(delayMs === undefined ? {} : { delayMs }),
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
assertInteraction({ action: 'gesture', steps }, timeoutMs);
|
|
366
|
+
return steps;
|
|
367
|
+
}
|
|
368
|
+
function interactionTimeout(opts) {
|
|
369
|
+
return integerInRange(opts.timeout, '--timeout', 100, 60_000, DEFAULT_INTERACTION_TIMEOUT_MS);
|
|
370
|
+
}
|
|
371
|
+
function interactionRequestId(opts) {
|
|
372
|
+
const requestId = opts.requestId?.trim() || `cli_${randomUUID()}`;
|
|
373
|
+
if (!REQUEST_ID_PATTERN.test(requestId)) {
|
|
374
|
+
throw new Error('--request-id must match /^[A-Za-z0-9._:-]{1,128}$/');
|
|
375
|
+
}
|
|
376
|
+
return requestId;
|
|
377
|
+
}
|
|
378
|
+
class CliInteractionError extends Error {
|
|
379
|
+
code;
|
|
380
|
+
requestId;
|
|
381
|
+
actionName;
|
|
382
|
+
sessionId;
|
|
383
|
+
platform;
|
|
384
|
+
constructor(message, code, requestId, actionName, sessionId, platform) {
|
|
385
|
+
super(message);
|
|
386
|
+
this.code = code;
|
|
387
|
+
this.requestId = requestId;
|
|
388
|
+
this.actionName = actionName;
|
|
389
|
+
this.sessionId = sessionId;
|
|
390
|
+
this.platform = platform;
|
|
391
|
+
this.name = 'CliInteractionError';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
async function withInteractionCancellation(timeoutMs, requestId, actionName, sessionId, platform, operation, run) {
|
|
395
|
+
const controller = new AbortController();
|
|
396
|
+
const onSigint = () => controller.abort(new CliInteractionError(`Simulator ${operation === 'screenshot' ? 'screenshot' : 'interaction'} cancelled by SIGINT`, operation === 'screenshot' ? 'screenshot_cancelled' : 'interaction_cancelled', requestId, actionName, sessionId, platform));
|
|
397
|
+
process.once('SIGINT', onSigint);
|
|
398
|
+
const timer = setTimeout(() => controller.abort(new CliInteractionError(`Simulator ${actionName} did not complete within ${timeoutMs} ms`, operation === 'screenshot' ? 'screenshot_timeout' : 'interaction_timeout', requestId, actionName, sessionId, platform)), timeoutMs + INTERACTION_RESPONSE_MARGIN_MS);
|
|
399
|
+
try {
|
|
400
|
+
return await run(controller.signal);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
if (controller.signal.aborted && controller.signal.reason instanceof CliInteractionError) {
|
|
404
|
+
throw controller.signal.reason;
|
|
405
|
+
}
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
clearTimeout(timer);
|
|
410
|
+
process.removeListener('SIGINT', onSigint);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function addInteractionOptions(command) {
|
|
414
|
+
return command
|
|
415
|
+
.option('--timeout <milliseconds>', 'acknowledgement timeout in milliseconds (100-60000)', String(DEFAULT_INTERACTION_TIMEOUT_MS))
|
|
416
|
+
.option('--request-id <id>', 'caller-supplied correlation id')
|
|
417
|
+
.option('--json', 'output a stable JSON result or error envelope', false);
|
|
418
|
+
}
|
|
419
|
+
async function runSimulatorInteraction(platform, id, interaction, opts) {
|
|
420
|
+
const timeoutMs = interactionTimeout(opts);
|
|
421
|
+
const requestId = interactionRequestId(opts);
|
|
422
|
+
assertInteraction(interaction, timeoutMs);
|
|
423
|
+
let result;
|
|
424
|
+
try {
|
|
425
|
+
result = await withInteractionCancellation(timeoutMs, requestId, interaction.action, id, platform, 'interaction', async (signal) => await client().post(`/run-cloud/${platform}/${encodeURIComponent(id)}/interactions`, { ...interaction, requestId, timeoutMs }, { signal }));
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
if (error instanceof ApiError) {
|
|
429
|
+
Object.assign(error, {
|
|
430
|
+
code: normalizedInteractionErrorCode(error.code),
|
|
431
|
+
requestId: error.requestId ?? requestId,
|
|
432
|
+
sessionId: error.sessionId ?? id,
|
|
433
|
+
platform: error.platform ?? platform,
|
|
434
|
+
action: error.action ?? interaction.action,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
if (error instanceof ApiTransportError) {
|
|
438
|
+
throw new CliInteractionError(`Simulator ${interaction.action} could not reach the run.cloud API`, 'interaction_transport_error', requestId, interaction.action, id, platform);
|
|
439
|
+
}
|
|
440
|
+
throw error;
|
|
441
|
+
}
|
|
442
|
+
print(result, opts);
|
|
443
|
+
}
|
|
444
|
+
async function captureSimulatorScreenshot(platform, id, opts) {
|
|
445
|
+
const timeoutMs = interactionTimeout(opts);
|
|
446
|
+
const requestId = interactionRequestId(opts);
|
|
447
|
+
let bytes;
|
|
448
|
+
try {
|
|
449
|
+
bytes = await withInteractionCancellation(timeoutMs, requestId, 'screenshot', id, platform, 'screenshot', async (signal) => await client().getBinary(`/run-cloud/${platform}/${encodeURIComponent(id)}/screenshot`, { signal, headers: { 'X-Run-Cloud-Request-ID': requestId } }));
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (error instanceof ApiError) {
|
|
453
|
+
Object.assign(error, {
|
|
454
|
+
code: normalizedScreenshotErrorCode(error.code),
|
|
455
|
+
requestId: error.requestId ?? requestId,
|
|
456
|
+
sessionId: error.sessionId ?? id,
|
|
457
|
+
platform: error.platform ?? platform,
|
|
458
|
+
action: error.action ?? 'screenshot',
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
if (error instanceof ApiTransportError) {
|
|
462
|
+
throw new CliInteractionError('Simulator screenshot could not reach the run.cloud API', 'screenshot_transport_error', requestId, 'screenshot', id, platform);
|
|
463
|
+
}
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
if (!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
|
467
|
+
throw new CliInteractionError('Simulator screenshot response was not a valid PNG image', 'screenshot_invalid_response', requestId, 'screenshot', id, platform);
|
|
468
|
+
}
|
|
469
|
+
const output = resolve(opts.output);
|
|
470
|
+
mkdirSync(dirname(output), { recursive: true });
|
|
471
|
+
writeFileSync(output, bytes);
|
|
472
|
+
const result = {
|
|
473
|
+
ok: true,
|
|
474
|
+
requestId,
|
|
475
|
+
sessionId: id,
|
|
476
|
+
platform,
|
|
477
|
+
action: 'screenshot',
|
|
478
|
+
status: 'completed',
|
|
479
|
+
output,
|
|
480
|
+
byteSize: bytes.byteLength,
|
|
481
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
482
|
+
};
|
|
483
|
+
if (opts.json)
|
|
484
|
+
console.log(JSON.stringify(result, null, 2));
|
|
485
|
+
else
|
|
486
|
+
console.log(`Saved ${platform} simulator screenshot to ${output} (${bytes.byteLength} bytes)`);
|
|
487
|
+
}
|
|
112
488
|
function fileBlob(path) {
|
|
113
489
|
const resolved = resolve(path);
|
|
114
490
|
if (!existsSync(resolved))
|
|
@@ -307,7 +683,7 @@ async function createSimulatorSession(platform, opts) {
|
|
|
307
683
|
model: opts.model,
|
|
308
684
|
region: opts.region,
|
|
309
685
|
displayName: opts.displayName,
|
|
310
|
-
|
|
686
|
+
tags: parseTags(opts.label),
|
|
311
687
|
installAssets,
|
|
312
688
|
inactivityTimeout: opts.inactivityTimeout,
|
|
313
689
|
hardTimeout: opts.hardTimeout,
|
|
@@ -319,7 +695,9 @@ async function createSimulatorSession(platform, opts) {
|
|
|
319
695
|
function registerSimulatorCommands(program, platform) {
|
|
320
696
|
const label = platform === 'android' ? 'Android emulator' : 'iOS simulator';
|
|
321
697
|
const article = platform === 'android' ? 'an' : 'an';
|
|
322
|
-
const simulator = program
|
|
698
|
+
const simulator = program
|
|
699
|
+
.command(platform)
|
|
700
|
+
.description(`Create, inspect, interact with, capture, and release ${label} sessions`);
|
|
323
701
|
simulator
|
|
324
702
|
.command('create')
|
|
325
703
|
.description(`Create a remote ${label} session`)
|
|
@@ -372,6 +750,138 @@ function registerSimulatorCommands(program, platform) {
|
|
|
372
750
|
.action((url, opts) => action(async () => {
|
|
373
751
|
print(await client().post(`/run-cloud/${platform}/${encodeURIComponent(opts.id)}/open-url`, { url }), opts);
|
|
374
752
|
}));
|
|
753
|
+
addInteractionOptions(simulator
|
|
754
|
+
.command('tap')
|
|
755
|
+
.description(`Tap normalized display coordinates in ${article} ${label} session`)
|
|
756
|
+
.argument('<id>', 'session id')
|
|
757
|
+
.argument('<x>', 'normalized x coordinate from 0 (left) to 1 (right)')
|
|
758
|
+
.argument('<y>', 'normalized y coordinate from 0 (top) to 1 (bottom)')).action((id, x, y, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'tap', ...normalizedPoint(x, y, 'tap') }, opts), opts));
|
|
759
|
+
addInteractionOptions(simulator
|
|
760
|
+
.command('swipe')
|
|
761
|
+
.description(`Swipe between normalized display coordinates in ${article} ${label} session`)
|
|
762
|
+
.argument('<id>', 'session id')
|
|
763
|
+
.argument('<from-x>', 'start x from 0 to 1')
|
|
764
|
+
.argument('<from-y>', 'start y from 0 to 1')
|
|
765
|
+
.argument('<to-x>', 'end x from 0 to 1')
|
|
766
|
+
.argument('<to-y>', 'end y from 0 to 1')
|
|
767
|
+
.option('--duration <milliseconds>', 'swipe duration in milliseconds (50-30000)')).action((id, fromX, fromY, toX, toY, opts) => action(() => runSimulatorInteraction(platform, id, {
|
|
768
|
+
action: 'swipe',
|
|
769
|
+
from: normalizedPoint(fromX, fromY, 'swipe start'),
|
|
770
|
+
to: normalizedPoint(toX, toY, 'swipe end'),
|
|
771
|
+
...(opts.duration === undefined
|
|
772
|
+
? {}
|
|
773
|
+
: { durationMs: integerInRange(opts.duration, '--duration', 50, 30_000) }),
|
|
774
|
+
}, opts), opts));
|
|
775
|
+
addInteractionOptions(simulator
|
|
776
|
+
.command('gesture')
|
|
777
|
+
.description(`Run an ordered one- or two-finger gesture in ${article} ${label} session`)
|
|
778
|
+
.argument('<id>', 'session id')
|
|
779
|
+
.requiredOption('--steps <json>', 'JSON steps: [{"phase":"begin|move|end","points":[{"x":0.5,"y":0.5}],"delayMs":0}]')).action((id, opts) => action(() => runSimulatorInteraction(platform, id, {
|
|
780
|
+
action: 'gesture',
|
|
781
|
+
steps: parseSimulatorGestureSteps(opts.steps, interactionTimeout(opts)),
|
|
782
|
+
}, opts), opts));
|
|
783
|
+
addInteractionOptions(simulator
|
|
784
|
+
.command('type-text')
|
|
785
|
+
.description(`Type US ASCII text into ${article} ${label} session`)
|
|
786
|
+
.argument('<id>', 'session id')
|
|
787
|
+
.argument('<text>', '1-10000 US ASCII characters; quote text containing spaces')).action((id, text, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'typeText', text }, opts), opts));
|
|
788
|
+
addInteractionOptions(simulator
|
|
789
|
+
.command('press-key')
|
|
790
|
+
.description(`Press a semantic keyboard key in ${article} ${label} session`)
|
|
791
|
+
.argument('<id>', 'session id')
|
|
792
|
+
.argument('<key>', 'semantic key, for example a, enter, arrowUp, or f1')
|
|
793
|
+
.option('--duration <milliseconds>', 'key hold duration in milliseconds (20-30000)')
|
|
794
|
+
.option('--shift', 'hold the left Shift modifier', false)
|
|
795
|
+
.option('--control', 'hold the left Control modifier', false)
|
|
796
|
+
.option('--alt', 'hold the left Alt modifier', false)
|
|
797
|
+
.option('--meta', 'hold the left Meta/Command modifier', false)
|
|
798
|
+
.addHelpText('after', `\nSupported keys:\n ${SIMULATOR_KEYS.join(', ')}\n`)).action((id, rawKey, opts) => action(() => {
|
|
799
|
+
const modifiers = ['shift', 'control', 'alt', 'meta']
|
|
800
|
+
.filter((modifier) => opts[modifier]);
|
|
801
|
+
return runSimulatorInteraction(platform, id, {
|
|
802
|
+
action: 'pressKey',
|
|
803
|
+
key: simulatorKey(rawKey),
|
|
804
|
+
...(modifiers.length ? { modifiers } : {}),
|
|
805
|
+
...(opts.duration === undefined
|
|
806
|
+
? {}
|
|
807
|
+
: { durationMs: integerInRange(opts.duration, '--duration', 20, 30_000) }),
|
|
808
|
+
}, opts);
|
|
809
|
+
}, opts));
|
|
810
|
+
addInteractionOptions(simulator
|
|
811
|
+
.command('press-button')
|
|
812
|
+
.description(`Press a simulator hardware or system button in ${article} ${label} session`)
|
|
813
|
+
.argument('<id>', 'session id')
|
|
814
|
+
.argument('<button>', `one of: ${SIMULATOR_BUTTONS.join(', ')}`)
|
|
815
|
+
.option('--duration <milliseconds>', 'button hold duration in milliseconds (20-30000)')).action((id, rawButton, opts) => action(() => runSimulatorInteraction(platform, id, {
|
|
816
|
+
action: 'pressButton',
|
|
817
|
+
button: simulatorButton(rawButton),
|
|
818
|
+
...(opts.duration === undefined
|
|
819
|
+
? {}
|
|
820
|
+
: { durationMs: integerInRange(opts.duration, '--duration', 20, 30_000) }),
|
|
821
|
+
}, opts), opts));
|
|
822
|
+
addInteractionOptions(simulator
|
|
823
|
+
.command('rotate')
|
|
824
|
+
.description(`Set the absolute display orientation for ${article} ${label} session`)
|
|
825
|
+
.argument('<id>', 'session id')
|
|
826
|
+
.argument('<orientation>', 'portrait, portrait_upside_down, landscape_left, or landscape_right')).action((id, rawOrientation, opts) => action(() => runSimulatorInteraction(platform, id, {
|
|
827
|
+
action: 'rotate',
|
|
828
|
+
orientation: simulatorOrientation(rawOrientation),
|
|
829
|
+
}, opts), opts));
|
|
830
|
+
addInteractionOptions(simulator
|
|
831
|
+
.command('reload')
|
|
832
|
+
.description(`Reload the foreground app in ${article} ${label} session`)
|
|
833
|
+
.argument('<id>', 'session id')).action((id, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'reload' }, opts), opts));
|
|
834
|
+
addInteractionOptions(simulator
|
|
835
|
+
.command('scroll')
|
|
836
|
+
.description(`Send normalized scroll deltas to ${article} ${label} session`)
|
|
837
|
+
.argument('<id>', 'session id')
|
|
838
|
+
.argument('<delta-x>', 'horizontal delta from -1 to 1')
|
|
839
|
+
.argument('<delta-y>', 'vertical delta from -1 to 1')
|
|
840
|
+
.option('--x <coordinate>', 'normalized anchor x; requires --y')
|
|
841
|
+
.option('--y <coordinate>', 'normalized anchor y; requires --x')).action((id, deltaX, deltaY, opts) => action(() => {
|
|
842
|
+
if ((opts.x === undefined) !== (opts.y === undefined))
|
|
843
|
+
throw new Error('--x and --y must be provided together');
|
|
844
|
+
const anchor = opts.x !== undefined && opts.y !== undefined
|
|
845
|
+
? normalizedPoint(opts.x, opts.y, 'scroll anchor')
|
|
846
|
+
: undefined;
|
|
847
|
+
return runSimulatorInteraction(platform, id, {
|
|
848
|
+
action: 'scroll',
|
|
849
|
+
deltaX: finiteInRange(deltaX, 'delta-x', -1, 1),
|
|
850
|
+
deltaY: finiteInRange(deltaY, 'delta-y', -1, 1),
|
|
851
|
+
...(anchor ?? {}),
|
|
852
|
+
}, opts);
|
|
853
|
+
}, opts));
|
|
854
|
+
addInteractionOptions(simulator
|
|
855
|
+
.command('toggle-software-keyboard')
|
|
856
|
+
.description(`Toggle the software keyboard in ${article} ${label} session`)
|
|
857
|
+
.argument('<id>', 'session id')).action((id, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'toggleSoftwareKeyboard' }, opts), opts));
|
|
858
|
+
addInteractionOptions(simulator
|
|
859
|
+
.command('simulate-memory-warning')
|
|
860
|
+
.description(`Send a memory-warning event to ${article} ${label} session`)
|
|
861
|
+
.argument('<id>', 'session id')).action((id, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'simulateMemoryWarning' }, opts), opts));
|
|
862
|
+
addInteractionOptions(simulator
|
|
863
|
+
.command('rotate-digital-crown')
|
|
864
|
+
.description(`Request Digital Crown input for ${article} ${label} session (unsupported on current mobile sessions)`)
|
|
865
|
+
.argument('<id>', 'session id')
|
|
866
|
+
.argument('<delta>', 'nonzero crown delta from -10000 to 10000')).action((id, delta, opts) => action(() => runSimulatorInteraction(platform, id, {
|
|
867
|
+
action: 'rotateDigitalCrown',
|
|
868
|
+
delta: finiteInRange(delta, 'delta', -10_000, 10_000),
|
|
869
|
+
}, opts), opts));
|
|
870
|
+
addInteractionOptions(simulator
|
|
871
|
+
.command('set-render-debug')
|
|
872
|
+
.description(`Enable or disable a simulator render diagnostic in ${article} ${label} session`)
|
|
873
|
+
.argument('<id>', 'session id')
|
|
874
|
+
.argument('<option>', `one of: ${SIMULATOR_RENDER_DEBUG_OPTIONS.join(', ')}`)
|
|
875
|
+
.argument('<enabled>', 'true or false')).action((id, rawOption, rawEnabled, opts) => action(() => runSimulatorInteraction(platform, id, {
|
|
876
|
+
action: 'setRenderDebug',
|
|
877
|
+
option: renderDebugOption(rawOption),
|
|
878
|
+
enabled: booleanArgument(rawEnabled, 'enabled'),
|
|
879
|
+
}, opts), opts));
|
|
880
|
+
addInteractionOptions(simulator
|
|
881
|
+
.command('screenshot')
|
|
882
|
+
.description(`Capture the current display from ${article} ${label} session`)
|
|
883
|
+
.argument('<id>', 'session id')
|
|
884
|
+
.requiredOption('-o, --output <path>', 'PNG output path')).action((id, opts) => action(() => captureSimulatorScreenshot(platform, id, opts), opts));
|
|
375
885
|
simulator
|
|
376
886
|
.command('logs')
|
|
377
887
|
.description(`Read or follow logs from ${article} active ${label} session`)
|
|
@@ -414,16 +924,89 @@ function registerSimulatorCommands(program, platform) {
|
|
|
414
924
|
}));
|
|
415
925
|
return simulator;
|
|
416
926
|
}
|
|
417
|
-
|
|
927
|
+
function jsonErrorEnvelope(error) {
|
|
928
|
+
if (error instanceof ApiError) {
|
|
929
|
+
return {
|
|
930
|
+
ok: false,
|
|
931
|
+
...(error.requestId ? { requestId: error.requestId } : {}),
|
|
932
|
+
...(error.sessionId ? { sessionId: error.sessionId } : {}),
|
|
933
|
+
...(error.platform ? { platform: error.platform } : {}),
|
|
934
|
+
...(error.action ? { action: error.action } : {}),
|
|
935
|
+
...(error.interactionStatus ? { status: error.interactionStatus } : {}),
|
|
936
|
+
...(error.acceptedAt ? { acceptedAt: error.acceptedAt } : {}),
|
|
937
|
+
...(error.completedAt ? { completedAt: error.completedAt } : {}),
|
|
938
|
+
...(error.durationMs === undefined ? {} : { durationMs: error.durationMs }),
|
|
939
|
+
error: {
|
|
940
|
+
code: error.code ?? `http_${error.status}`,
|
|
941
|
+
message: error.detail || friendlyApiError(error),
|
|
942
|
+
retryable: error.retryable ?? (error.status === 408 || error.status === 429 || error.status >= 500),
|
|
943
|
+
httpStatus: error.status,
|
|
944
|
+
...(error.details === undefined ? {} : { details: error.details }),
|
|
945
|
+
},
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
if (error instanceof CliInteractionError) {
|
|
949
|
+
const status = error.code.endsWith('_timeout')
|
|
950
|
+
? 'timed_out'
|
|
951
|
+
: error.code.endsWith('_cancelled')
|
|
952
|
+
? 'cancelled'
|
|
953
|
+
: 'failed';
|
|
954
|
+
return {
|
|
955
|
+
ok: false,
|
|
956
|
+
requestId: error.requestId,
|
|
957
|
+
sessionId: error.sessionId,
|
|
958
|
+
platform: error.platform,
|
|
959
|
+
action: error.actionName,
|
|
960
|
+
status,
|
|
961
|
+
error: {
|
|
962
|
+
code: error.code,
|
|
963
|
+
message: error.message,
|
|
964
|
+
retryable: !error.code.endsWith('_cancelled'),
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
ok: false,
|
|
970
|
+
error: {
|
|
971
|
+
code: 'invalid_request',
|
|
972
|
+
message: error instanceof Error ? error.message : String(error),
|
|
973
|
+
retryable: false,
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
async function action(fn, opts = {}) {
|
|
418
978
|
try {
|
|
419
979
|
await fn();
|
|
420
980
|
}
|
|
421
981
|
catch (err) {
|
|
422
|
-
console.error(friendlyApiError(err));
|
|
423
|
-
process.exitCode = 1;
|
|
982
|
+
console.error(opts.json ? JSON.stringify(jsonErrorEnvelope(err), null, 2) : friendlyApiError(err));
|
|
983
|
+
process.exitCode = err instanceof CliInteractionError && err.code.endsWith('_cancelled') ? 130 : 1;
|
|
424
984
|
}
|
|
425
985
|
}
|
|
986
|
+
function configureMachineReadableParseErrors(program) {
|
|
987
|
+
const output = program.configureOutput();
|
|
988
|
+
program.configureOutput({
|
|
989
|
+
...output,
|
|
990
|
+
outputError: (message, write) => {
|
|
991
|
+
const rawArgs = program.rawArgs ?? [];
|
|
992
|
+
const argumentsSeen = [...process.argv, ...rawArgs, ...program.args];
|
|
993
|
+
if (!argumentsSeen.includes('--json')) {
|
|
994
|
+
(output.outputError ?? ((value, destination) => destination(value)))(message, write);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
write(`${JSON.stringify({
|
|
998
|
+
ok: false,
|
|
999
|
+
error: {
|
|
1000
|
+
code: 'invalid_request',
|
|
1001
|
+
message: message.replace(/^error:\s*/i, '').trim(),
|
|
1002
|
+
retryable: false,
|
|
1003
|
+
},
|
|
1004
|
+
})}\n`);
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
426
1008
|
export function registerRunCloud(program) {
|
|
1009
|
+
configureMachineReadableParseErrors(program);
|
|
427
1010
|
const sample = program
|
|
428
1011
|
.command('sample')
|
|
429
1012
|
.alias('samples')
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = '0.1.
|
|
1
|
+
export const CLI_VERSION = '0.1.109';
|