@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/secrets.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// %variable% secret substitution (docs/19 §API shape, Stagehand's pattern):
|
|
3
|
+
// the model/caller plans against NAMES; values are injected at the last moment
|
|
4
|
+
// before backend.fill/typeText and never rendered into Actions, results,
|
|
5
|
+
// observations, or traces. Redaction is best-effort belt-and-braces — after
|
|
6
|
+
// typing into a non-secure field the next snapshot's value contains the
|
|
7
|
+
// secret, and redact() at observe/result assembly keeps it out of outbound
|
|
8
|
+
// text. The hard guarantee is at the substitution point: values never enter
|
|
9
|
+
// stored Actions by construction.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
const MIN_SECRET_LENGTH = 4;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `%variable%` secret substitution (docs/19 §API shape, Stagehand's pattern):
|
|
16
|
+
* the model/caller plans against NAMES; values are injected at the last moment
|
|
17
|
+
* before backend.fill/typeText and never rendered into Actions, results,
|
|
18
|
+
* observations, or traces. Redaction is best-effort belt-and-braces; the hard
|
|
19
|
+
* guarantee is at the substitution point — values never enter stored Actions
|
|
20
|
+
* by construction.
|
|
21
|
+
*/
|
|
22
|
+
export class SecretStore {
|
|
23
|
+
private readonly values = new Map<string, string>();
|
|
24
|
+
|
|
25
|
+
constructor(values?: Record<string, string>) {
|
|
26
|
+
if (values) for (const [k, v] of Object.entries(values)) this.set(k, v);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Store a secret under `name`. Rejects values shorter than 4 chars — a
|
|
31
|
+
* 2-char secret would redact innocent UI text everywhere.
|
|
32
|
+
*/
|
|
33
|
+
set(name: string, value: string): void {
|
|
34
|
+
if (value.length < MIN_SECRET_LENGTH) {
|
|
35
|
+
throw new Error(`secret "${name}" is too short (<${MIN_SECRET_LENGTH} chars) to redact safely`);
|
|
36
|
+
}
|
|
37
|
+
this.values.set(name, value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The stored secret NAMES (never the values). */
|
|
41
|
+
names(): string[] {
|
|
42
|
+
return [...this.values.keys()];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** %name% → value. Unknown %x% stays literal. */
|
|
46
|
+
substitute(text: string): string {
|
|
47
|
+
return text.replace(/%([A-Za-z0-9_-]+)%/g, (whole, name: string) => this.values.get(name) ?? whole);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** value → %name% across outbound text (messages, rendered observations). */
|
|
51
|
+
redact(text: string): string {
|
|
52
|
+
let out = text;
|
|
53
|
+
for (const [name, value] of this.values) {
|
|
54
|
+
out = out.split(value).join(`%${name}%`);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Per-call vars layered over the store (call-scoped, never persisted). */
|
|
60
|
+
withOverrides(vars?: Record<string, string>): SecretStore {
|
|
61
|
+
if (!vars || Object.keys(vars).length === 0) return this;
|
|
62
|
+
const merged = new SecretStore();
|
|
63
|
+
for (const [k, v] of this.values) merged.values.set(k, v);
|
|
64
|
+
for (const [k, v] of Object.entries(vars)) merged.set(k, v);
|
|
65
|
+
return merged;
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { BaseDeviceBackend } from './backend.ts';
|
|
2
|
+
import type {
|
|
3
|
+
AlertAction,
|
|
4
|
+
BackendAlertResult,
|
|
5
|
+
Capability,
|
|
6
|
+
OpenAppResult,
|
|
7
|
+
PressTarget,
|
|
8
|
+
Rect,
|
|
9
|
+
ScrollDirection,
|
|
10
|
+
Snapshot,
|
|
11
|
+
SnapshotNode,
|
|
12
|
+
} from './device.ts';
|
|
13
|
+
import { ALL_CAPABILITIES } from './device.ts';
|
|
14
|
+
|
|
15
|
+
/** One scripted screen in a {@link FakeBackend} sequence (alias of Snapshot). */
|
|
16
|
+
export type FakeScreen = Snapshot;
|
|
17
|
+
/** A recorded backend call: method name plus the arguments it received. */
|
|
18
|
+
export type FakeCall = { method: string; args: unknown[] };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The device-free test double the VPS test tier stands on (docs/20 item 2
|
|
22
|
+
* verification protocol). Scripts a sequence of snapshot "screens"; records
|
|
23
|
+
* every call; lets tests simulate screen transitions per action. Zero deps,
|
|
24
|
+
* Node-only. Public via the `@phone-use/sdk/testing` subpath so harness tests
|
|
25
|
+
* and third-party backend authors share it.
|
|
26
|
+
*/
|
|
27
|
+
export class FakeBackend extends BaseDeviceBackend {
|
|
28
|
+
/** Every backend call made, in order. */
|
|
29
|
+
readonly calls: FakeCall[] = [];
|
|
30
|
+
private screens: FakeScreen[];
|
|
31
|
+
private current = 0;
|
|
32
|
+
private readonly onAction: ((call: FakeCall, current: number) => number | undefined) | undefined;
|
|
33
|
+
private readonly failWith: ((call: FakeCall) => unknown) | undefined;
|
|
34
|
+
|
|
35
|
+
constructor(opts: {
|
|
36
|
+
screens: FakeScreen[];
|
|
37
|
+
name?: string;
|
|
38
|
+
capabilities?: Iterable<Capability>;
|
|
39
|
+
/** Return a new screen index to simulate a transition caused by the call. */
|
|
40
|
+
onAction?: (call: FakeCall, current: number) => number | undefined;
|
|
41
|
+
/** Throw for matching calls — return the error to throw, undefined to pass. */
|
|
42
|
+
failWith?: (call: FakeCall) => unknown;
|
|
43
|
+
}) {
|
|
44
|
+
super(opts.name ?? 'fake', opts.capabilities ?? ALL_CAPABILITIES);
|
|
45
|
+
if (!opts.screens.length) throw new Error('FakeBackend needs at least one screen');
|
|
46
|
+
this.screens = opts.screens;
|
|
47
|
+
this.onAction = opts.onAction;
|
|
48
|
+
this.failWith = opts.failWith;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Jump to screen `i` (throws when out of range). */
|
|
52
|
+
setScreen(i: number): void {
|
|
53
|
+
if (i < 0 || i >= this.screens.length) throw new Error(`no screen ${i}`);
|
|
54
|
+
this.current = i;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Index of the screen currently being served. */
|
|
58
|
+
get screenIndex(): number {
|
|
59
|
+
return this.current;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private record(method: string, ...args: unknown[]): FakeCall {
|
|
63
|
+
const call: FakeCall = { method, args };
|
|
64
|
+
this.calls.push(call);
|
|
65
|
+
const err = this.failWith?.(call);
|
|
66
|
+
if (err !== undefined) throw err;
|
|
67
|
+
const next = this.onAction?.(call, this.current);
|
|
68
|
+
if (next !== undefined) this.setScreen(next);
|
|
69
|
+
return call;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override snapshot(opts?: {
|
|
73
|
+
interactiveOnly?: boolean | undefined;
|
|
74
|
+
depth?: number | undefined;
|
|
75
|
+
}): Promise<Snapshot> {
|
|
76
|
+
this.record('snapshot', opts);
|
|
77
|
+
return Promise.resolve(this.screens[this.current]!);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
override screenshot(opts: { path: string; overlayRefs?: boolean | undefined }): Promise<{ path: string }> {
|
|
81
|
+
this.record('screenshot', opts);
|
|
82
|
+
return Promise.resolve({ path: opts.path }); // records; writes nothing
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
override press(target: PressTarget): Promise<void> {
|
|
86
|
+
this.record('press', target);
|
|
87
|
+
return Promise.resolve();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
override longPress(ref: string, durationMs?: number): Promise<void> {
|
|
91
|
+
this.record('longPress', ref, durationMs);
|
|
92
|
+
return Promise.resolve();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override fill(ref: string, text: string): Promise<void> {
|
|
96
|
+
this.record('fill', ref, text);
|
|
97
|
+
return Promise.resolve();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
override typeText(text: string): Promise<void> {
|
|
101
|
+
this.record('typeText', text);
|
|
102
|
+
return Promise.resolve();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
override pressKey(key: 'return'): Promise<void> {
|
|
106
|
+
this.record('pressKey', key);
|
|
107
|
+
return Promise.resolve();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
override scroll(direction: ScrollDirection): Promise<void> {
|
|
111
|
+
this.record('scroll', direction);
|
|
112
|
+
return Promise.resolve();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
override pan(x: number, y: number, dx: number, dy: number, durationMs?: number): Promise<void> {
|
|
116
|
+
this.record('pan', x, y, dx, dy, durationMs);
|
|
117
|
+
return Promise.resolve();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
override waitForText(text: string, timeoutMs?: number): Promise<void> {
|
|
121
|
+
this.record('waitForText', text, timeoutMs);
|
|
122
|
+
return Promise.resolve();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
override systemAlert(action: AlertAction): Promise<BackendAlertResult> {
|
|
126
|
+
this.record('systemAlert', action);
|
|
127
|
+
return Promise.resolve({ alert: null });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
override home(): Promise<void> {
|
|
131
|
+
this.record('home');
|
|
132
|
+
return Promise.resolve();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
override back(): Promise<void> {
|
|
136
|
+
this.record('back');
|
|
137
|
+
return Promise.resolve();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
override openApp(opts: {
|
|
141
|
+
app?: string | undefined;
|
|
142
|
+
url?: string | undefined;
|
|
143
|
+
relaunch?: boolean | undefined;
|
|
144
|
+
}): Promise<OpenAppResult> {
|
|
145
|
+
this.record('openApp', opts);
|
|
146
|
+
const s = this.screens[this.current]!;
|
|
147
|
+
return Promise.resolve({ appName: s.appName, appBundleId: s.appBundleId });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
override listApps(): Promise<string[]> {
|
|
151
|
+
this.record('listApps');
|
|
152
|
+
return Promise.resolve([]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
override closeSession(): Promise<void> {
|
|
156
|
+
this.record('closeSession');
|
|
157
|
+
return Promise.resolve();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const DEFAULT_RECT: Rect = { x: 0, y: 100, width: 390, height: 44 };
|
|
162
|
+
|
|
163
|
+
/** Fixture sugar: an element with sane defaults (Button role, on-screen rect). */
|
|
164
|
+
export function el(partial: Partial<SnapshotNode> & { ref: string }): SnapshotNode {
|
|
165
|
+
return { type: 'Button', label: '', rect: { ...DEFAULT_RECT }, ...partial };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Fixture sugar: a screen with an Application root so viewport detection works. */
|
|
169
|
+
export function screen(
|
|
170
|
+
nodes: SnapshotNode[],
|
|
171
|
+
app: { appName?: string | undefined; appBundleId?: string | undefined } = {},
|
|
172
|
+
): FakeScreen {
|
|
173
|
+
const root: SnapshotNode = { type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } };
|
|
174
|
+
return { nodes: [root, ...nodes], appName: app.appName, appBundleId: app.appBundleId };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type { ExecOptions, ExecResult, ExecRunner } from './exec.ts';
|
|
178
|
+
|
|
179
|
+
/** One step in a {@link scriptedExecRunner} script — what the next exec call gets. */
|
|
180
|
+
export type ScriptedExecStep = {
|
|
181
|
+
/** Optional argv guard — the step throws if the call doesn't match. */
|
|
182
|
+
expectArgs?: ((file: string, args: string[]) => boolean) | undefined;
|
|
183
|
+
stdout?: string | undefined;
|
|
184
|
+
stderr?: string | undefined;
|
|
185
|
+
/** Nonzero → the call rejects with an execFile-shaped error. */
|
|
186
|
+
code?: number | undefined;
|
|
187
|
+
/** Simulate a runner timeout kill. */
|
|
188
|
+
killed?: boolean | undefined;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** The runner {@link scriptedExecRunner} returns: an ExecRunner plus call inspection. */
|
|
192
|
+
export type ScriptedExecRunner = import('./exec.ts').ExecRunner & {
|
|
193
|
+
/** Every exec call made, in order. */
|
|
194
|
+
calls: { file: string; args: string[] }[];
|
|
195
|
+
/** Steps not yet consumed (assert 0 at test end for full-sequence checks). */
|
|
196
|
+
remaining(): number;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The DI'd process runner for lifecycle tests (docs/19: dependency-inject the
|
|
201
|
+
* spawn/exec runner). Takes an ordered script of steps; each call consumes the
|
|
202
|
+
* next step, optionally asserting the argv shape, and resolves/rejects in the
|
|
203
|
+
* execFile-error shape defaultExecRunner produces.
|
|
204
|
+
*/
|
|
205
|
+
export function scriptedExecRunner(steps: ScriptedExecStep[]): ScriptedExecRunner {
|
|
206
|
+
const queue = [...steps];
|
|
207
|
+
const calls: { file: string; args: string[] }[] = [];
|
|
208
|
+
const runner = (async (file: string, args: string[]) => {
|
|
209
|
+
calls.push({ file, args: [...args] });
|
|
210
|
+
const step = queue.shift();
|
|
211
|
+
if (!step) throw new Error(`scriptedExecRunner: unexpected call ${file} ${args.join(' ')}`);
|
|
212
|
+
if (step.expectArgs && !step.expectArgs(file, args)) {
|
|
213
|
+
throw new Error(`scriptedExecRunner: argv mismatch for ${file} ${args.join(' ')}`);
|
|
214
|
+
}
|
|
215
|
+
if (step.code !== undefined && step.code !== 0) {
|
|
216
|
+
const err = new Error(`Command failed: ${file} ${args.join(' ')}\n${step.stderr ?? ''}`) as Error & {
|
|
217
|
+
code: number;
|
|
218
|
+
stdout: string;
|
|
219
|
+
stderr: string;
|
|
220
|
+
killed: boolean;
|
|
221
|
+
};
|
|
222
|
+
err.code = step.code;
|
|
223
|
+
err.stdout = step.stdout ?? '';
|
|
224
|
+
err.stderr = step.stderr ?? '';
|
|
225
|
+
err.killed = step.killed ?? false;
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
if (step.killed) {
|
|
229
|
+
const err = new Error(`Command killed: ${file}`) as Error & { killed: boolean; signal: string };
|
|
230
|
+
err.killed = true;
|
|
231
|
+
err.signal = 'SIGTERM';
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
return { stdout: step.stdout ?? '', stderr: step.stderr ?? '' };
|
|
235
|
+
}) as ScriptedExecRunner;
|
|
236
|
+
runner.calls = calls;
|
|
237
|
+
runner.remaining = () => queue.length;
|
|
238
|
+
return runner;
|
|
239
|
+
}
|