runcloud 0.1.108 → 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 +589 -6
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/runcloud-0.1.109.tgz +0 -0
- package/skills/run-cloud-ios-simulator/SKILL.md +101 -129
package/README.md
CHANGED
|
@@ -64,12 +64,18 @@ runcloud ios create --model iphone --install ./MyApp.app # boot a simulator an
|
|
|
64
64
|
runcloud ios list # list active sessions
|
|
65
65
|
runcloud ios get <id> # inspect a session (viewer URL, status)
|
|
66
66
|
runcloud ios open-url myapp://path --id <id> # open a URL or deep link
|
|
67
|
+
runcloud ios tap <id> 0.5 0.75 # normalized top-left display coordinates
|
|
68
|
+
runcloud ios type-text <id> "Hello from iOS!" # printable US ASCII, tab, and line feed
|
|
69
|
+
runcloud ios screenshot <id> --output ios.png # save a PNG without exposing the viewer URL
|
|
67
70
|
runcloud ios logs <id> --tail 200 # read logs from this lease
|
|
68
71
|
runcloud ios logs <id> --follow # follow new log entries
|
|
69
72
|
runcloud ios delete <id> # release the session
|
|
70
73
|
|
|
71
74
|
# Android works the same under `runcloud android …`
|
|
72
75
|
runcloud android create --model pixel
|
|
76
|
+
runcloud android swipe <id> 0.5 0.8 0.5 0.2 --duration 250
|
|
77
|
+
runcloud android press-key <id> enter
|
|
78
|
+
runcloud android screenshot <id> --output android.png --json
|
|
73
79
|
```
|
|
74
80
|
|
|
75
81
|
Useful `create` flags: `--region`, `--display-name`, `--inactivity-timeout 3m`,
|
|
@@ -80,6 +86,23 @@ The same `logs` commands work for Android. Snapshots are limited to the active
|
|
|
80
86
|
lease and accept 1 to 1,000 lines. In follow mode, `--json` emits one JSON object
|
|
81
87
|
per line.
|
|
82
88
|
|
|
89
|
+
Both platforms also expose `gesture`, `press-button`, `rotate`, `reload`,
|
|
90
|
+
`scroll`, `toggle-software-keyboard`, `simulate-memory-warning`,
|
|
91
|
+
`rotate-digital-crown`, and `set-render-debug`. Run `runcloud ios --help` or
|
|
92
|
+
`runcloud android --help` for the full surface and per-command input choices.
|
|
93
|
+
Current mobile sessions return an unsupported-action error for Digital Crown
|
|
94
|
+
input; render-debug controls are iOS-only. Android Emulator sessions also
|
|
95
|
+
report `capsLock`, `numLock`, and `scrollLock` as unsupported keys.
|
|
96
|
+
Coordinates are inclusive normalized display coordinates: `(0, 0)` is the
|
|
97
|
+
top-left and `(1, 1)` is the bottom-right.
|
|
98
|
+
|
|
99
|
+
Interaction commands wait for a correlated simulator acknowledgement. They
|
|
100
|
+
accept `--timeout <milliseconds>` (15 seconds by default), `--request-id <id>`,
|
|
101
|
+
and `--json`. Successful JSON is the API's typed completion envelope. Failures
|
|
102
|
+
write a stable `{ ok: false, error: { code, message, retryable } }` envelope to
|
|
103
|
+
stderr and exit non-zero. Ctrl-C cancels only the in-flight command, leaves the
|
|
104
|
+
session active, and exits with code 130.
|
|
105
|
+
|
|
83
106
|
### Tunnel a local dev server into a simulator
|
|
84
107
|
|
|
85
108
|
```bash
|
package/dist/api.js
CHANGED
|
@@ -3,28 +3,84 @@ export class ApiError extends Error {
|
|
|
3
3
|
status;
|
|
4
4
|
detail;
|
|
5
5
|
path;
|
|
6
|
-
|
|
6
|
+
code;
|
|
7
|
+
retryable;
|
|
8
|
+
details;
|
|
9
|
+
requestId;
|
|
10
|
+
sessionId;
|
|
11
|
+
platform;
|
|
12
|
+
action;
|
|
13
|
+
interactionStatus;
|
|
14
|
+
acceptedAt;
|
|
15
|
+
completedAt;
|
|
16
|
+
durationMs;
|
|
17
|
+
constructor(status, detail, path, context = {}) {
|
|
7
18
|
super(`API ${status}: ${detail}`);
|
|
8
19
|
this.status = status;
|
|
9
20
|
this.detail = detail;
|
|
10
21
|
this.path = path;
|
|
22
|
+
this.name = 'ApiError';
|
|
23
|
+
Object.assign(this, context);
|
|
11
24
|
}
|
|
12
25
|
}
|
|
13
|
-
|
|
26
|
+
export class ApiTransportError extends Error {
|
|
27
|
+
path;
|
|
28
|
+
originalError;
|
|
29
|
+
constructor(path, originalError) {
|
|
30
|
+
super('Could not reach the run.cloud API');
|
|
31
|
+
this.path = path;
|
|
32
|
+
this.originalError = originalError;
|
|
33
|
+
this.name = 'ApiTransportError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function parsedError(body) {
|
|
14
37
|
try {
|
|
15
38
|
const parsed = JSON.parse(body);
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
39
|
+
const nested = parsed.error && typeof parsed.error === 'object'
|
|
40
|
+
? parsed.error
|
|
41
|
+
: undefined;
|
|
42
|
+
const rawDetail = nested?.message ?? parsed.detail ?? parsed.error ?? parsed.message;
|
|
43
|
+
const detail = typeof rawDetail === 'string'
|
|
44
|
+
? rawDetail
|
|
45
|
+
: rawDetail === undefined ? '' : JSON.stringify(rawDetail);
|
|
46
|
+
const retryable = nested?.retryable ?? parsed.retryable;
|
|
47
|
+
const interactionStatus = parsed.status === 'failed'
|
|
48
|
+
|| parsed.status === 'cancelled'
|
|
49
|
+
|| parsed.status === 'timed_out'
|
|
50
|
+
? parsed.status
|
|
51
|
+
: undefined;
|
|
52
|
+
const platform = parsed.platform === 'ios' || parsed.platform === 'android'
|
|
53
|
+
? parsed.platform
|
|
54
|
+
: undefined;
|
|
55
|
+
return {
|
|
56
|
+
detail,
|
|
57
|
+
...(typeof (nested?.code ?? parsed.code) === 'string'
|
|
58
|
+
? { code: (nested?.code ?? parsed.code) }
|
|
59
|
+
: {}),
|
|
60
|
+
...(typeof retryable === 'boolean' ? { retryable } : {}),
|
|
61
|
+
...((nested?.details ?? parsed.details) === undefined
|
|
62
|
+
? {}
|
|
63
|
+
: { details: nested?.details ?? parsed.details }),
|
|
64
|
+
...(typeof parsed.requestId === 'string' ? { requestId: parsed.requestId } : {}),
|
|
65
|
+
...(typeof parsed.sessionId === 'string' ? { sessionId: parsed.sessionId } : {}),
|
|
66
|
+
...(platform ? { platform } : {}),
|
|
67
|
+
...(typeof parsed.action === 'string' ? { action: parsed.action } : {}),
|
|
68
|
+
...(interactionStatus ? { interactionStatus } : {}),
|
|
69
|
+
...(typeof parsed.acceptedAt === 'string' ? { acceptedAt: parsed.acceptedAt } : {}),
|
|
70
|
+
...(typeof parsed.completedAt === 'string' ? { completedAt: parsed.completedAt } : {}),
|
|
71
|
+
...(typeof parsed.durationMs === 'number' && Number.isFinite(parsed.durationMs)
|
|
72
|
+
? { durationMs: parsed.durationMs }
|
|
73
|
+
: {}),
|
|
74
|
+
};
|
|
21
75
|
}
|
|
22
76
|
catch {
|
|
23
77
|
}
|
|
24
78
|
if (/<(?:!doctype|html)\b/i.test(body)) {
|
|
25
|
-
return
|
|
79
|
+
return {
|
|
80
|
+
detail: 'the API edge returned an HTML error page; retry the request or use the streaming/direct-transfer command',
|
|
81
|
+
};
|
|
26
82
|
}
|
|
27
|
-
return body;
|
|
83
|
+
return { detail: body };
|
|
28
84
|
}
|
|
29
85
|
export class ApiClient {
|
|
30
86
|
baseUrl;
|
|
@@ -33,18 +89,27 @@ export class ApiClient {
|
|
|
33
89
|
this.baseUrl = baseUrl;
|
|
34
90
|
this.token = token;
|
|
35
91
|
}
|
|
36
|
-
async request(method, path, body) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
92
|
+
async request(method, path, body, options = {}) {
|
|
93
|
+
let res;
|
|
94
|
+
try {
|
|
95
|
+
res = await fetch(`${this.baseUrl}${path}`, {
|
|
96
|
+
method,
|
|
97
|
+
headers: {
|
|
98
|
+
Authorization: `Bearer ${this.token}`,
|
|
99
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
100
|
+
},
|
|
101
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
102
|
+
signal: options.signal,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (options.signal?.aborted)
|
|
107
|
+
throw options.signal.reason ?? error;
|
|
108
|
+
throw new ApiTransportError(path, error);
|
|
109
|
+
}
|
|
45
110
|
if (!res.ok) {
|
|
46
|
-
const detail =
|
|
47
|
-
throw new ApiError(res.status, detail, path);
|
|
111
|
+
const { detail, ...context } = parsedError(await res.text());
|
|
112
|
+
throw new ApiError(res.status, detail, path, context);
|
|
48
113
|
}
|
|
49
114
|
const contentType = res.headers.get('content-type') ?? '';
|
|
50
115
|
return contentType.includes('application/json') ? res.json() : res;
|
|
@@ -56,8 +121,8 @@ export class ApiClient {
|
|
|
56
121
|
const response = await this.request('GET', path);
|
|
57
122
|
return response.text();
|
|
58
123
|
}
|
|
59
|
-
post(path, body) {
|
|
60
|
-
return this.request('POST', path, body);
|
|
124
|
+
post(path, body, options = {}) {
|
|
125
|
+
return this.request('POST', path, body, options);
|
|
61
126
|
}
|
|
62
127
|
async postWithHeaders(path, body, headers) {
|
|
63
128
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
@@ -70,8 +135,8 @@ export class ApiClient {
|
|
|
70
135
|
body: JSON.stringify(body),
|
|
71
136
|
});
|
|
72
137
|
if (!res.ok) {
|
|
73
|
-
const detail =
|
|
74
|
-
throw new ApiError(res.status, detail, path);
|
|
138
|
+
const { detail, ...context } = parsedError(await res.text());
|
|
139
|
+
throw new ApiError(res.status, detail, path, context);
|
|
75
140
|
}
|
|
76
141
|
const contentType = res.headers.get('content-type') ?? '';
|
|
77
142
|
return contentType.includes('application/json') ? res.json() : res;
|
|
@@ -95,8 +160,8 @@ export class ApiClient {
|
|
|
95
160
|
body: body,
|
|
96
161
|
});
|
|
97
162
|
if (!res.ok) {
|
|
98
|
-
const detail =
|
|
99
|
-
throw new ApiError(res.status, detail, path);
|
|
163
|
+
const { detail, ...context } = parsedError(await res.text());
|
|
164
|
+
throw new ApiError(res.status, detail, path, context);
|
|
100
165
|
}
|
|
101
166
|
const contentType_ = res.headers.get('content-type') ?? '';
|
|
102
167
|
return contentType_.includes('application/json') ? res.json() : res;
|
|
@@ -110,20 +175,29 @@ export class ApiClient {
|
|
|
110
175
|
body: form,
|
|
111
176
|
});
|
|
112
177
|
if (!res.ok) {
|
|
113
|
-
const detail =
|
|
114
|
-
throw new ApiError(res.status, detail, path);
|
|
178
|
+
const { detail, ...context } = parsedError(await res.text());
|
|
179
|
+
throw new ApiError(res.status, detail, path, context);
|
|
115
180
|
}
|
|
116
181
|
const contentType = res.headers.get('content-type') ?? '';
|
|
117
182
|
return contentType.includes('application/json') ? res.json() : res;
|
|
118
183
|
}
|
|
119
|
-
async getBinary(path) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
184
|
+
async getBinary(path, options = {}) {
|
|
185
|
+
let res;
|
|
186
|
+
try {
|
|
187
|
+
res = await fetch(`${this.baseUrl}${path}`, {
|
|
188
|
+
method: 'GET',
|
|
189
|
+
headers: { Authorization: `Bearer ${this.token}`, ...options.headers },
|
|
190
|
+
signal: options.signal,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (options.signal?.aborted)
|
|
195
|
+
throw options.signal.reason ?? error;
|
|
196
|
+
throw new ApiTransportError(path, error);
|
|
197
|
+
}
|
|
124
198
|
if (!res.ok) {
|
|
125
|
-
const detail = await res.text().catch(() => '');
|
|
126
|
-
throw new ApiError(res.status, detail || res.statusText, path);
|
|
199
|
+
const { detail, ...context } = parsedError(await res.text().catch(() => ''));
|
|
200
|
+
throw new ApiError(res.status, detail || res.statusText, path, context);
|
|
127
201
|
}
|
|
128
202
|
return Buffer.from(await res.arrayBuffer());
|
|
129
203
|
}
|
|
@@ -133,8 +207,8 @@ export class ApiClient {
|
|
|
133
207
|
headers: { Authorization: `Bearer ${this.token}`, Accept: 'text/event-stream' },
|
|
134
208
|
});
|
|
135
209
|
if (!res.ok) {
|
|
136
|
-
const detail =
|
|
137
|
-
throw new ApiError(res.status, detail || res.statusText, path);
|
|
210
|
+
const { detail, ...context } = parsedError(await res.text().catch(() => ''));
|
|
211
|
+
throw new ApiError(res.status, detail || res.statusText, path, context);
|
|
138
212
|
}
|
|
139
213
|
const decoder = new TextDecoder();
|
|
140
214
|
for await (const chunk of res.body) {
|
|
@@ -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',
|
|
@@ -109,6 +191,300 @@ function parseTags(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))
|
|
@@ -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';
|
package/package.json
CHANGED
|
Binary file
|
|
@@ -1,44 +1,32 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: run-cloud-ios-simulator
|
|
3
|
-
description: Operate run.cloud iOS simulator and Android emulator sessions with the CLI or TypeScript SDK. Use for creating, installing, inspecting, reading
|
|
3
|
+
description: Operate run.cloud iOS simulator and Android emulator sessions with the CLI or TypeScript SDK. Use for creating, installing, inspecting, reading logs, controlling, embedding, smoke-testing, connecting local Metro, taking screenshots, injecting iOS media, or releasing remote mobile sessions.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Operate run.cloud Mobile Sessions
|
|
7
7
|
|
|
8
|
-
Use
|
|
9
|
-
`@run-cloud/
|
|
8
|
+
Use the `runcloud` CLI for terminal workflows, `@run-cloud/sdk` for TypeScript,
|
|
9
|
+
and `@run-cloud/ui` for React embeds.
|
|
10
10
|
|
|
11
11
|
## Authenticate
|
|
12
12
|
|
|
13
13
|
- Install the CLI with `npm install -g runcloud`.
|
|
14
|
-
- Use `runcloud login`
|
|
15
|
-
`runcloud login --manual` when a local callback cannot open.
|
|
14
|
+
- Use `runcloud login`; add `--manual` when a local callback cannot open.
|
|
16
15
|
- In CI, set `RUN_CLOUD_API_KEY`. `RUN_CLOUD_API_TOKEN` is an equivalent alias.
|
|
17
|
-
- Set `RUN_CLOUD_API_URL` only to override
|
|
18
|
-
|
|
19
|
-
- Never print, commit, or place credentials in a skill file.
|
|
20
|
-
|
|
21
|
-
- Require Node.js 20 or newer for the CLI and TypeScript SDK.
|
|
16
|
+
- Set `RUN_CLOUD_API_URL` only to override `https://api.run.cloud`.
|
|
17
|
+
- Require Node.js 20 or newer.
|
|
18
|
+
- Never print, commit, or place credentials in a skill file. Treat signed session
|
|
19
|
+
and tunnel URLs as bearer secrets.
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
Check access and credit before starting metered work:
|
|
24
22
|
|
|
25
23
|
```bash
|
|
26
24
|
runcloud account --json
|
|
27
25
|
```
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
available fleet capacity. A create request may queue while capacity is full.
|
|
27
|
+
The SDK equivalents are `cloud.account()` and `cloud.usage({ orgId? })`.
|
|
31
28
|
|
|
32
|
-
##
|
|
33
|
-
|
|
34
|
-
- Prefer CLI commands with `--json` for shell automation.
|
|
35
|
-
- Prefer `@run-cloud/sdk` for TypeScript applications and CI.
|
|
36
|
-
- Inspect `runcloud <command> --help` or installed SDK types before using a
|
|
37
|
-
method not documented here.
|
|
38
|
-
|
|
39
|
-
## Use the CLI
|
|
40
|
-
|
|
41
|
-
Create an iOS session, capture its ID, open a deep link, and release it:
|
|
29
|
+
## Create and Release Sessions
|
|
42
30
|
|
|
43
31
|
```bash
|
|
44
32
|
SESSION_ID=$(runcloud ios create \
|
|
@@ -48,79 +36,51 @@ SESSION_ID=$(runcloud ios create \
|
|
|
48
36
|
--json | jq -r '.id')
|
|
49
37
|
|
|
50
38
|
trap 'runcloud ios delete "$SESSION_ID" >/dev/null 2>&1 || true' EXIT
|
|
51
|
-
|
|
52
39
|
runcloud ios get "$SESSION_ID" --json
|
|
53
|
-
runcloud ios open-url myapp://settings --id "$SESSION_ID" --json
|
|
54
40
|
```
|
|
55
41
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
- `runcloud ios|android create`
|
|
61
|
-
- `runcloud ios|android list [--all]`
|
|
62
|
-
- `runcloud ios|android get <id>`
|
|
63
|
-
- `runcloud ios|android open-url <url> --id <id>`
|
|
64
|
-
- `runcloud ios|android logs <id> [--tail N|--follow]`
|
|
65
|
-
- `runcloud ios|android delete <id>`
|
|
66
|
-
|
|
67
|
-
Create accepts `--model`, `--region`, `--display-name`, repeatable `--label`,
|
|
68
|
-
repeatable `--install`, repeatable `--install-asset`,
|
|
69
|
-
`--inactivity-timeout`, `--hard-timeout`, `--codec`, `--rm`, and `--json`.
|
|
70
|
-
|
|
71
|
-
Use assets and samples when no local artifact is ready:
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
runcloud sample download ios
|
|
75
|
-
runcloud ios create --install ./run-cloud-sample-ios.app.tar.gz --json
|
|
76
|
-
|
|
77
|
-
runcloud asset push ./build/MyApp.tar.gz --name my-app --json
|
|
78
|
-
runcloud ios create --install-asset my-app --json
|
|
79
|
-
runcloud asset list --json
|
|
80
|
-
runcloud asset pull <asset-id> --output ./MyApp.tar.gz
|
|
81
|
-
runcloud asset delete <asset-id> --json
|
|
82
|
-
```
|
|
42
|
+
Replace `ios` with `android` for an Android artifact. The shared lifecycle is
|
|
43
|
+
`create`, `list`, `get`, `open-url`, `logs`, and `delete`. Use `--json` whenever
|
|
44
|
+
another program consumes output, and inspect `runcloud ios|android --help` for
|
|
45
|
+
create and log options.
|
|
83
46
|
|
|
84
47
|
iOS needs an Apple Silicon simulator-compatible `.app`, `.zip`, `.tar.gz`, or
|
|
85
|
-
`.ipa
|
|
86
|
-
|
|
48
|
+
`.ipa`; a device-signed App Store IPA is not a substitute. Android needs an
|
|
49
|
+
emulator-compatible APK.
|
|
87
50
|
|
|
88
|
-
##
|
|
51
|
+
## Control a Session
|
|
89
52
|
|
|
90
|
-
|
|
53
|
+
Both platform groups expose acknowledged controls:
|
|
91
54
|
|
|
92
55
|
```bash
|
|
93
|
-
runcloud ios
|
|
94
|
-
runcloud
|
|
56
|
+
runcloud ios tap "$SESSION_ID" 0.5 0.3 --json
|
|
57
|
+
runcloud ios swipe "$SESSION_ID" 0.5 0.8 0.5 0.2 --duration 300 --json
|
|
58
|
+
runcloud ios type-text "$SESSION_ID" 'hello' --json
|
|
59
|
+
runcloud ios press-key "$SESSION_ID" enter --json
|
|
60
|
+
runcloud ios press-button "$SESSION_ID" home --json
|
|
61
|
+
runcloud ios screenshot "$SESSION_ID" --output ios.png --json
|
|
95
62
|
```
|
|
96
63
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
## Connect Local Development
|
|
104
|
-
|
|
105
|
-
Connect a local Metro or mock server to an active iOS session:
|
|
64
|
+
The full control set is `tap`, `swipe`, `gesture`, `type-text`, `press-key`,
|
|
65
|
+
`press-button`, `rotate`, `reload`, `scroll`, `toggle-software-keyboard`,
|
|
66
|
+
`simulate-memory-warning`, `rotate-digital-crown`, `set-render-debug`, and
|
|
67
|
+
`screenshot`. Every interaction accepts `--request-id`, `--timeout`, and
|
|
68
|
+
`--json`; use `runcloud <platform> <control> --help` for its typed arguments.
|
|
106
69
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
70
|
+
Coordinates use the current display orientation: `(0, 0)` is top-left and
|
|
71
|
+
`(1, 1)` is bottom-right. Gesture steps use one or two points with `begin`,
|
|
72
|
+
`move`, and `end` phases. Each `delayMs` is the pause before the next step, so
|
|
73
|
+
the final `end` step must use `0`. Key names are semantic US-keyboard names;
|
|
74
|
+
modifiers are `shift`, `control`, `alt`, and `meta`. Current mobile sessions do
|
|
75
|
+
not support Digital Crown input, and Android does not support iOS render-debug
|
|
76
|
+
controls or the `capsLock`, `numLock`, and `scrollLock` keys. Handle structured
|
|
77
|
+
`unsupported_action` errors instead of retrying them.
|
|
115
78
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
instead of guessing a public URL.
|
|
79
|
+
An acknowledgement means input dispatch completed. Confirm visible app effects
|
|
80
|
+
with a screenshot or the signed viewer when the outcome matters.
|
|
119
81
|
|
|
120
82
|
## Use the TypeScript SDK
|
|
121
83
|
|
|
122
|
-
Install the SDK:
|
|
123
|
-
|
|
124
84
|
```bash
|
|
125
85
|
npm install @run-cloud/sdk
|
|
126
86
|
```
|
|
@@ -132,84 +92,96 @@ import { writeFile } from "node:fs/promises";
|
|
|
132
92
|
import { Client } from "@run-cloud/sdk";
|
|
133
93
|
|
|
134
94
|
const cloud = new Client();
|
|
135
|
-
const session = await cloud.
|
|
136
|
-
displayName: "Agent smoke",
|
|
137
|
-
tags: { owner: "agent" },
|
|
95
|
+
const session = await cloud.android.create({
|
|
138
96
|
inactivityTimeout: "60s",
|
|
139
97
|
hardTimeout: "10m",
|
|
140
|
-
|
|
98
|
+
tags: { owner: "agent" },
|
|
141
99
|
});
|
|
142
100
|
|
|
143
101
|
try {
|
|
144
|
-
await cloud.
|
|
145
|
-
|
|
146
|
-
await
|
|
102
|
+
await cloud.android.tap(session.id, { x: 0.5, y: 0.3 });
|
|
103
|
+
await cloud.android.typeText(session.id, "hello");
|
|
104
|
+
await cloud.android.pressKey(session.id, "enter");
|
|
105
|
+
const screenshot = await cloud.android.screenshot(session.id);
|
|
106
|
+
await writeFile("android.png", screenshot);
|
|
147
107
|
} finally {
|
|
148
|
-
await cloud.
|
|
108
|
+
await cloud.android.delete(session.id);
|
|
149
109
|
}
|
|
150
110
|
```
|
|
151
111
|
|
|
152
|
-
|
|
112
|
+
`cloud.ios`, `cloud.android`, and the platform-selectable `cloud.simulators`
|
|
113
|
+
expose `interact` plus convenience methods matching every CLI control above.
|
|
114
|
+
Interaction options accept `requestId`, `timeoutMs`, and `signal`; results are
|
|
115
|
+
typed acknowledgements. Use `RunCloudError` fields such as `code`, `retryable`,
|
|
116
|
+
`requestId`, and `action` when reporting API failures.
|
|
153
117
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
- `cloud.simulators`: runtime-platform `create`, `list`, `get`, `openUrl`,
|
|
159
|
-
`delete`
|
|
160
|
-
- `cloud.assets`: `upload`, `list`, `delete`
|
|
118
|
+
The lifecycle surface also includes `create`, `list`, `get`, `openUrl`, `logs`,
|
|
119
|
+
`followLogs`, and `delete`. Both platforms expose `screenshot`; iOS additionally
|
|
120
|
+
supports `uploadVideo` and `uploadMicrophoneAudio`. Inspect the installed types
|
|
121
|
+
for complete create, asset, log, and media options.
|
|
161
122
|
|
|
162
|
-
|
|
163
|
-
`
|
|
123
|
+
In compact form, `cloud.ios`: `create`, `list`, `get`, `openUrl`, `logs`, `followLogs`, `screenshot`.
|
|
124
|
+
`cloud.android` provides the same shared lifecycle and control operations.
|
|
164
125
|
|
|
165
|
-
|
|
166
|
-
or Android screenshots. Browser-stream interaction and iframe commands are
|
|
167
|
-
separate from the public SDK.
|
|
126
|
+
## Diagnose App Failures
|
|
168
127
|
|
|
169
|
-
|
|
128
|
+
```bash
|
|
129
|
+
runcloud ios logs "$SESSION_ID" --tail 1000
|
|
130
|
+
runcloud android logs "$SESSION_ID" --tail 1000
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Use `--follow` while reproducing an issue. Before releasing a failed session,
|
|
134
|
+
capture a bounded retained snapshot; a follow stream contains only new entries.
|
|
170
135
|
|
|
171
|
-
|
|
172
|
-
stores a user-owned asset and imports it into Photos.
|
|
136
|
+
## Connect Local Development
|
|
173
137
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
138
|
+
Connect a local Metro or mock server through the supported sidecar flow:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
runcloud ios tunnel "$SESSION_ID" --local-port 8081 --service metro --json
|
|
142
|
+
runcloud ios tunnel-status --json
|
|
143
|
+
```
|
|
178
144
|
|
|
179
|
-
|
|
145
|
+
Do not expose an unauthenticated third-party tunnel. If the sidecar is
|
|
146
|
+
unavailable, report that requirement instead of guessing a public URL.
|
|
180
147
|
|
|
181
148
|
## Embed a Session
|
|
182
149
|
|
|
183
|
-
-
|
|
184
|
-
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
`
|
|
190
|
-
|
|
191
|
-
`
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
150
|
+
- Use `RemoteControl` from `@run-cloud/ui` with the signed session URL.
|
|
151
|
+
- Its ref exposes promise-based `interact` and convenience methods matching the
|
|
152
|
+
SDK controls. Handle `onInteractionResult` for inspectable acknowledgements.
|
|
153
|
+
- Add `embed=1` to raw iframe URLs. Add `loadingGuard=1` when interaction should
|
|
154
|
+
wait for streaming and app readiness.
|
|
155
|
+
- Raw iframe requests use `run-cloud:interaction`; acknowledgements use
|
|
156
|
+
`run-cloud:interaction-result`. Correlate them by `requestId`. To stop a
|
|
157
|
+
pending request, post `run-cloud:interaction-cancel` with the same
|
|
158
|
+
`requestId` and `action`.
|
|
159
|
+
- Verify `event.source` and the exact signed-URL origin, and use that origin as
|
|
160
|
+
the `postMessage` target.
|
|
161
|
+
- Legacy `ios-simulator:command` messages remain compatibility-only. Prefer the
|
|
162
|
+
generic acknowledged interaction channel for new code.
|
|
163
|
+
- Create a new session after an `ios-simulator:session-restart-requested`
|
|
164
|
+
message; never reuse an ended URL.
|
|
165
|
+
|
|
166
|
+
## Assets, Samples, and Demos
|
|
198
167
|
|
|
199
168
|
```bash
|
|
169
|
+
runcloud sample download ios
|
|
170
|
+
runcloud asset push ./build/MyApp.tar.gz --name my-app --json
|
|
171
|
+
runcloud asset pull <asset-id>
|
|
172
|
+
runcloud ios create --install-asset my-app --json
|
|
200
173
|
runcloud demo run eight-device-mosaic --open
|
|
201
174
|
runcloud demo run live-camera-relay --open
|
|
202
175
|
```
|
|
203
176
|
|
|
204
|
-
|
|
177
|
+
Delete uploaded assets when no longer needed. Bundled demos release their
|
|
178
|
+
sessions automatically.
|
|
205
179
|
|
|
206
180
|
## Guardrails
|
|
207
181
|
|
|
208
|
-
- Release every session created during a task unless
|
|
209
|
-
to keep it open.
|
|
182
|
+
- Release every session created during a task unless asked to keep it open.
|
|
210
183
|
- Use inactivity and hard timeouts for unattended work.
|
|
211
|
-
- Verify platform compatibility before changing
|
|
184
|
+
- Verify artifact/platform compatibility before changing app code after an
|
|
212
185
|
install failure.
|
|
213
186
|
- Do not expose credentials, signed viewer URLs, tunnel URLs, or simulator
|
|
214
187
|
tokens in logs, screenshots, PR comments, or chat output.
|
|
215
|
-
- Do not claim that browser iframe controls are public SDK methods.
|