@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.
- package/dist/{backend-Cbr2tIN-.d.mts → backend-CkJkkw5Z.d.mts} +6 -6
- package/dist/{device-BzPnHvQy.mjs → device-Xsy_LPUF.mjs} +2 -2
- package/dist/device-Xsy_LPUF.mjs.map +1 -0
- package/dist/index.d.mts +73 -13
- package/dist/index.mjs +162 -8
- package/dist/index.mjs.map +1 -1
- package/dist/testing.d.mts +6 -6
- package/dist/testing.mjs +5 -5
- package/dist/testing.mjs.map +1 -1
- package/package.json +15 -1
- package/src/actions.ts +6 -6
- package/src/backend.ts +4 -5
- package/src/backends/agent-device.ts +2 -3
- package/src/backends/device-runner.ts +205 -0
- package/src/backends/ios.ts +4 -4
- package/src/config.ts +4 -5
- package/src/device.ts +3 -3
- package/src/errors.ts +3 -3
- package/src/exec.ts +1 -1
- package/src/index.ts +4 -3
- package/src/lifecycle.ts +5 -5
- package/src/observe.ts +1 -1
- package/src/secrets.ts +2 -2
- package/src/testing.ts +4 -4
- package/dist/device-BzPnHvQy.mjs.map +0 -1
package/dist/testing.d.mts
CHANGED
|
@@ -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-
|
|
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 (
|
|
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
|
|
33
|
-
*
|
|
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 (
|
|
120
|
-
*
|
|
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-
|
|
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
|
|
5
|
-
*
|
|
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 (
|
|
147
|
-
*
|
|
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
|
*/
|
package/dist/testing.mjs.map
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
315
|
-
* action, no caller sleep
|
|
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
|
|
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-*
|
|
143
|
-
//
|
|
144
|
-
// permissions.ts's documented pattern
|
|
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
|
-
//
|
|
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);
|
package/src/backends/ios.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
41
|
-
*
|
|
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
|
|
4
|
-
//
|
|
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 (
|
|
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
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
22
|
-
*
|
|
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 (
|
|
201
|
-
*
|
|
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
|
*/
|