@phone-use/sdk 0.1.0 → 0.2.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.
@@ -1,4 +1,4 @@
1
- import { A as Snapshot, C as AlertAction, D as PressTarget, E as OpenAppResult, T as Capability, j as SnapshotNode, k as ScrollDirection, n as BaseDeviceBackend, w as BackendAlertResult } from "./backend-Cbr2tIN-.mjs";
1
+ import { A as Snapshot, C as AlertAction, D as PressTarget, E as OpenAppResult, T as Capability, j as SnapshotNode, k as ScrollDirection, n as BaseDeviceBackend, w as BackendAlertResult } from "./backend-CkJkkw5Z.mjs";
2
2
  //#region src/exec.d.ts
3
3
  /** Per-call options an {@link ExecRunner} accepts. */
4
4
  type ExecOptions = {
@@ -13,7 +13,7 @@ type ExecResult = {
13
13
  stderr: string;
14
14
  };
15
15
  /**
16
- * The process-execution seam (docs/19: dependency-inject the spawn/exec runner
16
+ * The process-execution seam (dependency-inject the spawn/exec runner
17
17
  * so unit tests run without the real binaries). Runners are dumb: resolve on
18
18
  * exit 0, reject with the execFile error shape otherwise — error normalization
19
19
  * to PhoneUseError happens at the call site, once.
@@ -29,8 +29,8 @@ type FakeCall = {
29
29
  args: unknown[];
30
30
  };
31
31
  /**
32
- * The device-free test double the VPS test tier stands on (docs/20 item 2
33
- * verification protocol). Scripts a sequence of snapshot "screens"; records
32
+ * The device-free test double runs anywhere, no simulator needed.
33
+ * Scripts a sequence of snapshot "screens"; records
34
34
  * every call; lets tests simulate screen transitions per action. Zero deps,
35
35
  * Node-only. Public via the `@phone-use/sdk/testing` subpath so harness tests
36
36
  * and third-party backend authors share it.
@@ -116,8 +116,8 @@ type ScriptedExecRunner = ExecRunner & {
116
116
  remaining(): number;
117
117
  };
118
118
  /**
119
- * The DI'd process runner for lifecycle tests (docs/19: dependency-inject the
120
- * spawn/exec runner). Takes an ordered script of steps; each call consumes the
119
+ * The DI'd process runner for lifecycle tests (the injectable counterpart to
120
+ * defaultExecRunner). Takes an ordered script of steps; each call consumes the
121
121
  * next step, optionally asserting the argv shape, and resolves/rejects in the
122
122
  * execFile-error shape defaultExecRunner produces.
123
123
  */
package/dist/testing.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import { n as BaseDeviceBackend, t as ALL_CAPABILITIES } from "./device-BzPnHvQy.mjs";
1
+ import { n as BaseDeviceBackend, t as ALL_CAPABILITIES } from "./device-Xsy_LPUF.mjs";
2
2
  //#region src/testing.ts
3
3
  /**
4
- * The device-free test double the VPS test tier stands on (docs/20 item 2
5
- * verification protocol). Scripts a sequence of snapshot "screens"; records
4
+ * The device-free test double runs anywhere, no simulator needed.
5
+ * Scripts a sequence of snapshot "screens"; records
6
6
  * every call; lets tests simulate screen transitions per action. Zero deps,
7
7
  * Node-only. Public via the `@phone-use/sdk/testing` subpath so harness tests
8
8
  * and third-party backend authors share it.
@@ -143,8 +143,8 @@ function screen(nodes, app = {}) {
143
143
  };
144
144
  }
145
145
  /**
146
- * The DI'd process runner for lifecycle tests (docs/19: dependency-inject the
147
- * spawn/exec runner). Takes an ordered script of steps; each call consumes the
146
+ * The DI'd process runner for lifecycle tests (the injectable counterpart to
147
+ * defaultExecRunner). Takes an ordered script of steps; each call consumes the
148
148
  * next step, optionally asserting the argv shape, and resolves/rejects in the
149
149
  * execFile-error shape defaultExecRunner produces.
150
150
  */
@@ -1 +1 @@
1
- {"version":3,"file":"testing.mjs","names":[],"sources":["../src/testing.ts"],"sourcesContent":["import { BaseDeviceBackend } from './backend.ts';\nimport type {\n AlertAction,\n BackendAlertResult,\n Capability,\n OpenAppResult,\n PressTarget,\n Rect,\n ScrollDirection,\n Snapshot,\n SnapshotNode,\n} from './device.ts';\nimport { ALL_CAPABILITIES } from './device.ts';\n\n/** One scripted screen in a {@link FakeBackend} sequence (alias of Snapshot). */\nexport type FakeScreen = Snapshot;\n/** A recorded backend call: method name plus the arguments it received. */\nexport type FakeCall = { method: string; args: unknown[] };\n\n/**\n * The device-free test double the VPS test tier stands on (docs/20 item 2\n * verification protocol). Scripts a sequence of snapshot \"screens\"; records\n * every call; lets tests simulate screen transitions per action. Zero deps,\n * Node-only. Public via the `@phone-use/sdk/testing` subpath so harness tests\n * and third-party backend authors share it.\n */\nexport class FakeBackend extends BaseDeviceBackend {\n /** Every backend call made, in order. */\n readonly calls: FakeCall[] = [];\n private screens: FakeScreen[];\n private current = 0;\n private readonly onAction: ((call: FakeCall, current: number) => number | undefined) | undefined;\n private readonly failWith: ((call: FakeCall) => unknown) | undefined;\n\n constructor(opts: {\n screens: FakeScreen[];\n name?: string;\n capabilities?: Iterable<Capability>;\n /** Return a new screen index to simulate a transition caused by the call. */\n onAction?: (call: FakeCall, current: number) => number | undefined;\n /** Throw for matching calls — return the error to throw, undefined to pass. */\n failWith?: (call: FakeCall) => unknown;\n }) {\n super(opts.name ?? 'fake', opts.capabilities ?? ALL_CAPABILITIES);\n if (!opts.screens.length) throw new Error('FakeBackend needs at least one screen');\n this.screens = opts.screens;\n this.onAction = opts.onAction;\n this.failWith = opts.failWith;\n }\n\n /** Jump to screen `i` (throws when out of range). */\n setScreen(i: number): void {\n if (i < 0 || i >= this.screens.length) throw new Error(`no screen ${i}`);\n this.current = i;\n }\n\n /** Index of the screen currently being served. */\n get screenIndex(): number {\n return this.current;\n }\n\n private record(method: string, ...args: unknown[]): FakeCall {\n const call: FakeCall = { method, args };\n this.calls.push(call);\n const err = this.failWith?.(call);\n if (err !== undefined) throw err;\n const next = this.onAction?.(call, this.current);\n if (next !== undefined) this.setScreen(next);\n return call;\n }\n\n override snapshot(opts?: {\n interactiveOnly?: boolean | undefined;\n depth?: number | undefined;\n }): Promise<Snapshot> {\n this.record('snapshot', opts);\n return Promise.resolve(this.screens[this.current]!);\n }\n\n override screenshot(opts: { path: string; overlayRefs?: boolean | undefined }): Promise<{ path: string }> {\n this.record('screenshot', opts);\n return Promise.resolve({ path: opts.path }); // records; writes nothing\n }\n\n override press(target: PressTarget): Promise<void> {\n this.record('press', target);\n return Promise.resolve();\n }\n\n override longPress(ref: string, durationMs?: number): Promise<void> {\n this.record('longPress', ref, durationMs);\n return Promise.resolve();\n }\n\n override fill(ref: string, text: string): Promise<void> {\n this.record('fill', ref, text);\n return Promise.resolve();\n }\n\n override typeText(text: string): Promise<void> {\n this.record('typeText', text);\n return Promise.resolve();\n }\n\n override pressKey(key: 'return'): Promise<void> {\n this.record('pressKey', key);\n return Promise.resolve();\n }\n\n override scroll(direction: ScrollDirection): Promise<void> {\n this.record('scroll', direction);\n return Promise.resolve();\n }\n\n override pan(x: number, y: number, dx: number, dy: number, durationMs?: number): Promise<void> {\n this.record('pan', x, y, dx, dy, durationMs);\n return Promise.resolve();\n }\n\n override waitForText(text: string, timeoutMs?: number): Promise<void> {\n this.record('waitForText', text, timeoutMs);\n return Promise.resolve();\n }\n\n override systemAlert(action: AlertAction): Promise<BackendAlertResult> {\n this.record('systemAlert', action);\n return Promise.resolve({ alert: null });\n }\n\n override home(): Promise<void> {\n this.record('home');\n return Promise.resolve();\n }\n\n override back(): Promise<void> {\n this.record('back');\n return Promise.resolve();\n }\n\n override openApp(opts: {\n app?: string | undefined;\n url?: string | undefined;\n relaunch?: boolean | undefined;\n }): Promise<OpenAppResult> {\n this.record('openApp', opts);\n const s = this.screens[this.current]!;\n return Promise.resolve({ appName: s.appName, appBundleId: s.appBundleId });\n }\n\n override listApps(): Promise<string[]> {\n this.record('listApps');\n return Promise.resolve([]);\n }\n\n override closeSession(): Promise<void> {\n this.record('closeSession');\n return Promise.resolve();\n }\n}\n\nconst DEFAULT_RECT: Rect = { x: 0, y: 100, width: 390, height: 44 };\n\n/** Fixture sugar: an element with sane defaults (Button role, on-screen rect). */\nexport function el(partial: Partial<SnapshotNode> & { ref: string }): SnapshotNode {\n return { type: 'Button', label: '', rect: { ...DEFAULT_RECT }, ...partial };\n}\n\n/** Fixture sugar: a screen with an Application root so viewport detection works. */\nexport function screen(\n nodes: SnapshotNode[],\n app: { appName?: string | undefined; appBundleId?: string | undefined } = {},\n): FakeScreen {\n const root: SnapshotNode = { type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } };\n return { nodes: [root, ...nodes], appName: app.appName, appBundleId: app.appBundleId };\n}\n\nexport type { ExecOptions, ExecResult, ExecRunner } from './exec.ts';\n\n/** One step in a {@link scriptedExecRunner} script — what the next exec call gets. */\nexport type ScriptedExecStep = {\n /** Optional argv guard — the step throws if the call doesn't match. */\n expectArgs?: ((file: string, args: string[]) => boolean) | undefined;\n stdout?: string | undefined;\n stderr?: string | undefined;\n /** Nonzero → the call rejects with an execFile-shaped error. */\n code?: number | undefined;\n /** Simulate a runner timeout kill. */\n killed?: boolean | undefined;\n};\n\n/** The runner {@link scriptedExecRunner} returns: an ExecRunner plus call inspection. */\nexport type ScriptedExecRunner = import('./exec.ts').ExecRunner & {\n /** Every exec call made, in order. */\n calls: { file: string; args: string[] }[];\n /** Steps not yet consumed (assert 0 at test end for full-sequence checks). */\n remaining(): number;\n};\n\n/**\n * The DI'd process runner for lifecycle tests (docs/19: dependency-inject the\n * spawn/exec runner). Takes an ordered script of steps; each call consumes the\n * next step, optionally asserting the argv shape, and resolves/rejects in the\n * execFile-error shape defaultExecRunner produces.\n */\nexport function scriptedExecRunner(steps: ScriptedExecStep[]): ScriptedExecRunner {\n const queue = [...steps];\n const calls: { file: string; args: string[] }[] = [];\n const runner = (async (file: string, args: string[]) => {\n calls.push({ file, args: [...args] });\n const step = queue.shift();\n if (!step) throw new Error(`scriptedExecRunner: unexpected call ${file} ${args.join(' ')}`);\n if (step.expectArgs && !step.expectArgs(file, args)) {\n throw new Error(`scriptedExecRunner: argv mismatch for ${file} ${args.join(' ')}`);\n }\n if (step.code !== undefined && step.code !== 0) {\n const err = new Error(`Command failed: ${file} ${args.join(' ')}\\n${step.stderr ?? ''}`) as Error & {\n code: number;\n stdout: string;\n stderr: string;\n killed: boolean;\n };\n err.code = step.code;\n err.stdout = step.stdout ?? '';\n err.stderr = step.stderr ?? '';\n err.killed = step.killed ?? false;\n throw err;\n }\n if (step.killed) {\n const err = new Error(`Command killed: ${file}`) as Error & { killed: boolean; signal: string };\n err.killed = true;\n err.signal = 'SIGTERM';\n throw err;\n }\n return { stdout: step.stdout ?? '', stderr: step.stderr ?? '' };\n }) as ScriptedExecRunner;\n runner.calls = calls;\n runner.remaining = () => queue.length;\n return runner;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"testing.mjs","names":[],"sources":["../src/testing.ts"],"sourcesContent":["import { BaseDeviceBackend } from './backend.ts';\nimport type {\n AlertAction,\n BackendAlertResult,\n Capability,\n OpenAppResult,\n PressTarget,\n Rect,\n ScrollDirection,\n Snapshot,\n SnapshotNode,\n} from './device.ts';\nimport { ALL_CAPABILITIES } from './device.ts';\n\n/** One scripted screen in a {@link FakeBackend} sequence (alias of Snapshot). */\nexport type FakeScreen = Snapshot;\n/** A recorded backend call: method name plus the arguments it received. */\nexport type FakeCall = { method: string; args: unknown[] };\n\n/**\n * The device-free test double — runs anywhere, no simulator needed.\n * Scripts a sequence of snapshot \"screens\"; records\n * every call; lets tests simulate screen transitions per action. Zero deps,\n * Node-only. Public via the `@phone-use/sdk/testing` subpath so harness tests\n * and third-party backend authors share it.\n */\nexport class FakeBackend extends BaseDeviceBackend {\n /** Every backend call made, in order. */\n readonly calls: FakeCall[] = [];\n private screens: FakeScreen[];\n private current = 0;\n private readonly onAction: ((call: FakeCall, current: number) => number | undefined) | undefined;\n private readonly failWith: ((call: FakeCall) => unknown) | undefined;\n\n constructor(opts: {\n screens: FakeScreen[];\n name?: string;\n capabilities?: Iterable<Capability>;\n /** Return a new screen index to simulate a transition caused by the call. */\n onAction?: (call: FakeCall, current: number) => number | undefined;\n /** Throw for matching calls — return the error to throw, undefined to pass. */\n failWith?: (call: FakeCall) => unknown;\n }) {\n super(opts.name ?? 'fake', opts.capabilities ?? ALL_CAPABILITIES);\n if (!opts.screens.length) throw new Error('FakeBackend needs at least one screen');\n this.screens = opts.screens;\n this.onAction = opts.onAction;\n this.failWith = opts.failWith;\n }\n\n /** Jump to screen `i` (throws when out of range). */\n setScreen(i: number): void {\n if (i < 0 || i >= this.screens.length) throw new Error(`no screen ${i}`);\n this.current = i;\n }\n\n /** Index of the screen currently being served. */\n get screenIndex(): number {\n return this.current;\n }\n\n private record(method: string, ...args: unknown[]): FakeCall {\n const call: FakeCall = { method, args };\n this.calls.push(call);\n const err = this.failWith?.(call);\n if (err !== undefined) throw err;\n const next = this.onAction?.(call, this.current);\n if (next !== undefined) this.setScreen(next);\n return call;\n }\n\n override snapshot(opts?: {\n interactiveOnly?: boolean | undefined;\n depth?: number | undefined;\n }): Promise<Snapshot> {\n this.record('snapshot', opts);\n return Promise.resolve(this.screens[this.current]!);\n }\n\n override screenshot(opts: { path: string; overlayRefs?: boolean | undefined }): Promise<{ path: string }> {\n this.record('screenshot', opts);\n return Promise.resolve({ path: opts.path }); // records; writes nothing\n }\n\n override press(target: PressTarget): Promise<void> {\n this.record('press', target);\n return Promise.resolve();\n }\n\n override longPress(ref: string, durationMs?: number): Promise<void> {\n this.record('longPress', ref, durationMs);\n return Promise.resolve();\n }\n\n override fill(ref: string, text: string): Promise<void> {\n this.record('fill', ref, text);\n return Promise.resolve();\n }\n\n override typeText(text: string): Promise<void> {\n this.record('typeText', text);\n return Promise.resolve();\n }\n\n override pressKey(key: 'return'): Promise<void> {\n this.record('pressKey', key);\n return Promise.resolve();\n }\n\n override scroll(direction: ScrollDirection): Promise<void> {\n this.record('scroll', direction);\n return Promise.resolve();\n }\n\n override pan(x: number, y: number, dx: number, dy: number, durationMs?: number): Promise<void> {\n this.record('pan', x, y, dx, dy, durationMs);\n return Promise.resolve();\n }\n\n override waitForText(text: string, timeoutMs?: number): Promise<void> {\n this.record('waitForText', text, timeoutMs);\n return Promise.resolve();\n }\n\n override systemAlert(action: AlertAction): Promise<BackendAlertResult> {\n this.record('systemAlert', action);\n return Promise.resolve({ alert: null });\n }\n\n override home(): Promise<void> {\n this.record('home');\n return Promise.resolve();\n }\n\n override back(): Promise<void> {\n this.record('back');\n return Promise.resolve();\n }\n\n override openApp(opts: {\n app?: string | undefined;\n url?: string | undefined;\n relaunch?: boolean | undefined;\n }): Promise<OpenAppResult> {\n this.record('openApp', opts);\n const s = this.screens[this.current]!;\n return Promise.resolve({ appName: s.appName, appBundleId: s.appBundleId });\n }\n\n override listApps(): Promise<string[]> {\n this.record('listApps');\n return Promise.resolve([]);\n }\n\n override closeSession(): Promise<void> {\n this.record('closeSession');\n return Promise.resolve();\n }\n}\n\nconst DEFAULT_RECT: Rect = { x: 0, y: 100, width: 390, height: 44 };\n\n/** Fixture sugar: an element with sane defaults (Button role, on-screen rect). */\nexport function el(partial: Partial<SnapshotNode> & { ref: string }): SnapshotNode {\n return { type: 'Button', label: '', rect: { ...DEFAULT_RECT }, ...partial };\n}\n\n/** Fixture sugar: a screen with an Application root so viewport detection works. */\nexport function screen(\n nodes: SnapshotNode[],\n app: { appName?: string | undefined; appBundleId?: string | undefined } = {},\n): FakeScreen {\n const root: SnapshotNode = { type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } };\n return { nodes: [root, ...nodes], appName: app.appName, appBundleId: app.appBundleId };\n}\n\nexport type { ExecOptions, ExecResult, ExecRunner } from './exec.ts';\n\n/** One step in a {@link scriptedExecRunner} script — what the next exec call gets. */\nexport type ScriptedExecStep = {\n /** Optional argv guard — the step throws if the call doesn't match. */\n expectArgs?: ((file: string, args: string[]) => boolean) | undefined;\n stdout?: string | undefined;\n stderr?: string | undefined;\n /** Nonzero → the call rejects with an execFile-shaped error. */\n code?: number | undefined;\n /** Simulate a runner timeout kill. */\n killed?: boolean | undefined;\n};\n\n/** The runner {@link scriptedExecRunner} returns: an ExecRunner plus call inspection. */\nexport type ScriptedExecRunner = import('./exec.ts').ExecRunner & {\n /** Every exec call made, in order. */\n calls: { file: string; args: string[] }[];\n /** Steps not yet consumed (assert 0 at test end for full-sequence checks). */\n remaining(): number;\n};\n\n/**\n * The DI'd process runner for lifecycle tests (the injectable counterpart to\n * defaultExecRunner). Takes an ordered script of steps; each call consumes the\n * next step, optionally asserting the argv shape, and resolves/rejects in the\n * execFile-error shape defaultExecRunner produces.\n */\nexport function scriptedExecRunner(steps: ScriptedExecStep[]): ScriptedExecRunner {\n const queue = [...steps];\n const calls: { file: string; args: string[] }[] = [];\n const runner = (async (file: string, args: string[]) => {\n calls.push({ file, args: [...args] });\n const step = queue.shift();\n if (!step) throw new Error(`scriptedExecRunner: unexpected call ${file} ${args.join(' ')}`);\n if (step.expectArgs && !step.expectArgs(file, args)) {\n throw new Error(`scriptedExecRunner: argv mismatch for ${file} ${args.join(' ')}`);\n }\n if (step.code !== undefined && step.code !== 0) {\n const err = new Error(`Command failed: ${file} ${args.join(' ')}\\n${step.stderr ?? ''}`) as Error & {\n code: number;\n stdout: string;\n stderr: string;\n killed: boolean;\n };\n err.code = step.code;\n err.stdout = step.stdout ?? '';\n err.stderr = step.stderr ?? '';\n err.killed = step.killed ?? false;\n throw err;\n }\n if (step.killed) {\n const err = new Error(`Command killed: ${file}`) as Error & { killed: boolean; signal: string };\n err.killed = true;\n err.signal = 'SIGTERM';\n throw err;\n }\n return { stdout: step.stdout ?? '', stderr: step.stderr ?? '' };\n }) as ScriptedExecRunner;\n runner.calls = calls;\n runner.remaining = () => queue.length;\n return runner;\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,15 +1,29 @@
1
1
  {
2
2
  "name": "@phone-use/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Typed mobile-device SDK — Device, backends, errors, actions",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "sideEffects": false,
8
+ "homepage": "https://www.phoneuse.dev",
9
+ "bugs": {
10
+ "url": "https://github.com/Rajmeet/phone-use/issues"
11
+ },
8
12
  "repository": {
9
13
  "type": "git",
10
14
  "url": "git+https://github.com/Rajmeet/phone-use.git",
11
15
  "directory": "packages/sdk"
12
16
  },
17
+ "keywords": [
18
+ "ios",
19
+ "simulator",
20
+ "mobile",
21
+ "device",
22
+ "automation",
23
+ "agent",
24
+ "ai",
25
+ "testing"
26
+ ],
13
27
  "publishConfig": {
14
28
  "access": "public",
15
29
  "registry": "https://registry.npmjs.org"
package/src/actions.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  import { SecretStore } from './secrets.ts';
18
18
 
19
19
  // ---------------------------------------------------------------------------
20
- // The observe→act seam (docs/19 §API shape; docs/20 item 4): observe() returns
20
+ // The observe→act seam: observe() returns
21
21
  // portable Action descriptors carrying RE-RESOLVABLE element queries; act()
22
22
  // re-resolves against the live tree and executes with no re-inference. A
23
23
  // compiled skill is a stored Action[]. The resolution ladder (DeviceCore) is
@@ -63,13 +63,13 @@ export type ActionVerb =
63
63
  | 'waitForText';
64
64
 
65
65
  /**
66
- * The observe→act seam (docs/19 §API shape; docs/20 item 4): a portable action
66
+ * The observe→act seam: a portable action
67
67
  * descriptor carrying a re-resolvable {@link ElementQuery}. `observe()` returns
68
68
  * these; `act()` re-resolves against the live tree and executes with no
69
69
  * re-inference. A compiled skill is a stored `Action[]`.
70
70
  */
71
71
  export type Action = {
72
- /** Versioned, documented UNSTABLE pre-1.0 (docs/20 open-question 3). */
72
+ /** Versioned, documented UNSTABLE pre-1.0. */
73
73
  formatVersion: 0;
74
74
  /** What to do. */
75
75
  verb: ActionVerb;
@@ -106,7 +106,7 @@ export type Action = {
106
106
  | undefined;
107
107
  };
108
108
 
109
- /** The stored-Action[] artifact (public TYPE, unstable FORMAT — see docs/20). */
109
+ /** The stored-Action[] artifact (public TYPE, unstable FORMAT pre-1.0). */
110
110
  export type CompiledSkill = {
111
111
  formatVersion: 0;
112
112
  name: string;
@@ -311,8 +311,8 @@ type WaitOutcome =
311
311
  | { ok: false; result: ActionResult };
312
312
 
313
313
  /**
314
- * Resolve + auto-wait (docs/19: visible+hittable+enabled+settled on every
315
- * action, no caller sleep; docs/20 risk 2: must not double latency).
314
+ * Resolve + auto-wait (visible+hittable+enabled+settled on every
315
+ * action, no caller sleep and it must not double latency).
316
316
  * Fast path: a fresh cache resolving to an actionable target executes with
317
317
  * ZERO extra snapshots. Slow path: poll in place (never scroll — scrolling is
318
318
  * the ladder's job, and polling must not dismiss transient menus) until the
package/src/backend.ts CHANGED
@@ -11,7 +11,7 @@ import type {
11
11
  import { PhoneUseError, UnsupportedCapabilityError } from './errors.ts';
12
12
 
13
13
  /**
14
- * The backend contract (docs/19 §Backend contract): a STRUCTURAL interface —
14
+ * The backend contract: a STRUCTURAL interface —
15
15
  * any object with these methods is a backend — plus an optional
16
16
  * {@link BaseDeviceBackend} class with the plumbing done (Appium BaseDriver
17
17
  * ergonomics). Methods reject only with PhoneUseError subclasses; backends
@@ -139,10 +139,9 @@ export abstract class BaseDeviceBackend implements DeviceBackend {
139
139
  }
140
140
 
141
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.
142
+ // Runtime backend registry (registerBackend + the phone-backend-* naming
143
+ // convention). A deliberate module-level Map: this IS the registry — mirrored
144
+ // on permissions.ts's documented pattern.
146
145
  // ---------------------------------------------------------------------------
147
146
 
148
147
  /** Builds a backend from an optional {@link DeviceConfig}. */
@@ -19,9 +19,8 @@ import { toPhoneUseError } from '../errors.ts';
19
19
  // pinning is per-request in agent-device (AgentDeviceSelectionOptions), so a
20
20
  // backend holds a selection object and spreads it into every call. With no
21
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).
22
+ // byte-identical to the pre-seam process-global path (booted-sim auto-detect)
23
+ // the backward-compat guarantee against default-selection drift.
25
24
  //
26
25
  // Every method normalizes errors via toPhoneUseError — no agent-device type
27
26
  // or error ever escapes this file.
@@ -0,0 +1,205 @@
1
+ import { BaseDeviceBackend } from '../backend.ts';
2
+ import type {
3
+ AlertAction,
4
+ BackendAlertResult,
5
+ Capability,
6
+ OpenAppResult,
7
+ PressTarget,
8
+ ScrollDirection,
9
+ Snapshot,
10
+ } from '../device.ts';
11
+ import { ActionFailedError, DeviceNotFoundError, TimeoutError } from '../errors.ts';
12
+
13
+ /**
14
+ * A device-runner is addressed purely by URL, so it takes no DeviceConfig
15
+ * platform/udid selection — only where to reach the runner and how to auth.
16
+ */
17
+ export type DeviceRunnerConfig = {
18
+ endpoint?: string | undefined;
19
+ token?: string | undefined;
20
+ timeoutMs?: number | undefined;
21
+ };
22
+
23
+ const RUNNER_CAPABILITIES: readonly Capability[] = [
24
+ 'snapshot',
25
+ 'screenshot',
26
+ 'press',
27
+ 'fill',
28
+ 'type',
29
+ 'scroll',
30
+ 'pan',
31
+ 'openApp',
32
+ 'home',
33
+ ];
34
+
35
+ /**
36
+ * Backend that speaks to an on-device runner: an XCTest-hosted JSON-RPC server
37
+ * running ON the iPhone itself, which holds the automation privileges iOS
38
+ * denies to ordinary apps.
39
+ *
40
+ * The endpoint is just a URL, so the same backend serves every topology:
41
+ * - `http://127.0.0.1:45678` — port-forwarded from a paired host
42
+ * - `http://<phone-ip>:45678` — straight over the LAN / tailnet
43
+ * - `https://relay.example/d/<id>` — the runner dials out to a cloud relay,
44
+ * which is what lets an agent anywhere drive the phone with no inbound
45
+ * ports and no Mac in the loop.
46
+ *
47
+ * The wire format matches the shape proven by rounak/PhoneAgent: newline-free
48
+ * JSON request/response over HTTP POST, one method per call.
49
+ */
50
+ export class DeviceRunnerBackend extends BaseDeviceBackend {
51
+ readonly #endpoint: string;
52
+ readonly #token: string | undefined;
53
+ readonly #timeoutMs: number;
54
+
55
+ constructor(config?: DeviceRunnerConfig) {
56
+ // BaseDeviceBackend owns backendName/capabilities — set them via super()
57
+ // rather than redeclaring the fields.
58
+ super('device-runner', RUNNER_CAPABILITIES);
59
+ const endpoint = config?.endpoint ?? process.env.PHONE_USE_RUNNER_URL ?? 'http://127.0.0.1:45678';
60
+ this.#endpoint = endpoint.replace(/\/+$/, '');
61
+ this.#token = config?.token ?? process.env.PHONE_USE_RUNNER_TOKEN;
62
+ this.#timeoutMs = config?.timeoutMs ?? 30_000;
63
+ }
64
+
65
+ async #rpc<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
68
+ let res: Response;
69
+ try {
70
+ res = await fetch(this.#endpoint, {
71
+ method: 'POST',
72
+ headers: {
73
+ 'content-type': 'application/json',
74
+ ...(this.#token ? { authorization: `Bearer ${this.#token}` } : {}),
75
+ },
76
+ body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }),
77
+ signal: controller.signal,
78
+ });
79
+ } catch (cause) {
80
+ if (controller.signal.aborted) {
81
+ throw new TimeoutError(`runner did not answer ${method} within ${this.#timeoutMs}ms`);
82
+ }
83
+ throw new DeviceNotFoundError(
84
+ `cannot reach the on-device runner at ${this.#endpoint} — is it activated on the phone?`,
85
+ { cause },
86
+ );
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+
91
+ if (!res.ok) {
92
+ throw new ActionFailedError(`runner returned HTTP ${res.status} for ${method}`);
93
+ }
94
+ const body = (await res.json()) as { result?: T; error?: { message?: string } };
95
+ if (body.error) {
96
+ throw new ActionFailedError(body.error.message ?? `runner rejected ${method}`);
97
+ }
98
+ return body.result as T;
99
+ }
100
+
101
+ override async snapshot(opts?: {
102
+ interactiveOnly?: boolean | undefined;
103
+ depth?: number | undefined;
104
+ }): Promise<Snapshot> {
105
+ // The runner speaks its own compact wire shape; map it onto the SDK's
106
+ // Snapshot contract so every consumer (CLI, MCP, agent, bench) is unaware
107
+ // it is talking to a phone rather than a simulator.
108
+ const wire = await this.#rpc<{
109
+ app?: string;
110
+ elements: Array<{
111
+ ref: string;
112
+ role?: string;
113
+ label?: string;
114
+ value?: string;
115
+ enabled?: boolean;
116
+ rect?: { x: number; y: number; w: number; h: number };
117
+ }>;
118
+ }>('get_tree', {
119
+ interactiveOnly: opts?.interactiveOnly ?? false,
120
+ depth: opts?.depth,
121
+ });
122
+ return {
123
+ appBundleId: wire.app,
124
+ appName: wire.app,
125
+ nodes: (wire.elements ?? []).map((e) => ({
126
+ ref: e.ref,
127
+ role: e.role,
128
+ type: e.role,
129
+ label: e.label,
130
+ value: e.value,
131
+ enabled: e.enabled,
132
+ rect: e.rect ? { x: e.rect.x, y: e.rect.y, width: e.rect.w, height: e.rect.h } : undefined,
133
+ })),
134
+ };
135
+ }
136
+
137
+ override async screenshot(opts: {
138
+ path: string;
139
+ overlayRefs?: boolean | undefined;
140
+ }): Promise<{ path: string }> {
141
+ const { base64 } = await this.#rpc<{ base64: string }>('get_screen_image', {
142
+ overlayRefs: opts.overlayRefs ?? false,
143
+ });
144
+ const { writeFile } = await import('node:fs/promises');
145
+ await writeFile(opts.path, Buffer.from(base64, 'base64'));
146
+ return { path: opts.path };
147
+ }
148
+
149
+ override async press(target: PressTarget): Promise<void> {
150
+ // PressTarget is {ref} | {x,y} — never a bare string, so narrow on the key.
151
+ if ('ref' in target) {
152
+ await this.#rpc('tap_element', { ref: target.ref });
153
+ return;
154
+ }
155
+ await this.#rpc('tap', { x: target.x, y: target.y });
156
+ }
157
+
158
+ override async fill(ref: string, text: string): Promise<void> {
159
+ await this.#rpc('enter_text', { ref, text, replace: true });
160
+ }
161
+
162
+ override async typeText(text: string): Promise<void> {
163
+ await this.#rpc('enter_text', { text, replace: false });
164
+ }
165
+
166
+ override async scroll(direction: ScrollDirection): Promise<void> {
167
+ await this.#rpc('scroll', { direction });
168
+ }
169
+
170
+ override async pan(x: number, y: number, dx: number, dy: number, durationMs = 300): Promise<void> {
171
+ await this.#rpc('swipe', { x, y, dx, dy, durationMs });
172
+ }
173
+
174
+ override async home(): Promise<void> {
175
+ await this.#rpc('home');
176
+ }
177
+
178
+ override async openApp(opts: {
179
+ app?: string | undefined;
180
+ url?: string | undefined;
181
+ relaunch?: boolean | undefined;
182
+ }): Promise<OpenAppResult> {
183
+ return this.#rpc<OpenAppResult>('open_app', {
184
+ app: opts.app,
185
+ url: opts.url,
186
+ relaunch: opts.relaunch ?? false,
187
+ });
188
+ }
189
+
190
+ override async systemAlert(action: AlertAction): Promise<BackendAlertResult> {
191
+ return this.#rpc<BackendAlertResult>('alert', { action });
192
+ }
193
+
194
+ override async closeSession(): Promise<void> {
195
+ // The runner outlives any single client; nothing to tear down.
196
+ }
197
+
198
+ /** Liveness probe used by `phone-use doctor` and the relay health check. */
199
+ async ping(): Promise<{ ok: boolean; ios?: string; device?: string }> {
200
+ return this.#rpc('get_context');
201
+ }
202
+ }
203
+
204
+ export const createDeviceRunnerBackend = (config?: DeviceRunnerConfig): DeviceRunnerBackend =>
205
+ new DeviceRunnerBackend(config);
@@ -5,12 +5,12 @@ import { createDeviceHandle, type Device } from '../lifecycle.ts';
5
5
  import { createAgentDeviceBackend } from './agent-device.ts';
6
6
 
7
7
  // ---------------------------------------------------------------------------
8
- // The iOS engine (docs/19 §Lifecycle, engine-as-object): ios.launch() creates
8
+ // The iOS engine (engine-as-object): ios.launch() creates
9
9
  // and boots a DEDICATED simulator via simctl — no more "whatever is booted" —
10
10
  // and returns a Device whose backend is pinned to that udid. ios.connect()
11
11
  // reattaches; its no-arg form is the sole survivor of the old booted-sim
12
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).
13
+ // devices is reserved API — local-only for now.
14
14
  //
15
15
  // Created sims are named `phone-use-<hex>` deliberately: if a process is
16
16
  // kill -9'd, the in-process reaper can't run, and the name prefix is how
@@ -40,7 +40,7 @@ type CommonIosOptions = {
40
40
  * open lazily and a probe requires the daemon to exist.
41
41
  */
42
42
  failFast?: boolean | undefined;
43
- /** @internal test seam — DI'd process runner (docs/19 test strategy). */
43
+ /** @internal test seam — DI'd process runner. */
44
44
  exec?: ExecRunner | undefined;
45
45
  };
46
46
 
@@ -73,7 +73,7 @@ function simctlArgs(setPath: string | undefined, args: string[]): string[] {
73
73
 
74
74
  // Exit 149 = "operation not allowed in current state" (already booted /
75
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).
76
+ // messages; the numeric code is the stable signal.
77
77
  function isAlreadyInState(err: unknown): boolean {
78
78
  if (!isExecError(err)) return false;
79
79
  if (err.code === 149) return true;
package/src/config.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Device configuration: a discriminated union, never a stringly-typed
3
- // capability blob (docs/19 §Backend contract, learning from Appium's leaks).
3
+ // capability blob (learning from Appium's leaks).
4
4
  // Field names verified against agent-device 0.19.3's AgentDeviceSelectionOptions
5
- // and AgentDeviceClientConfig. Plain types — no zod in the SDK public API
6
- // (docs/20 risk 6).
5
+ // and AgentDeviceClientConfig. Plain types — no zod in the SDK public API.
7
6
  // ---------------------------------------------------------------------------
8
7
 
9
8
  /** Fields shared by every platform's device config (session/daemon pinning). */
@@ -37,7 +36,7 @@ export type AndroidDeviceConfig = CommonDeviceConfig & {
37
36
 
38
37
  /**
39
38
  * Device configuration: a discriminated union on `platform`, never a
40
- * stringly-typed capability blob (docs/19 §Backend contract, learning from
41
- * Appium's leaks). Plain types — no zod in the SDK public API.
39
+ * stringly-typed capability blob (learning from Appium's leaks).
40
+ * Plain types — no zod in the SDK public API.
42
41
  */
43
42
  export type DeviceConfig = IosDeviceConfig | AndroidDeviceConfig;
package/src/device.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Shared device types — the SDK's own vocabulary, structurally compatible with
3
- // agent-device's snapshot nodes but never importing its types (docs/20 item 2:
4
- // no backend type in the public API). Every optional is `| undefined` so
3
+ // agent-device's snapshot nodes but never importing its types no backend
4
+ // type in the public API. Every optional is `| undefined` so
5
5
  // narrower backend types assign structurally under exactOptionalPropertyTypes.
6
6
  // ---------------------------------------------------------------------------
7
7
 
@@ -11,7 +11,7 @@ export type Rect = { x: number; y: number; width: number; height: number };
11
11
  /**
12
12
  * One accessibility-tree node as reported by a backend snapshot. The SDK's own
13
13
  * vocabulary — structurally compatible with agent-device's nodes but never
14
- * importing its types (docs/20 item 2: no backend type in the public API).
14
+ * importing its types (no backend type in the public API).
15
15
  */
16
16
  export type SnapshotNode = {
17
17
  ref?: string | undefined;
package/src/errors.ts CHANGED
@@ -28,7 +28,7 @@ export type PhoneUseErrorDetails = Record<string, unknown> & {
28
28
  };
29
29
 
30
30
  /**
31
- * Root of the typed error tree (docs/19 §Artifacts, errors; docs/20 item 2).
31
+ * Root of the typed error tree.
32
32
  * Every backend method rejects only with PhoneUseError subclasses; agent-device
33
33
  * (or any transport) errors are normalized at the boundary by
34
34
  * {@link toPhoneUseError} so no backend type ever reaches the public API. Each
@@ -143,8 +143,8 @@ export function toPhoneUseError(
143
143
  const message = e.message || String(e.code ?? 'agent-device error');
144
144
  const details: PhoneUseErrorDetails = { ...(e.details ?? {}) };
145
145
  const opts = { details, cause: err };
146
- // Timeout is a shape, not a code, in agent-device — heuristic on message.
147
- // Flagged in docs/20: Mac verification is the arbiter for this mapping.
146
+ // Timeout is a shape, not a code, in agent-device — heuristic on message,
147
+ // verified against real agent-device errors on macOS.
148
148
  if (TIMEOUT_RE.test(message)) return new TimeoutError(message, opts);
149
149
  switch (e.code) {
150
150
  case 'DEVICE_NOT_FOUND':
package/src/exec.ts CHANGED
@@ -13,7 +13,7 @@ export type ExecOptions = {
13
13
  export type ExecResult = { stdout: string; stderr: string };
14
14
 
15
15
  /**
16
- * The process-execution seam (docs/19: dependency-inject the spawn/exec runner
16
+ * The process-execution seam (dependency-inject the spawn/exec runner
17
17
  * so unit tests run without the real binaries). Runners are dumb: resolve on
18
18
  * exit 0, reject with the execFile error shape otherwise — error normalization
19
19
  * to PhoneUseError happens at the call site, once.
package/src/index.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * @phone-use/sdk — the device runtime SDK: engine-as-object lifecycle
3
- * (ios.launch/connect → Device), Device backends, config, errors, capabilities
4
- * (docs/20-runtime-sdk-v1-plan.md items 2-3; action verbs land in item 4).
3
+ * (ios.launch/connect → Device), Device backends, config, errors, capabilities,
4
+ * and the action verb surface.
5
5
  *
6
6
  * The test double (FakeBackend) lives on the "@phone-use/sdk/testing" subpath,
7
7
  * deliberately not re-exported here.
8
8
  */
9
9
  /** The published package version (kept in sync with package.json by the release flow). */
10
- export const VERSION = '0.1.0';
10
+ export const VERSION = '0.2.0';
11
11
 
12
12
  export {
13
13
  type Action,
@@ -31,6 +31,7 @@ export {
31
31
  registerBackend,
32
32
  } from './backend.ts';
33
33
  export { createAgentDeviceBackend } from './backends/agent-device.ts';
34
+ export { createDeviceRunnerBackend, DeviceRunnerBackend } from './backends/device-runner.ts';
34
35
  export { type IosConnectOptions, type IosLaunchOptions, ios } from './backends/ios.ts';
35
36
  export type { AndroidDeviceConfig, CommonDeviceConfig, DeviceConfig, IosDeviceConfig } from './config.ts';
36
37
  export type {
package/src/lifecycle.ts CHANGED
@@ -19,10 +19,10 @@ export type DevicePlatform = 'ios' | 'android';
19
19
  export type DeviceStatus = 'running' | 'closed';
20
20
 
21
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`.
22
+ * The Device lifecycle handle: id, pinned backend, close/dispose, idle lease +
23
+ * reaper — plus the action verb surface layered onto the same type.
24
+ * `ios.launch()` and `ios.connect()` return it; the future android engine will
25
+ * share `createDeviceHandle`.
26
26
  */
27
27
  export interface Device {
28
28
  /** udid (iOS) / serial (Android). */
@@ -53,7 +53,7 @@ export interface Device {
53
53
  /** `await using` support — delegates to {@link Device.close}. */
54
54
  [Symbol.asyncDispose](): Promise<void>;
55
55
 
56
- // --- the action surface (docs/20 item 4): flat hot path -------------------
56
+ // --- the action surface: flat hot path ------------------------------------
57
57
  /** Look at the screen: elements + rendered text + portable Action[]. */
58
58
  observe(opts?: { signal?: AbortSignal | undefined }): Promise<ObserveResult>;
59
59
  /** Tap by label/id query. Auto-waits; never throws for normal outcomes. */
package/src/observe.ts CHANGED
@@ -3,7 +3,7 @@ import type { Rect, SnapshotNode } from './device.ts';
3
3
  import { SessionNotFoundError } from './errors.ts';
4
4
 
5
5
  // ---------------------------------------------------------------------------
6
- // DeviceCore (docs/20 item 4): the one-brain observe/resolve/act core, moved
6
+ // DeviceCore: the one-brain observe/resolve/act core, moved
7
7
  // verbatim from the harness's DeviceContext. Everything here is portable —
8
8
  // no runtime-specific globals and no image libraries — so it runs under Node. The
9
9
  // module level holds only types, pure functions, and read-only lookup tables;
package/src/secrets.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // ---------------------------------------------------------------------------
2
- // %variable% secret substitution (docs/19 §API shape, Stagehand's pattern):
2
+ // %variable% secret substitution (Stagehand's pattern):
3
3
  // the model/caller plans against NAMES; values are injected at the last moment
4
4
  // before backend.fill/typeText and never rendered into Actions, results,
5
5
  // observations, or traces. Redaction is best-effort belt-and-braces — after
@@ -12,7 +12,7 @@
12
12
  const MIN_SECRET_LENGTH = 4;
13
13
 
14
14
  /**
15
- * `%variable%` secret substitution (docs/19 §API shape, Stagehand's pattern):
15
+ * `%variable%` secret substitution (Stagehand's pattern):
16
16
  * the model/caller plans against NAMES; values are injected at the last moment
17
17
  * before backend.fill/typeText and never rendered into Actions, results,
18
18
  * observations, or traces. Redaction is best-effort belt-and-braces; the hard
package/src/testing.ts CHANGED
@@ -18,8 +18,8 @@ export type FakeScreen = Snapshot;
18
18
  export type FakeCall = { method: string; args: unknown[] };
19
19
 
20
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
21
+ * The device-free test double runs anywhere, no simulator needed.
22
+ * Scripts a sequence of snapshot "screens"; records
23
23
  * every call; lets tests simulate screen transitions per action. Zero deps,
24
24
  * Node-only. Public via the `@phone-use/sdk/testing` subpath so harness tests
25
25
  * and third-party backend authors share it.
@@ -197,8 +197,8 @@ export type ScriptedExecRunner = import('./exec.ts').ExecRunner & {
197
197
  };
198
198
 
199
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
200
+ * The DI'd process runner for lifecycle tests (the injectable counterpart to
201
+ * defaultExecRunner). Takes an ordered script of steps; each call consumes the
202
202
  * next step, optionally asserting the argv shape, and resolves/rejects in the
203
203
  * execFile-error shape defaultExecRunner produces.
204
204
  */