@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/backend.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { DeviceConfig } from './config.ts';
|
|
2
|
+
import type {
|
|
3
|
+
AlertAction,
|
|
4
|
+
BackendAlertResult,
|
|
5
|
+
Capability,
|
|
6
|
+
OpenAppResult,
|
|
7
|
+
PressTarget,
|
|
8
|
+
ScrollDirection,
|
|
9
|
+
Snapshot,
|
|
10
|
+
} from './device.ts';
|
|
11
|
+
import { PhoneUseError, UnsupportedCapabilityError } from './errors.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The backend contract (docs/19 §Backend contract): a STRUCTURAL interface —
|
|
15
|
+
* any object with these methods is a backend — plus an optional
|
|
16
|
+
* {@link BaseDeviceBackend} class with the plumbing done (Appium BaseDriver
|
|
17
|
+
* ergonomics). Methods reject only with PhoneUseError subclasses; backends
|
|
18
|
+
* normalize their transport's errors at the boundary.
|
|
19
|
+
*/
|
|
20
|
+
export interface DeviceBackend {
|
|
21
|
+
/** Stable backend identifier, e.g. `"agent-device"`. */
|
|
22
|
+
readonly backendName: string;
|
|
23
|
+
/** The capabilities this backend actually implements. */
|
|
24
|
+
readonly capabilities: ReadonlySet<Capability>;
|
|
25
|
+
|
|
26
|
+
/** Capture the accessibility tree of the frontmost app. */
|
|
27
|
+
snapshot(opts?: { interactiveOnly?: boolean | undefined; depth?: number | undefined }): Promise<Snapshot>;
|
|
28
|
+
/** Save a screenshot to `path` (optionally with `@ref` overlays drawn). */
|
|
29
|
+
screenshot(opts: { path: string; overlayRefs?: boolean | undefined }): Promise<{ path: string }>;
|
|
30
|
+
/** Tap an element ref or raw coordinates. */
|
|
31
|
+
press(target: PressTarget): Promise<void>;
|
|
32
|
+
/** Long-press an element ref. */
|
|
33
|
+
longPress(ref: string, durationMs?: number): Promise<void>;
|
|
34
|
+
/** Focus an element and replace its text. */
|
|
35
|
+
fill(ref: string, text: string): Promise<void>;
|
|
36
|
+
/** Type into whatever currently has keyboard focus. */
|
|
37
|
+
typeText(text: string): Promise<void>;
|
|
38
|
+
/** Press a hardware/keyboard key (Return only, this cycle). */
|
|
39
|
+
pressKey(key: 'return'): Promise<void>;
|
|
40
|
+
/** Scroll the active scroll view one step. */
|
|
41
|
+
scroll(direction: ScrollDirection): Promise<void>;
|
|
42
|
+
/** Coordinate drag: touch down at (x,y), move by (dx,dy). Operates picker wheels, sliders, carousels. */
|
|
43
|
+
pan(x: number, y: number, dx: number, dy: number, durationMs?: number): Promise<void>;
|
|
44
|
+
/** Block until `text` appears on screen or the timeout elapses. */
|
|
45
|
+
waitForText(text: string, timeoutMs?: number): Promise<void>;
|
|
46
|
+
/** Read/accept/dismiss the app's own alert via the transport's alert command. */
|
|
47
|
+
systemAlert(action: AlertAction): Promise<BackendAlertResult>;
|
|
48
|
+
/** Go to the home screen. */
|
|
49
|
+
home(): Promise<void>;
|
|
50
|
+
/** Navigate back (hardware back / nav-bar back). */
|
|
51
|
+
back(): Promise<void>;
|
|
52
|
+
/** Open an app by name/bundle id, or a URL (deep link). */
|
|
53
|
+
openApp(opts: {
|
|
54
|
+
app?: string | undefined;
|
|
55
|
+
url?: string | undefined;
|
|
56
|
+
relaunch?: boolean | undefined;
|
|
57
|
+
}): Promise<OpenAppResult>;
|
|
58
|
+
/** List installed app bundle ids. */
|
|
59
|
+
listApps(): Promise<string[]>;
|
|
60
|
+
/** Close the transport session (idempotent; safe when none is open). */
|
|
61
|
+
closeSession(): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Optional plumbing base: every method rejects with a typed
|
|
66
|
+
* UnsupportedCapabilityError until overridden. Subclasses declare their
|
|
67
|
+
* capability set and override exactly what they support.
|
|
68
|
+
*/
|
|
69
|
+
export abstract class BaseDeviceBackend implements DeviceBackend {
|
|
70
|
+
readonly backendName: string;
|
|
71
|
+
readonly capabilities: ReadonlySet<Capability>;
|
|
72
|
+
|
|
73
|
+
protected constructor(backendName: string, capabilities: Iterable<Capability>) {
|
|
74
|
+
this.backendName = backendName;
|
|
75
|
+
this.capabilities = new Set(capabilities);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
protected unsupported(capability: Capability): UnsupportedCapabilityError {
|
|
79
|
+
return new UnsupportedCapabilityError({ backend: this.backendName, capability });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Throws UnsupportedCapabilityError unless this backend declares `capability`. */
|
|
83
|
+
requireCapability(capability: Capability): void {
|
|
84
|
+
if (!this.capabilities.has(capability)) throw this.unsupported(capability);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
snapshot(_opts?: { interactiveOnly?: boolean | undefined; depth?: number | undefined }): Promise<Snapshot> {
|
|
88
|
+
return Promise.reject(this.unsupported('snapshot'));
|
|
89
|
+
}
|
|
90
|
+
screenshot(_opts: { path: string; overlayRefs?: boolean | undefined }): Promise<{ path: string }> {
|
|
91
|
+
return Promise.reject(this.unsupported('screenshot'));
|
|
92
|
+
}
|
|
93
|
+
press(_target: PressTarget): Promise<void> {
|
|
94
|
+
return Promise.reject(this.unsupported('press'));
|
|
95
|
+
}
|
|
96
|
+
longPress(_ref: string, _durationMs?: number): Promise<void> {
|
|
97
|
+
return Promise.reject(this.unsupported('longPress'));
|
|
98
|
+
}
|
|
99
|
+
fill(_ref: string, _text: string): Promise<void> {
|
|
100
|
+
return Promise.reject(this.unsupported('fill'));
|
|
101
|
+
}
|
|
102
|
+
typeText(_text: string): Promise<void> {
|
|
103
|
+
return Promise.reject(this.unsupported('type'));
|
|
104
|
+
}
|
|
105
|
+
pressKey(_key: 'return'): Promise<void> {
|
|
106
|
+
return Promise.reject(this.unsupported('key'));
|
|
107
|
+
}
|
|
108
|
+
scroll(_direction: ScrollDirection): Promise<void> {
|
|
109
|
+
return Promise.reject(this.unsupported('scroll'));
|
|
110
|
+
}
|
|
111
|
+
pan(_x: number, _y: number, _dx: number, _dy: number, _durationMs?: number): Promise<void> {
|
|
112
|
+
return Promise.reject(this.unsupported('pan'));
|
|
113
|
+
}
|
|
114
|
+
waitForText(_text: string, _timeoutMs?: number): Promise<void> {
|
|
115
|
+
return Promise.reject(this.unsupported('waitForText'));
|
|
116
|
+
}
|
|
117
|
+
systemAlert(_action: AlertAction): Promise<BackendAlertResult> {
|
|
118
|
+
return Promise.reject(this.unsupported('alert'));
|
|
119
|
+
}
|
|
120
|
+
home(): Promise<void> {
|
|
121
|
+
return Promise.reject(this.unsupported('home'));
|
|
122
|
+
}
|
|
123
|
+
back(): Promise<void> {
|
|
124
|
+
return Promise.reject(this.unsupported('back'));
|
|
125
|
+
}
|
|
126
|
+
openApp(_opts: {
|
|
127
|
+
app?: string | undefined;
|
|
128
|
+
url?: string | undefined;
|
|
129
|
+
relaunch?: boolean | undefined;
|
|
130
|
+
}): Promise<OpenAppResult> {
|
|
131
|
+
return Promise.reject(this.unsupported('openApp'));
|
|
132
|
+
}
|
|
133
|
+
listApps(): Promise<string[]> {
|
|
134
|
+
return Promise.reject(this.unsupported('listApps'));
|
|
135
|
+
}
|
|
136
|
+
closeSession(): Promise<void> {
|
|
137
|
+
return Promise.reject(this.unsupported('closeSession'));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Runtime backend registry (registerBackend + phone-backend-* convention,
|
|
143
|
+
// docs/19). A deliberate module-level Map: this IS the registry — mirrored on
|
|
144
|
+
// permissions.ts's documented pattern; the item-2 grep audit is scoped to
|
|
145
|
+
// driver code.
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/** Builds a backend from an optional {@link DeviceConfig}. */
|
|
149
|
+
export type BackendFactory = (config?: DeviceConfig) => DeviceBackend | Promise<DeviceBackend>;
|
|
150
|
+
|
|
151
|
+
const factories = new Map<string, BackendFactory>();
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Register a backend factory under a unique name (the `phone-backend-*`
|
|
155
|
+
* convention for third parties). Throws if the name is already taken.
|
|
156
|
+
*/
|
|
157
|
+
export function registerBackend(name: string, factory: BackendFactory): void {
|
|
158
|
+
if (factories.has(name)) {
|
|
159
|
+
throw new PhoneUseError(`backend "${name}" is already registered`, {
|
|
160
|
+
code: 'ACTION_FAILED',
|
|
161
|
+
retryable: false,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
factories.set(name, factory);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Look up a registered factory by name; throws `BACKEND_NOT_FOUND` if absent. */
|
|
168
|
+
export function getBackendFactory(name: string): BackendFactory {
|
|
169
|
+
const factory = factories.get(name);
|
|
170
|
+
if (!factory) {
|
|
171
|
+
throw new PhoneUseError(
|
|
172
|
+
`no backend named "${name}" — registered: ${[...factories.keys()].join(', ') || '(none)'}`,
|
|
173
|
+
{
|
|
174
|
+
code: 'BACKEND_NOT_FOUND',
|
|
175
|
+
retryable: false,
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return factory;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Names of every registered backend, in registration order. */
|
|
183
|
+
export function listBackends(): string[] {
|
|
184
|
+
return [...factories.keys()];
|
|
185
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { createAgentDeviceClient } from 'agent-device';
|
|
2
|
+
import type { DeviceBackend } from '../backend.ts';
|
|
3
|
+
import { BaseDeviceBackend } from '../backend.ts';
|
|
4
|
+
import type { DeviceConfig } from '../config.ts';
|
|
5
|
+
import type {
|
|
6
|
+
AlertAction,
|
|
7
|
+
BackendAlertResult,
|
|
8
|
+
Capability,
|
|
9
|
+
OpenAppResult,
|
|
10
|
+
PressTarget,
|
|
11
|
+
ScrollDirection,
|
|
12
|
+
Snapshot,
|
|
13
|
+
} from '../device.ts';
|
|
14
|
+
import { ALL_CAPABILITIES } from '../device.ts';
|
|
15
|
+
import { toPhoneUseError } from '../errors.ts';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// The agent-device backend: the ONE place agent-device is called. Device
|
|
19
|
+
// pinning is per-request in agent-device (AgentDeviceSelectionOptions), so a
|
|
20
|
+
// backend holds a selection object and spreads it into every call. With no
|
|
21
|
+
// config, selection is {} and the client is default-constructed — requests are
|
|
22
|
+
// byte-identical to the pre-seam process-global path (booted-sim auto-detect),
|
|
23
|
+
// which is the item-2 backward-compat guarantee (docs/20 risk: default-
|
|
24
|
+
// selection drift).
|
|
25
|
+
//
|
|
26
|
+
// Every method normalizes errors via toPhoneUseError — no agent-device type
|
|
27
|
+
// or error ever escapes this file.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
type AdClient = ReturnType<typeof createAgentDeviceClient>;
|
|
31
|
+
|
|
32
|
+
class AgentDeviceBackend extends BaseDeviceBackend {
|
|
33
|
+
private readonly client: AdClient;
|
|
34
|
+
private readonly selection: Record<string, unknown>;
|
|
35
|
+
// Sessions are lazy daemon-side: nothing exists to close until a first real
|
|
36
|
+
// call is made, and asking the daemon anyway would SPAWN one on hosts where
|
|
37
|
+
// it isn't running (observed in the Mac verification sweep).
|
|
38
|
+
private used = false;
|
|
39
|
+
|
|
40
|
+
constructor(config?: DeviceConfig) {
|
|
41
|
+
super('agent-device', ALL_CAPABILITIES);
|
|
42
|
+
this.client = createAgentDeviceClient(
|
|
43
|
+
config &&
|
|
44
|
+
(config.session !== undefined ||
|
|
45
|
+
config.daemonBaseUrl !== undefined ||
|
|
46
|
+
config.daemonAuthToken !== undefined)
|
|
47
|
+
? {
|
|
48
|
+
...(config.session === undefined ? {} : { session: config.session }),
|
|
49
|
+
...(config.daemonBaseUrl === undefined ? {} : { daemonBaseUrl: config.daemonBaseUrl }),
|
|
50
|
+
...(config.daemonAuthToken === undefined ? {} : { daemonAuthToken: config.daemonAuthToken }),
|
|
51
|
+
}
|
|
52
|
+
: undefined,
|
|
53
|
+
);
|
|
54
|
+
this.selection = !config
|
|
55
|
+
? {}
|
|
56
|
+
: {
|
|
57
|
+
platform: config.platform,
|
|
58
|
+
...(config.device === undefined ? {} : { device: config.device }),
|
|
59
|
+
...(config.platform === 'ios' && config.udid !== undefined ? { udid: config.udid } : {}),
|
|
60
|
+
...(config.platform === 'ios' && config.simulatorDeviceSet !== undefined
|
|
61
|
+
? { iosSimulatorDeviceSet: config.simulatorDeviceSet }
|
|
62
|
+
: {}),
|
|
63
|
+
...(config.platform === 'android' && config.serial !== undefined ? { serial: config.serial } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async guard<T>(capability: Capability, fn: () => Promise<T>): Promise<T> {
|
|
68
|
+
if (capability !== 'closeSession') this.used = true;
|
|
69
|
+
try {
|
|
70
|
+
return await fn();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw toPhoneUseError(error, { backend: this.backendName, capability });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override snapshot(opts?: {
|
|
77
|
+
interactiveOnly?: boolean | undefined;
|
|
78
|
+
depth?: number | undefined;
|
|
79
|
+
}): Promise<Snapshot> {
|
|
80
|
+
return this.guard('snapshot', async () => {
|
|
81
|
+
const snap = await this.client.capture.snapshot({
|
|
82
|
+
...this.selection,
|
|
83
|
+
...(opts?.interactiveOnly === undefined ? {} : { interactiveOnly: opts.interactiveOnly }),
|
|
84
|
+
...(opts?.depth === undefined ? {} : { depth: opts.depth }),
|
|
85
|
+
});
|
|
86
|
+
return { nodes: snap.nodes, appName: snap.appName, appBundleId: snap.appBundleId };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
override screenshot(opts: { path: string; overlayRefs?: boolean | undefined }): Promise<{ path: string }> {
|
|
91
|
+
return this.guard('screenshot', async () => {
|
|
92
|
+
const result = await this.client.capture.screenshot({
|
|
93
|
+
...this.selection,
|
|
94
|
+
path: opts.path,
|
|
95
|
+
...(opts.overlayRefs === undefined ? {} : { overlayRefs: opts.overlayRefs }),
|
|
96
|
+
});
|
|
97
|
+
return { path: result.path };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
override press(target: PressTarget): Promise<void> {
|
|
102
|
+
return this.guard('press', async () => {
|
|
103
|
+
await this.client.interactions.press({ ...this.selection, ...target });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
override longPress(ref: string, durationMs?: number): Promise<void> {
|
|
108
|
+
return this.guard('longPress', async () => {
|
|
109
|
+
// settle: true preserved exactly from the pre-seam driver.
|
|
110
|
+
await this.client.interactions.longPress({
|
|
111
|
+
...this.selection,
|
|
112
|
+
ref,
|
|
113
|
+
...(durationMs === undefined ? {} : { durationMs }),
|
|
114
|
+
settle: true,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
override fill(ref: string, text: string): Promise<void> {
|
|
120
|
+
return this.guard('fill', async () => {
|
|
121
|
+
await this.client.interactions.fill({ ...this.selection, ref, text });
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
override typeText(text: string): Promise<void> {
|
|
126
|
+
return this.guard('type', async () => {
|
|
127
|
+
await this.client.interactions.type({ ...this.selection, text });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
override pressKey(_key: 'return'): Promise<void> {
|
|
132
|
+
return this.guard('key', async () => {
|
|
133
|
+
await this.client.command.keyboard({ ...this.selection, action: 'return' });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
override scroll(direction: ScrollDirection): Promise<void> {
|
|
138
|
+
return this.guard('scroll', async () => {
|
|
139
|
+
const args = { ...this.selection, direction } as Parameters<AdClient['interactions']['scroll']>[0];
|
|
140
|
+
await this.client.interactions.scroll(args);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
override pan(x: number, y: number, dx: number, dy: number, durationMs?: number): Promise<void> {
|
|
145
|
+
return this.guard('pan', async () => {
|
|
146
|
+
await this.client.interactions.pan({
|
|
147
|
+
...this.selection,
|
|
148
|
+
x,
|
|
149
|
+
y,
|
|
150
|
+
dx,
|
|
151
|
+
dy,
|
|
152
|
+
...(durationMs === undefined ? {} : { durationMs }),
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
override waitForText(text: string, timeoutMs?: number): Promise<void> {
|
|
158
|
+
return this.guard('waitForText', async () => {
|
|
159
|
+
await this.client.command.wait({
|
|
160
|
+
...this.selection,
|
|
161
|
+
text,
|
|
162
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
override systemAlert(action: AlertAction): Promise<BackendAlertResult> {
|
|
168
|
+
return this.guard('alert', async () => {
|
|
169
|
+
const result = (await this.client.command.alert({ ...this.selection, action })) as {
|
|
170
|
+
alert?: { title?: string; message?: string; buttons?: string[] } | null;
|
|
171
|
+
handled?: boolean;
|
|
172
|
+
button?: string;
|
|
173
|
+
};
|
|
174
|
+
return { alert: result.alert, handled: result.handled, button: result.button };
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
override home(): Promise<void> {
|
|
179
|
+
return this.guard('home', async () => {
|
|
180
|
+
await this.client.command.home({ ...this.selection });
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
override back(): Promise<void> {
|
|
185
|
+
return this.guard('back', async () => {
|
|
186
|
+
await this.client.command.back({ ...this.selection });
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
override openApp(opts: {
|
|
191
|
+
app?: string | undefined;
|
|
192
|
+
url?: string | undefined;
|
|
193
|
+
relaunch?: boolean | undefined;
|
|
194
|
+
}): Promise<OpenAppResult> {
|
|
195
|
+
return this.guard('openApp', async () => {
|
|
196
|
+
// Preserve the pre-seam arg branching exactly:
|
|
197
|
+
// {app, relaunch} for app launches; {app, url} | {url} for deep links.
|
|
198
|
+
const args =
|
|
199
|
+
opts.url === undefined
|
|
200
|
+
? { app: opts.app, ...(opts.relaunch === undefined ? {} : { relaunch: opts.relaunch }) }
|
|
201
|
+
: opts.app !== undefined
|
|
202
|
+
? { app: opts.app, url: opts.url }
|
|
203
|
+
: { url: opts.url };
|
|
204
|
+
const result = (await this.client.apps.open({ ...this.selection, ...args } as Parameters<
|
|
205
|
+
AdClient['apps']['open']
|
|
206
|
+
>[0])) as { appName?: string; appBundleId?: string };
|
|
207
|
+
return { appName: result.appName, appBundleId: result.appBundleId };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
override listApps(): Promise<string[]> {
|
|
212
|
+
return this.guard('listApps', async () => {
|
|
213
|
+
const result = (await this.client.apps.list(
|
|
214
|
+
Object.keys(this.selection).length
|
|
215
|
+
? (this.selection as Parameters<AdClient['apps']['list']>[0])
|
|
216
|
+
: undefined,
|
|
217
|
+
)) as unknown as string[];
|
|
218
|
+
return result;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
override closeSession(): Promise<void> {
|
|
223
|
+
if (!this.used) return Promise.resolve();
|
|
224
|
+
return this.guard('closeSession', async () => {
|
|
225
|
+
// Pre-seam behavior: close({}) — session override only when pinned.
|
|
226
|
+
await this.client.sessions.close({ ...this.selection });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Build the agent-device backend: the ONE place agent-device is called. Device
|
|
233
|
+
* pinning is per-request in agent-device, so the backend holds a selection
|
|
234
|
+
* object and spreads it into every call. With no config, selection is `{}` and
|
|
235
|
+
* the client is default-constructed — requests are byte-identical to the
|
|
236
|
+
* pre-seam process-global path (booted-sim auto-detect). Every method
|
|
237
|
+
* normalizes errors via `toPhoneUseError`; no agent-device type or error ever
|
|
238
|
+
* escapes this module.
|
|
239
|
+
*/
|
|
240
|
+
export function createAgentDeviceBackend(config?: DeviceConfig): DeviceBackend {
|
|
241
|
+
return new AgentDeviceBackend(config);
|
|
242
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { IosDeviceConfig } from '../config.ts';
|
|
2
|
+
import { ActionFailedError, DeviceNotFoundError, TimeoutError, toPhoneUseError } from '../errors.ts';
|
|
3
|
+
import { defaultExecRunner, type ExecRunner, isExecError } from '../exec.ts';
|
|
4
|
+
import { createDeviceHandle, type Device } from '../lifecycle.ts';
|
|
5
|
+
import { createAgentDeviceBackend } from './agent-device.ts';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// The iOS engine (docs/19 §Lifecycle, engine-as-object): ios.launch() creates
|
|
9
|
+
// and boots a DEDICATED simulator via simctl — no more "whatever is booted" —
|
|
10
|
+
// and returns a Device whose backend is pinned to that udid. ios.connect()
|
|
11
|
+
// reattaches; its no-arg form is the sole survivor of the old booted-sim
|
|
12
|
+
// auto-detect. Scripts never branch on locality: connect(endpoint) for cloud
|
|
13
|
+
// devices is reserved API (docs/20 kill list — local-only this cycle).
|
|
14
|
+
//
|
|
15
|
+
// Created sims are named `phone-use-<hex>` deliberately: if a process is
|
|
16
|
+
// kill -9'd, the in-process reaper can't run, and the name prefix is how
|
|
17
|
+
// orphans are found (`xcrun simctl list devices -j` | filter the prefix).
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
type CommonIosOptions = {
|
|
21
|
+
/** Custom simulator device set directory (maps to `simctl --set`). */
|
|
22
|
+
simulatorDeviceSet?: string | undefined;
|
|
23
|
+
/** agent-device session/daemon pinning, passed through to the backend. */
|
|
24
|
+
session?: string | undefined;
|
|
25
|
+
daemonBaseUrl?: string | undefined;
|
|
26
|
+
daemonAuthToken?: string | undefined;
|
|
27
|
+
/** Idle lease window in ms (false disables). Default 180_000 (3 min). */
|
|
28
|
+
idleTimeoutMs?: number | false | undefined;
|
|
29
|
+
/** Observer for reaper-initiated closes. */
|
|
30
|
+
onIdleClose?: ((device: Device) => void) | undefined;
|
|
31
|
+
/** Initial %name% secret values (see Device.secrets). */
|
|
32
|
+
secrets?: Record<string, string> | undefined;
|
|
33
|
+
/** @internal harness seam — supplies the verb core (see createDeviceHandle). */
|
|
34
|
+
coreFactory?:
|
|
35
|
+
| ((backend: import('../backend.ts').DeviceBackend) => import('../observe.ts').DeviceCore)
|
|
36
|
+
| undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Probe the agent-device daemon right away (one listApps) so a missing
|
|
39
|
+
* daemon fails at launch instead of on first use. Default false: sessions
|
|
40
|
+
* open lazily and a probe requires the daemon to exist.
|
|
41
|
+
*/
|
|
42
|
+
failFast?: boolean | undefined;
|
|
43
|
+
/** @internal test seam — DI'd process runner (docs/19 test strategy). */
|
|
44
|
+
exec?: ExecRunner | undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Options for `ios.launch()` — device type, runtime, name, boot ceiling. */
|
|
48
|
+
export type IosLaunchOptions = CommonIosOptions & {
|
|
49
|
+
/** simctl device type, e.g. "iPhone 16" (the default). */
|
|
50
|
+
deviceType?: string | undefined;
|
|
51
|
+
/** simctl runtime id; omitted → newest compatible. */
|
|
52
|
+
runtime?: string | undefined;
|
|
53
|
+
/** Simulator name; default `phone-use-<hex>` (the orphan-discovery prefix). */
|
|
54
|
+
name?: string | undefined;
|
|
55
|
+
/** Boot wait ceiling for `simctl bootstatus` (default 120_000 ms). */
|
|
56
|
+
bootTimeoutMs?: number | undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Options for `ios.connect()`. */
|
|
60
|
+
export type IosConnectOptions = CommonIosOptions & {
|
|
61
|
+
/** Boot wait ceiling when connect has to boot a shut-down sim (default 120_000 ms). */
|
|
62
|
+
bootTimeoutMs?: number | undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const UDID_RE = /^[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}$/i;
|
|
66
|
+
|
|
67
|
+
type SimctlDeviceRow = { udid: string; state: string; name?: string; isAvailable?: boolean };
|
|
68
|
+
type SimctlList = { devices: Record<string, SimctlDeviceRow[]> };
|
|
69
|
+
|
|
70
|
+
function simctlArgs(setPath: string | undefined, args: string[]): string[] {
|
|
71
|
+
return setPath === undefined ? ['simctl', ...args] : ['simctl', '--set', setPath, ...args];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Exit 149 = "operation not allowed in current state" (already booted /
|
|
75
|
+
// already shut down). Code first, stderr regex as fallback — Apple rewords
|
|
76
|
+
// messages; the numeric code is a Mac-verification item (docs/20 as-built).
|
|
77
|
+
function isAlreadyInState(err: unknown): boolean {
|
|
78
|
+
if (!isExecError(err)) return false;
|
|
79
|
+
if (err.code === 149) return true;
|
|
80
|
+
return /current state.*(Booted|Shutdown)/i.test(err.stderr ?? '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function runSimctl(
|
|
84
|
+
exec: ExecRunner,
|
|
85
|
+
setPath: string | undefined,
|
|
86
|
+
args: string[],
|
|
87
|
+
opts: { timeoutMs?: number | undefined; tolerateState?: boolean | undefined } = {},
|
|
88
|
+
): Promise<string> {
|
|
89
|
+
try {
|
|
90
|
+
const r = await exec(
|
|
91
|
+
'xcrun',
|
|
92
|
+
simctlArgs(setPath, args),
|
|
93
|
+
opts.timeoutMs === undefined ? undefined : { timeoutMs: opts.timeoutMs },
|
|
94
|
+
);
|
|
95
|
+
return r.stdout;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (opts.tolerateState && isAlreadyInState(err)) return '';
|
|
98
|
+
if (isExecError(err)) {
|
|
99
|
+
if (err.killed || err.signal) {
|
|
100
|
+
throw new TimeoutError(
|
|
101
|
+
`simctl ${args[0]} timed out${opts.timeoutMs ? ` after ${opts.timeoutMs}ms` : ''}`,
|
|
102
|
+
{
|
|
103
|
+
cause: err,
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
throw new ActionFailedError(
|
|
108
|
+
`simctl ${args[0]} failed (exit ${String(err.code ?? '?')}): ${(err.stderr ?? err.message).trim()}`,
|
|
109
|
+
{ details: { backendCode: String(err.code ?? 'EXEC') }, cause: err },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
throw toPhoneUseError(err);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseCreatedUdid(stdout: string): string {
|
|
117
|
+
const lines = stdout
|
|
118
|
+
.split('\n')
|
|
119
|
+
.map((l) => l.trim())
|
|
120
|
+
.filter(Boolean);
|
|
121
|
+
const last = lines[lines.length - 1] ?? '';
|
|
122
|
+
if (!UDID_RE.test(last)) {
|
|
123
|
+
throw new ActionFailedError(
|
|
124
|
+
`could not parse udid from simctl create output: ${JSON.stringify(stdout.slice(0, 200))}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return last;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseList(stdout: string): SimctlDeviceRow[] {
|
|
131
|
+
let parsed: SimctlList;
|
|
132
|
+
try {
|
|
133
|
+
parsed = JSON.parse(stdout) as SimctlList;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
throw new ActionFailedError('could not parse simctl list output as JSON', { cause: err });
|
|
136
|
+
}
|
|
137
|
+
return Object.values(parsed.devices ?? {}).flat();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function makeBackendConfig(udid: string, opts: CommonIosOptions): IosDeviceConfig {
|
|
141
|
+
return {
|
|
142
|
+
platform: 'ios',
|
|
143
|
+
udid,
|
|
144
|
+
...(opts.simulatorDeviceSet === undefined ? {} : { simulatorDeviceSet: opts.simulatorDeviceSet }),
|
|
145
|
+
// The daemon binds one session to one device: a udid-pinned Device on the
|
|
146
|
+
// shared "default" session collides with whatever bound it first (live
|
|
147
|
+
// Mac finding). A udid-derived session name is what makes two Devices
|
|
148
|
+
// independent; deterministic so reconnects reuse the same session.
|
|
149
|
+
session: opts.session ?? `phone-use-${udid}`,
|
|
150
|
+
...(opts.daemonBaseUrl === undefined ? {} : { daemonBaseUrl: opts.daemonBaseUrl }),
|
|
151
|
+
...(opts.daemonAuthToken === undefined ? {} : { daemonAuthToken: opts.daemonAuthToken }),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function bootAndWait(
|
|
156
|
+
exec: ExecRunner,
|
|
157
|
+
setPath: string | undefined,
|
|
158
|
+
udid: string,
|
|
159
|
+
bootTimeoutMs: number,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
await runSimctl(exec, setPath, ['boot', udid], { tolerateState: true });
|
|
162
|
+
// -b boots if needed, closing the boot/bootstatus race; blocks until booted.
|
|
163
|
+
await runSimctl(exec, setPath, ['bootstatus', udid, '-b'], { timeoutMs: bootTimeoutMs });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function finishHandle(
|
|
167
|
+
udid: string,
|
|
168
|
+
name: string | undefined,
|
|
169
|
+
createdByUs: boolean,
|
|
170
|
+
opts: CommonIosOptions,
|
|
171
|
+
exec: ExecRunner,
|
|
172
|
+
): Promise<Device> {
|
|
173
|
+
const backend = createAgentDeviceBackend(makeBackendConfig(udid, opts));
|
|
174
|
+
if (opts.failFast) await backend.listApps();
|
|
175
|
+
return createDeviceHandle({
|
|
176
|
+
id: udid,
|
|
177
|
+
platform: 'ios',
|
|
178
|
+
name,
|
|
179
|
+
backend,
|
|
180
|
+
createdByUs,
|
|
181
|
+
idleTimeoutMs: opts.idleTimeoutMs,
|
|
182
|
+
onIdleClose: opts.onIdleClose,
|
|
183
|
+
secrets: opts.secrets,
|
|
184
|
+
coreFactory: opts.coreFactory,
|
|
185
|
+
doClose: async () => {
|
|
186
|
+
await runSimctl(exec, opts.simulatorDeviceSet, ['shutdown', udid], { tolerateState: true });
|
|
187
|
+
if (createdByUs) await runSimctl(exec, opts.simulatorDeviceSet, ['delete', udid]);
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create and boot a DEDICATED simulator via simctl — no more "whatever is
|
|
194
|
+
* booted" — and return a {@link Device} pinned to its udid. Created sims are
|
|
195
|
+
* named `phone-use-<hex>` deliberately: if the process is kill -9'd the
|
|
196
|
+
* in-process reaper can't run, and the name prefix is how orphans are found.
|
|
197
|
+
* `close()` shuts the sim down AND deletes it (we created it); a failed boot
|
|
198
|
+
* best-effort-deletes before rethrowing.
|
|
199
|
+
*/
|
|
200
|
+
async function launch(options: IosLaunchOptions = {}): Promise<Device> {
|
|
201
|
+
const exec = options.exec ?? defaultExecRunner;
|
|
202
|
+
const deviceType = options.deviceType ?? 'iPhone 16';
|
|
203
|
+
const name = options.name ?? `phone-use-${Math.random().toString(16).slice(2, 10)}`;
|
|
204
|
+
const bootTimeoutMs = options.bootTimeoutMs ?? 120_000;
|
|
205
|
+
|
|
206
|
+
const createArgs = [
|
|
207
|
+
'create',
|
|
208
|
+
name,
|
|
209
|
+
deviceType,
|
|
210
|
+
...(options.runtime === undefined ? [] : [options.runtime]),
|
|
211
|
+
];
|
|
212
|
+
const udid = parseCreatedUdid(await runSimctl(exec, options.simulatorDeviceSet, createArgs));
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
await bootAndWait(exec, options.simulatorDeviceSet, udid, bootTimeoutMs);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
// We created it and it never booted — best-effort delete so the failure
|
|
218
|
+
// doesn't leak a sim, then rethrow the original error.
|
|
219
|
+
await runSimctl(exec, options.simulatorDeviceSet, ['delete', udid]).catch(() => undefined);
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return finishHandle(udid, name, true, options, exec);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Reattach to an existing simulator by udid (booting it if shut down). The
|
|
228
|
+
* no-arg form is the sole survivor of the old booted-sim auto-detect: it
|
|
229
|
+
* attaches to the first booted, available sim. `close()` on a connected
|
|
230
|
+
* device shuts it down but never deletes it.
|
|
231
|
+
*/
|
|
232
|
+
async function connect(udid?: string, options: IosConnectOptions = {}): Promise<Device> {
|
|
233
|
+
const exec = options.exec ?? defaultExecRunner;
|
|
234
|
+
const bootTimeoutMs = options.bootTimeoutMs ?? 120_000;
|
|
235
|
+
|
|
236
|
+
if (udid === undefined) {
|
|
237
|
+
// The surviving auto-detect: attach to the first booted, available sim.
|
|
238
|
+
const rows = parseList(
|
|
239
|
+
await runSimctl(exec, options.simulatorDeviceSet, ['list', 'devices', 'booted', '-j']),
|
|
240
|
+
);
|
|
241
|
+
const booted = rows.find((d) => d.state === 'Booted' && d.isAvailable !== false);
|
|
242
|
+
if (!booted) throw new DeviceNotFoundError('no booted simulator — use ios.launch() or boot one');
|
|
243
|
+
return finishHandle(booted.udid, booted.name, false, options, exec);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const rows = parseList(await runSimctl(exec, options.simulatorDeviceSet, ['list', 'devices', '-j']));
|
|
247
|
+
const row = rows.find((d) => d.udid.toLowerCase() === udid.toLowerCase());
|
|
248
|
+
if (!row) throw new DeviceNotFoundError(`no simulator with udid ${udid}`);
|
|
249
|
+
if (row.state !== 'Booted') await bootAndWait(exec, options.simulatorDeviceSet, row.udid, bootTimeoutMs);
|
|
250
|
+
return finishHandle(row.udid, row.name, false, options, exec);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The iOS engine object (Playwright-style): `ios.launch()` for a dedicated
|
|
255
|
+
* simulator, `ios.connect()` to reattach. Both return the same Device type.
|
|
256
|
+
*/
|
|
257
|
+
export const ios = {
|
|
258
|
+
/** Create + boot a dedicated simulator and return a Device pinned to it. */
|
|
259
|
+
launch,
|
|
260
|
+
/** Reattach to an existing simulator (no-arg: first booted sim). */
|
|
261
|
+
connect,
|
|
262
|
+
} as const;
|