@phone-use/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +103 -0
- package/dist/backend-Cbr2tIN-.d.mts +316 -0
- package/dist/device-BzPnHvQy.mjs +284 -0
- package/dist/device-BzPnHvQy.mjs.map +1 -0
- package/dist/index.d.mts +689 -0
- package/dist/index.mjs +1848 -0
- package/dist/index.mjs.map +1 -0
- package/dist/testing.d.mts +127 -0
- package/dist/testing.mjs +188 -0
- package/dist/testing.mjs.map +1 -0
- package/package.json +44 -0
- package/src/actions.ts +545 -0
- package/src/backend.ts +185 -0
- package/src/backends/agent-device.ts +242 -0
- package/src/backends/ios.ts +262 -0
- package/src/config.ts +43 -0
- package/src/device.ts +98 -0
- package/src/errors.ts +177 -0
- package/src/exec.ts +48 -0
- package/src/index.ts +86 -0
- package/src/lifecycle.ts +349 -0
- package/src/observe.ts +1093 -0
- package/src/secrets.ts +67 -0
- package/src/testing.ts +239 -0
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Action,
|
|
3
|
+
type ActionResult,
|
|
4
|
+
type ActOptions,
|
|
5
|
+
buildObserveResult,
|
|
6
|
+
type ElementQuery,
|
|
7
|
+
executeAction,
|
|
8
|
+
type ObserveResult,
|
|
9
|
+
} from './actions.ts';
|
|
10
|
+
import type { DeviceBackend } from './backend.ts';
|
|
11
|
+
import type { Capability, ScrollDirection } from './device.ts';
|
|
12
|
+
import { SessionNotFoundError } from './errors.ts';
|
|
13
|
+
import { DeviceCore } from './observe.ts';
|
|
14
|
+
import { SecretStore } from './secrets.ts';
|
|
15
|
+
|
|
16
|
+
/** The platform a {@link Device} runs on. */
|
|
17
|
+
export type DevicePlatform = 'ios' | 'android';
|
|
18
|
+
/** Lifecycle state of a {@link Device} handle. */
|
|
19
|
+
export type DeviceStatus = 'running' | 'closed';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The Device lifecycle handle (docs/20 item 3; docs/19 §Lifecycle): id, pinned
|
|
23
|
+
* backend, close/dispose, idle lease + reaper — plus the action verb surface
|
|
24
|
+
* (item 4) layered onto the same type. `ios.launch()` and `ios.connect()`
|
|
25
|
+
* return it; the android engine (item 7b) will share `createDeviceHandle`.
|
|
26
|
+
*/
|
|
27
|
+
export interface Device {
|
|
28
|
+
/** udid (iOS) / serial (Android). */
|
|
29
|
+
readonly id: string;
|
|
30
|
+
/** Which platform this device runs. */
|
|
31
|
+
readonly platform: DevicePlatform;
|
|
32
|
+
/** Simulator/device name when known. */
|
|
33
|
+
readonly name?: string | undefined;
|
|
34
|
+
/** Name of the backend driving this device. */
|
|
35
|
+
readonly backendName: string;
|
|
36
|
+
/** The backend's declared capability set. */
|
|
37
|
+
readonly capabilities: ReadonlySet<Capability>;
|
|
38
|
+
/**
|
|
39
|
+
* The pinned backend — `new DeviceContext(device.backend)` works today.
|
|
40
|
+
* Backend calls through this handle count as activity for the idle lease.
|
|
41
|
+
*/
|
|
42
|
+
readonly backend: DeviceBackend;
|
|
43
|
+
/** true when launch() created the device — close() then also deletes it. */
|
|
44
|
+
readonly createdByUs: boolean;
|
|
45
|
+
/** Current lifecycle state. */
|
|
46
|
+
readonly status: DeviceStatus;
|
|
47
|
+
/** Sugar for `status === 'closed'`. */
|
|
48
|
+
readonly isClosed: boolean;
|
|
49
|
+
/** Re-arm the idle lease (ms overrides the configured window for this arm only). */
|
|
50
|
+
extendLease(ms?: number): void;
|
|
51
|
+
/** Canonical, idempotent shutdown. `await using` is sugar over this. */
|
|
52
|
+
close(): Promise<void>;
|
|
53
|
+
/** `await using` support — delegates to {@link Device.close}. */
|
|
54
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
55
|
+
|
|
56
|
+
// --- the action surface (docs/20 item 4): flat hot path -------------------
|
|
57
|
+
/** Look at the screen: elements + rendered text + portable Action[]. */
|
|
58
|
+
observe(opts?: { signal?: AbortSignal | undefined }): Promise<ObserveResult>;
|
|
59
|
+
/** Tap by label/id query. Auto-waits; never throws for normal outcomes. */
|
|
60
|
+
tap(target: string | ElementQuery, opts?: ActOptions): Promise<ActionResult>;
|
|
61
|
+
/** Type text (optionally into a field resolved by query); %name% secrets substituted. */
|
|
62
|
+
type(
|
|
63
|
+
text: string,
|
|
64
|
+
opts?: ActOptions & { field?: string | ElementQuery | undefined; submit?: boolean | undefined },
|
|
65
|
+
): Promise<ActionResult>;
|
|
66
|
+
/** Execute a portable Action deterministically — no re-inference. */
|
|
67
|
+
act(action: Action, opts?: ActOptions): Promise<ActionResult>;
|
|
68
|
+
|
|
69
|
+
// --- grouped breadth -------------------------------------------------------
|
|
70
|
+
/** App management: open by name/deep link, list installed, current app. */
|
|
71
|
+
readonly apps: {
|
|
72
|
+
/** Open an app by name/bundle id, or a deep link when `url` is set. */
|
|
73
|
+
open(
|
|
74
|
+
app: string,
|
|
75
|
+
opts?: { relaunch?: boolean | undefined; url?: string | undefined; signal?: AbortSignal | undefined },
|
|
76
|
+
): Promise<ActionResult>;
|
|
77
|
+
/** List installed app bundle ids. */
|
|
78
|
+
list(opts?: { signal?: AbortSignal | undefined }): Promise<string[]>;
|
|
79
|
+
/** The frontmost app name from the last observation (no new snapshot). */
|
|
80
|
+
current(): string | undefined;
|
|
81
|
+
};
|
|
82
|
+
/** Screen-level verbs: scroll, screenshot, waitForText, alert, back, home. */
|
|
83
|
+
readonly screen: {
|
|
84
|
+
/** Scroll the active scroll view one step. */
|
|
85
|
+
scroll(direction: ScrollDirection, opts?: { signal?: AbortSignal | undefined }): Promise<ActionResult>;
|
|
86
|
+
/** Save a screenshot to `path`. */
|
|
87
|
+
screenshot(opts: {
|
|
88
|
+
path: string;
|
|
89
|
+
signal?: AbortSignal | undefined;
|
|
90
|
+
}): Promise<{ success: boolean; message: string; path?: string | undefined }>;
|
|
91
|
+
/** Block until `text` appears on screen or the timeout elapses. */
|
|
92
|
+
waitForText(
|
|
93
|
+
text: string,
|
|
94
|
+
opts?: { timeoutMs?: number | undefined; signal?: AbortSignal | undefined },
|
|
95
|
+
): Promise<ActionResult>;
|
|
96
|
+
/** Read ('get'), accept, or dismiss a blocking system alert. */
|
|
97
|
+
alert(
|
|
98
|
+
action: 'get' | 'accept' | 'dismiss',
|
|
99
|
+
opts?: { signal?: AbortSignal | undefined },
|
|
100
|
+
): Promise<ActionResult>;
|
|
101
|
+
/** Navigate back. */
|
|
102
|
+
back(opts?: { signal?: AbortSignal | undefined }): Promise<ActionResult>;
|
|
103
|
+
/** Go to the home screen. */
|
|
104
|
+
home(opts?: { signal?: AbortSignal | undefined }): Promise<ActionResult>;
|
|
105
|
+
};
|
|
106
|
+
/** %name% secret store — values substituted at execution, redacted everywhere else. */
|
|
107
|
+
readonly secrets: SecretStore;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The idle lease: an unref'd timer, so a LEAKED handle can never hold the
|
|
111
|
+
// process open — and on expiry the reaper closes the device, so a leaked
|
|
112
|
+
// handle can't poison the host with an orphaned booted sim either (the
|
|
113
|
+
// bench-hang failure mode). Limits, honestly: this protects in-process leaks
|
|
114
|
+
// only; a kill -9'd process orphans the sim until external cleanup — created
|
|
115
|
+
// sims carry the `phone-use-` name prefix precisely so
|
|
116
|
+
// `xcrun simctl list devices -j` can find and delete them.
|
|
117
|
+
export class IdleLease {
|
|
118
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
119
|
+
private readonly windowMs: number | false;
|
|
120
|
+
private readonly onExpire: () => void;
|
|
121
|
+
|
|
122
|
+
constructor(windowMs: number | false, onExpire: () => void) {
|
|
123
|
+
this.windowMs = windowMs;
|
|
124
|
+
this.onExpire = onExpire;
|
|
125
|
+
this.touch();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Re-arm with the configured window (no-op when disabled). */
|
|
129
|
+
touch(): void {
|
|
130
|
+
this.arm(this.windowMs);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Re-arm with a one-shot override window. */
|
|
134
|
+
extend(ms?: number): void {
|
|
135
|
+
this.arm(ms ?? this.windowMs);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private arm(ms: number | false): void {
|
|
139
|
+
if (this.timer) clearTimeout(this.timer);
|
|
140
|
+
this.timer = null;
|
|
141
|
+
if (ms === false) return;
|
|
142
|
+
const t = setTimeout(this.onExpire, ms);
|
|
143
|
+
// Fake-timer objects may lack unref — guard, don't crash.
|
|
144
|
+
t.unref?.();
|
|
145
|
+
this.timer = t;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
dispose(): void {
|
|
149
|
+
if (this.timer) clearTimeout(this.timer);
|
|
150
|
+
this.timer = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Inputs to {@link createDeviceHandle} — what an engine supplies per device. */
|
|
155
|
+
export type CreateDeviceHandleOptions = {
|
|
156
|
+
/** udid (iOS) / serial (Android). */
|
|
157
|
+
id: string;
|
|
158
|
+
/** Which platform the device runs. */
|
|
159
|
+
platform: DevicePlatform;
|
|
160
|
+
/** Simulator/device name when known. */
|
|
161
|
+
name?: string | undefined;
|
|
162
|
+
/** The backend pinned to this device. */
|
|
163
|
+
backend: DeviceBackend;
|
|
164
|
+
/** true when the engine created the device (close() then also deletes it). */
|
|
165
|
+
createdByUs: boolean;
|
|
166
|
+
/** Idle window in ms; false disables the lease. Default 180_000 (3m). */
|
|
167
|
+
idleTimeoutMs?: number | false | undefined;
|
|
168
|
+
/** Observer for reaper-initiated closes (the SDK never logs). */
|
|
169
|
+
onIdleClose?: ((device: Device) => void) | undefined;
|
|
170
|
+
/** Initial %name% secret values. */
|
|
171
|
+
secrets?: Record<string, string> | undefined;
|
|
172
|
+
/** @internal harness seam — supplies the verb core (DeviceContext extends DeviceCore). */
|
|
173
|
+
coreFactory?: ((backend: DeviceBackend) => DeviceCore) | undefined;
|
|
174
|
+
/** Platform teardown: shutdown (+ delete when createdByUs). */
|
|
175
|
+
doClose: () => Promise<void>;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Assemble a Device handle over a backend: lease/reaper, verb surface,
|
|
180
|
+
* close/dispose semantics. Engine authors (ios here, android in item 7b,
|
|
181
|
+
* phone-backend-* third parties) build on this; tests fabricate devices with
|
|
182
|
+
* it over a FakeBackend.
|
|
183
|
+
*/
|
|
184
|
+
export function createDeviceHandle(opts: CreateDeviceHandleOptions): Device {
|
|
185
|
+
let status: DeviceStatus = 'running';
|
|
186
|
+
let closePromise: Promise<void> | null = null;
|
|
187
|
+
|
|
188
|
+
const close = (): Promise<void> => {
|
|
189
|
+
closePromise ??= (async () => {
|
|
190
|
+
status = 'closed';
|
|
191
|
+
lease.dispose();
|
|
192
|
+
// Best-effort session close; a session may never have opened (agent-device
|
|
193
|
+
// sessions are lazy), so swallow the not-found case.
|
|
194
|
+
await opts.backend.closeSession().catch(() => undefined);
|
|
195
|
+
await opts.doClose();
|
|
196
|
+
})();
|
|
197
|
+
return closePromise;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const lease = new IdleLease(opts.idleTimeoutMs ?? 180_000, () => {
|
|
201
|
+
void close()
|
|
202
|
+
.catch(() => undefined)
|
|
203
|
+
.then(() => opts.onIdleClose?.(device));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Backend calls through the handle count as lease activity — without this,
|
|
207
|
+
// a long bench run driving DeviceContext(device.backend) would be reaped
|
|
208
|
+
// mid-run, recreating the exact hazard the lease exists to prevent.
|
|
209
|
+
const touchingBackend = new Proxy(opts.backend, {
|
|
210
|
+
get(target, prop, receiver) {
|
|
211
|
+
const value = Reflect.get(target, prop, receiver);
|
|
212
|
+
if (typeof value !== 'function') return value;
|
|
213
|
+
return (...args: unknown[]) => {
|
|
214
|
+
if (status === 'running') lease.touch();
|
|
215
|
+
return (value as (...a: unknown[]) => unknown).apply(target, args);
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// The verb surface drives its own DeviceCore over the lease-touching proxy,
|
|
221
|
+
// so every verb call counts as activity. One brain: all logic lives in
|
|
222
|
+
// DeviceCore + executeAction; the Device only wires them together.
|
|
223
|
+
const core = opts.coreFactory?.(touchingBackend) ?? new DeviceCore(touchingBackend);
|
|
224
|
+
const secrets = new SecretStore(opts.secrets);
|
|
225
|
+
|
|
226
|
+
const assertOpen = (): void => {
|
|
227
|
+
if (status === 'closed') throw new SessionNotFoundError(`device ${opts.id} is closed`);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const toQuery = (target: string | ElementQuery): ElementQuery =>
|
|
231
|
+
typeof target === 'string' ? { label: target } : target;
|
|
232
|
+
|
|
233
|
+
const device: Device = {
|
|
234
|
+
id: opts.id,
|
|
235
|
+
platform: opts.platform,
|
|
236
|
+
name: opts.name,
|
|
237
|
+
backendName: opts.backend.backendName,
|
|
238
|
+
capabilities: opts.backend.capabilities,
|
|
239
|
+
backend: touchingBackend,
|
|
240
|
+
createdByUs: opts.createdByUs,
|
|
241
|
+
get status() {
|
|
242
|
+
return status;
|
|
243
|
+
},
|
|
244
|
+
get isClosed() {
|
|
245
|
+
return status === 'closed';
|
|
246
|
+
},
|
|
247
|
+
extendLease(ms?: number) {
|
|
248
|
+
if (status === 'running') lease.extend(ms);
|
|
249
|
+
},
|
|
250
|
+
close,
|
|
251
|
+
[Symbol.asyncDispose]: close,
|
|
252
|
+
|
|
253
|
+
observe() {
|
|
254
|
+
assertOpen();
|
|
255
|
+
return buildObserveResult(core, secrets);
|
|
256
|
+
},
|
|
257
|
+
tap(target, actOpts = {}) {
|
|
258
|
+
assertOpen();
|
|
259
|
+
return executeAction(
|
|
260
|
+
core,
|
|
261
|
+
{ formatVersion: 0, verb: 'tap', target: toQuery(target) },
|
|
262
|
+
actOpts,
|
|
263
|
+
secrets,
|
|
264
|
+
);
|
|
265
|
+
},
|
|
266
|
+
type(text, actOpts = {}) {
|
|
267
|
+
assertOpen();
|
|
268
|
+
const { field, submit, ...rest } = actOpts;
|
|
269
|
+
const action: Action =
|
|
270
|
+
field === undefined
|
|
271
|
+
? { formatVersion: 0, verb: 'type', params: { text, submit } }
|
|
272
|
+
: { formatVersion: 0, verb: 'fill', target: toQuery(field), params: { text, submit } };
|
|
273
|
+
return executeAction(core, action, rest, secrets);
|
|
274
|
+
},
|
|
275
|
+
act(action, actOpts = {}) {
|
|
276
|
+
assertOpen();
|
|
277
|
+
return executeAction(core, action, actOpts, secrets);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
apps: {
|
|
281
|
+
open(app, o = {}) {
|
|
282
|
+
assertOpen();
|
|
283
|
+
const action: Action =
|
|
284
|
+
o.url === undefined
|
|
285
|
+
? { formatVersion: 0, verb: 'openApp', params: { app, relaunch: o.relaunch } }
|
|
286
|
+
: { formatVersion: 0, verb: 'openUrl', params: { app, url: o.url } };
|
|
287
|
+
return executeAction(core, action, { signal: o.signal }, secrets);
|
|
288
|
+
},
|
|
289
|
+
list(o = {}) {
|
|
290
|
+
assertOpen();
|
|
291
|
+
void o;
|
|
292
|
+
return core.listApps();
|
|
293
|
+
},
|
|
294
|
+
current() {
|
|
295
|
+
return core.currentApp();
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
screen: {
|
|
299
|
+
scroll(direction, o = {}) {
|
|
300
|
+
assertOpen();
|
|
301
|
+
return executeAction(
|
|
302
|
+
core,
|
|
303
|
+
{ formatVersion: 0, verb: 'scroll', params: { direction } },
|
|
304
|
+
{ signal: o.signal },
|
|
305
|
+
secrets,
|
|
306
|
+
);
|
|
307
|
+
},
|
|
308
|
+
async screenshot(o) {
|
|
309
|
+
assertOpen();
|
|
310
|
+
const path = await core.screenshot(o.path);
|
|
311
|
+
return { success: true, message: `screenshot saved`, path };
|
|
312
|
+
},
|
|
313
|
+
waitForText(text, o = {}) {
|
|
314
|
+
assertOpen();
|
|
315
|
+
return executeAction(
|
|
316
|
+
core,
|
|
317
|
+
{ formatVersion: 0, verb: 'waitForText', params: { text } },
|
|
318
|
+
{ signal: o.signal, timeoutMs: o.timeoutMs },
|
|
319
|
+
secrets,
|
|
320
|
+
);
|
|
321
|
+
},
|
|
322
|
+
alert(action, o = {}) {
|
|
323
|
+
assertOpen();
|
|
324
|
+
if (action === 'get') {
|
|
325
|
+
return core.handleAlert('get').then((r) => ({
|
|
326
|
+
success: r.present,
|
|
327
|
+
message: r.present ? `alert: ${r.description ?? ''}` : 'no system alert is showing',
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
return executeAction(
|
|
331
|
+
core,
|
|
332
|
+
{ formatVersion: 0, verb: 'alert', params: { alertAction: action } },
|
|
333
|
+
{ signal: o.signal },
|
|
334
|
+
secrets,
|
|
335
|
+
);
|
|
336
|
+
},
|
|
337
|
+
back(o = {}) {
|
|
338
|
+
assertOpen();
|
|
339
|
+
return executeAction(core, { formatVersion: 0, verb: 'back' }, { signal: o.signal }, secrets);
|
|
340
|
+
},
|
|
341
|
+
home(o = {}) {
|
|
342
|
+
assertOpen();
|
|
343
|
+
return executeAction(core, { formatVersion: 0, verb: 'home' }, { signal: o.signal }, secrets);
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
secrets,
|
|
347
|
+
};
|
|
348
|
+
return device;
|
|
349
|
+
}
|