touchpress 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,19 @@ npx playwright test --project=ios
66
66
 
67
67
  `preflight` reads a project's options and reports whether the device they name is booted, so a missing simulator fails once, in about a second, instead of once per test after the launch timeout. Wire it as a setup project per platform that the device projects depend on. [Basics](https://github.com/wobsoriano/touchpress/blob/main/docs/basics.md) has the spec, and [`apps/e2e/e2e/preflight.setup.mts`](https://github.com/wobsoriano/touchpress/blob/main/apps/e2e/e2e/preflight.setup.mts) is a working one.
68
68
 
69
+ ### Drive a step with a model
70
+
71
+ `device.act` takes an instruction in English and drives the app until it is satisfied. `device.extract` asks one question about the screen and returns a typed answer.
72
+
73
+ ```ts
74
+ test('sign in', async ({ device }) => {
75
+ await device.act('Sign in with the email rob@example.com and the password hunter2');
76
+ await expect(device.getByTestId('greeting')).toHaveText('Hi, Rob');
77
+ });
78
+ ```
79
+
80
+ Set `use.aiModel` to a gateway model id or a provider model instance, and install `ai`, an optional peer dependency. A loop can run for minutes, so raise the test timeout on any spec that calls `act`. The example uses the sample app's fake account. Keep real credentials in deterministic `fill(text, { secret: true })` calls outside `act`, and assert deterministically afterwards. [AI](https://github.com/wobsoriano/touchpress/blob/main/docs/ai.md) covers `act`, `extract`, the tools the model gets, and the report.
81
+
69
82
  ## Run the sample project
70
83
 
71
84
  `apps/e2e` is an Expo app with a home, login, and profile route and a fake sign-in. It is the app touchpress is tested against. Build the library first with `vp run -r build` so the app can resolve `dist`, then run these from `apps/e2e`:
@@ -93,6 +106,7 @@ Leave Metro running for the whole suite. The first build takes several minutes.
93
106
  - [Configuration](https://github.com/wobsoriano/touchpress/blob/main/docs/configuration.md)
94
107
  - [Locators](https://github.com/wobsoriano/touchpress/blob/main/docs/locators.md)
95
108
  - [Assertions](https://github.com/wobsoriano/touchpress/blob/main/docs/assertions.md)
109
+ - [AI](https://github.com/wobsoriano/touchpress/blob/main/docs/ai.md)
96
110
  - [Lifecycle](https://github.com/wobsoriano/touchpress/blob/main/docs/lifecycle.md)
97
111
  - [Continuous integration](https://github.com/wobsoriano/touchpress/blob/main/docs/ci.md)
98
112
 
@@ -1,4 +1,4 @@
1
- import { $ as Tree, A as deviceNameForSlot, B as ProbeResult, C as openSession, D as ResolvedOptions, E as ReadyQuery, F as StepOptions, G as CaptureOptions, H as formatFailure, I as Typed, J as DeviceInfo, K as DeviceDriver, L as renderTitle, M as ActionRecord, N as ActionSink, O as TOUCHPRESS_DEFAULTS, P as EvidenceFile, Q as Settled, R as silentSink, S as SessionState, T as DeviceChoice, U as probe, V as ProbeTarget, W as Binding, X as OpenRequest, Y as DeviceSelection, Z as ScrollDirection, _ as RoleOptions, _t as Screen, a as ExpectedValue, at as Filter, b as DeviceSession, bt as renderScreen, c as ScrollSearch, ct as TextMatch, d as directionToward, dt as textMatch, et as Check, f as ActionOptions, ft as PinnedRef, g as Locator, gt as Resolution, h as FilterOptions, ht as Rect, i as ErrorInfo, it as evaluate, j as parseDeviceOptions, k as TouchpressOptions, l as ScrollTrail, lt as describeQuery, m as FillOptions, mt as RawSnapshot, n as PreflightReport, nt as Verdict, o as TouchpressError, ot as Query, p as Device, pt as Platform, q as DeviceFailure, r as preflight, rt as describeCheck, s as ScrollDevice, st as Role, t as PreflightDevice, tt as CheckName, u as createScrollSearch, ut as normalizeText, v as TextOptions, vt as ScreenNode, w as sessionName, x as OpenSessionInput, xt as resolve, y as createDevice, yt as parseScreen, z as ProbeOptions } from "../preflight-CCKYplYi.mjs";
1
+ import { $ as Tree, A as deviceNameForSlot, B as ProbeResult, C as openSession, D as ResolvedOptions, E as ReadyQuery, F as StepOptions, G as CaptureOptions, H as formatFailure, I as Typed, J as DeviceInfo, K as DeviceDriver, L as renderTitle, M as ActionRecord, N as ActionSink, O as TOUCHPRESS_DEFAULTS, P as EvidenceFile, Q as Settled, R as silentSink, S as SessionState, T as DeviceChoice, U as probe, V as ProbeTarget, W as Binding, X as OpenRequest, Y as DeviceSelection, Z as ScrollDirection, _ as RoleOptions, _t as Screen, a as ExpectedValue, at as Filter, b as DeviceSession, bt as renderScreen, c as ScrollSearch, ct as TextMatch, d as directionToward, dt as textMatch, et as Check, f as ActionOptions, ft as PinnedRef, g as Locator, gt as Resolution, h as FilterOptions, ht as Rect, i as ErrorInfo, it as evaluate, j as parseDeviceOptions, k as TouchpressOptions, l as ScrollTrail, lt as describeQuery, m as FillOptions, mt as RawSnapshot, n as PreflightReport, nt as Verdict, o as TouchpressError, ot as Query, p as Device, pt as Platform, q as DeviceFailure, r as preflight, rt as describeCheck, s as ScrollDevice, st as Role, t as PreflightDevice, tt as CheckName, u as createScrollSearch, ut as normalizeText, v as TextOptions, vt as ScreenNode, w as sessionName, x as OpenSessionInput, xt as resolve, y as createDevice, yt as parseScreen, z as ProbeOptions } from "../preflight-Czhj9QFM.mjs";
2
2
  //#region src/core/evidence.d.ts
3
3
  /**
4
4
  * Never throws. A capture that fails records a note and returns, because masking
@@ -1,2 +1,2 @@
1
- import { A as textMatch, C as describeCheck, D as parseDeviceOptions, E as deviceNameForSlot, O as describeQuery, S as resolve, T as TOUCHPRESS_DEFAULTS, a as sizeOf, b as parseScreen, d as createScrollSearch, f as directionToward, g as sessionName, h as openSession, i as relativeTo, j as TouchpressError, k as normalizeText, l as captureEvidence, m as probe, n as compareScreenshot, o as toPixelBox, p as formatFailure, r as cropScreenshot, t as preflight, u as createDevice, v as renderTitle, w as evaluate, x as renderScreen, y as silentSink } from "../preflight-DzSceNMx.mjs";
1
+ import { A as textMatch, C as renderScreen, D as parseDeviceOptions, E as deviceNameForSlot, O as describeQuery, S as parseScreen, T as TOUCHPRESS_DEFAULTS, a as sizeOf, b as renderTitle, d as createScrollSearch, f as directionToward, g as sessionName, h as openSession, i as relativeTo, j as TouchpressError, k as normalizeText, l as captureEvidence, m as probe, n as compareScreenshot, o as toPixelBox, p as formatFailure, r as cropScreenshot, t as preflight, u as createDevice, v as describeCheck, w as resolve, x as silentSink, y as evaluate } from "../preflight-B7KrbqSO.mjs";
2
2
  export { TOUCHPRESS_DEFAULTS, TouchpressError, captureEvidence, compareScreenshot, createDevice, createScrollSearch, cropScreenshot, describeCheck, describeQuery, deviceNameForSlot, directionToward, evaluate, formatFailure, normalizeText, openSession, parseDeviceOptions, parseScreen, preflight, probe, relativeTo, renderScreen, renderTitle, resolve, sessionName, silentSink, sizeOf, textMatch, toPixelBox };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,40 @@
1
- import { E as ReadyQuery, _t as Screen, a as ExpectedValue, at as Filter, b as DeviceSession, ct as TextMatch, g as Locator, h as FilterOptions, ht as Rect, i as ErrorInfo, k as TouchpressOptions, n as PreflightReport, o as TouchpressError, ot as Query, p as Device, pt as Platform, r as preflight, st as Role, t as PreflightDevice, vt as ScreenNode } from "./preflight-CCKYplYi.mjs";
1
+ import { E as ReadyQuery, _t as Screen, a as ExpectedValue, at as Filter, b as DeviceSession, ct as TextMatch, g as Locator, h as FilterOptions, ht as Rect, i as ErrorInfo, k as TouchpressOptions$1, n as PreflightReport, o as TouchpressError, ot as Query, p as Device$1, pt as Platform, r as preflight, st as Role, t as PreflightDevice, vt as ScreenNode } from "./preflight-Czhj9QFM.mjs";
2
2
  import { ExpectMatcherState } from "@playwright/test";
3
+ import { FlexibleSchema, LanguageModel } from "ai";
4
+ //#region src/ai/device.d.ts
5
+ /** A loop can run for minutes, so its budget is its own rather than the action timeout a deterministic step takes. */
6
+ type ActOptions = {
7
+ timeout?: number;
8
+ maxSteps?: number;
9
+ };
10
+ type ExtractOptions = {
11
+ timeout?: number;
12
+ };
13
+ type AiDevice = {
14
+ /**
15
+ * Drives the app with a model until the instruction is satisfied. Resolves
16
+ * with the model's summary of what it did, and throws when the model reports
17
+ * it could not proceed or runs out of steps.
18
+ */
19
+ act(instruction: string, options?: ActOptions): Promise<string>;
20
+ /** Asks a model one question about the current screen and validates the answer against `schema`. */
21
+ extract<T>(question: string, schema: FlexibleSchema<T>, options?: ExtractOptions): Promise<T>;
22
+ };
23
+ //#endregion
24
+ //#region src/ai/options.d.ts
25
+ /**
26
+ * The one key `act` and `extract` add to Playwright's `use`. It is not part of
27
+ * `core/config.ts`, because nothing under `core/` may name an AI SDK type.
28
+ *
29
+ * A gateway model id such as `'anthropic/claude-sonnet-5'` and a provider
30
+ * model instance are both `LanguageModel`, so a config picks either without a
31
+ * second key. Unset is the default, and it fails at the first `act` rather than
32
+ * at worker start, so a project that never calls one needs no model.
33
+ */
34
+ type AiOptions = {
35
+ aiModel: LanguageModel | undefined;
36
+ };
37
+ //#endregion
3
38
  //#region src/playwright/fixtures.d.ts
4
39
  /**
5
40
  * Touchpress's options and none of its fixtures, for a setup project that reads the
@@ -10,7 +45,7 @@ import { ExpectMatcherState } from "@playwright/test";
10
45
  * type, and `parseDeviceOptions` rejects `undefined` by name, so a config that
11
46
  * forgot a key and one that never set it fail the same way.
12
47
  */
13
- declare const setupTest: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & object, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & TouchpressOptions>;
48
+ declare const setupTest: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & object, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & TouchpressOptions$1 & AiOptions>;
14
49
  /**
15
50
  * `device` is auto so evidence capture runs for every test in a device project,
16
51
  * whether or not the body touched it. Its teardown runs before the session's,
@@ -21,8 +56,8 @@ declare const setupTest: import("@playwright/test").TestType<import("@playwright
21
56
  * `page` are lazy and non-auto, and nothing here names them.
22
57
  */
23
58
  declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & object & {
24
- device: Device;
25
- }, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & TouchpressOptions & {
59
+ device: Device$1 & AiDevice;
60
+ }, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & TouchpressOptions$1 & AiOptions & {
26
61
  session: DeviceSession;
27
62
  }>;
28
63
  //#endregion
@@ -64,7 +99,7 @@ declare const expect: import("@playwright/test").Expect<{
64
99
  toHaveText(this: ExpectMatcherState, locator: Locator, expected: string | RegExp, options?: TextMatcherOptions): Promise<MatcherResult>;
65
100
  toHaveValue(this: ExpectMatcherState, locator: Locator, expected: string | RegExp, options?: MatcherOptions): Promise<MatcherResult>;
66
101
  toHaveCount(this: ExpectMatcherState, locator: Locator, expected: number, options?: MatcherOptions): Promise<MatcherResult>;
67
- toHaveScreenshot(this: ExpectMatcherState, target: Device | Locator, nameOrOptions?: string | ScreenshotOptions, options?: ScreenshotOptions): Promise<{
102
+ toHaveScreenshot(this: ExpectMatcherState, target: Device$1 | Locator, nameOrOptions?: string | ScreenshotOptions, options?: ScreenshotOptions): Promise<{
68
103
  pass: boolean;
69
104
  message: () => string;
70
105
  name: string;
@@ -73,4 +108,13 @@ declare const expect: import("@playwright/test").Expect<{
73
108
  }>;
74
109
  }>;
75
110
  //#endregion
76
- export { type Device, type ErrorInfo, type ExpectedValue, type Filter, type FilterOptions, type Locator, type Platform, type PreflightDevice, type PreflightReport, type Query, type ReadyQuery, type Rect, type Role, type Screen, type ScreenNode, type ScreenshotOptions, type TextMatch, TouchpressError, type TouchpressOptions, expect, preflight, setupTest, test };
111
+ //#region src/index.d.ts
112
+ /**
113
+ * `Device` and `TouchpressOptions` carry the AI surface here and only here.
114
+ * `touchpress/core` exports the runner-independent pair without it, because
115
+ * nothing under `core/` may name an AI SDK type.
116
+ */
117
+ type TouchpressOptions = TouchpressOptions$1 & AiOptions;
118
+ type Device = Device$1 & AiDevice;
119
+ //#endregion
120
+ export { type ActOptions, type AiDevice, type AiOptions, Device, type ErrorInfo, type ExpectedValue, type ExtractOptions, type Filter, type FilterOptions, type Locator, type Platform, type PreflightDevice, type PreflightReport, type Query, type ReadyQuery, type Rect, type Role, type Screen, type ScreenNode, type ScreenshotOptions, type TextMatch, TouchpressError, TouchpressOptions, expect, preflight, setupTest, test };
package/dist/index.mjs CHANGED
@@ -1,7 +1,500 @@
1
- import { A as textMatch, D as parseDeviceOptions, S as resolve, T as TOUCHPRESS_DEFAULTS, _ as sleep, a as sizeOf, c as createClient, h as openSession, i as relativeTo, j as TouchpressError, l as captureEvidence, n as compareScreenshot, o as toPixelBox, r as cropScreenshot, s as createAgentDeviceDriver, t as preflight, u as createDevice, y as silentSink } from "./preflight-DzSceNMx.mjs";
1
+ import { A as textMatch, C as renderScreen, D as parseDeviceOptions, S as parseScreen, T as TOUCHPRESS_DEFAULTS, _ as sleep, a as sizeOf, b as renderTitle, c as createClient, h as openSession, i as relativeTo, j as TouchpressError, l as captureEvidence, n as compareScreenshot, o as toPixelBox, r as cropScreenshot, s as createAgentDeviceDriver, t as preflight, u as createDevice, w as resolve, x as silentSink } from "./preflight-B7KrbqSO.mjs";
2
2
  import { expect as expect$1, test as test$1 } from "@playwright/test";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { dirname } from "node:path";
5
+ //#region src/ai/tools.ts
6
+ /**
7
+ * The AI SDK is an optional peer, so it is imported here and never at module
8
+ * scope. Importing `touchpress` must not require it, because a project that
9
+ * calls neither `act` nor `extract` never installs it.
10
+ */
11
+ async function loadAi() {
12
+ try {
13
+ return await import("ai");
14
+ } catch {
15
+ throw new TouchpressError({ kind: "ai-missing-peer" });
16
+ }
17
+ }
18
+ const READ = {
19
+ detail: null,
20
+ echoesText: false,
21
+ output: "raw"
22
+ };
23
+ const DEVICE_TOOLS = /* @__PURE__ */ new Map([
24
+ ["snapshot", {
25
+ detail: null,
26
+ echoesText: false,
27
+ output: "screen"
28
+ }],
29
+ ["press", {
30
+ detail: "target",
31
+ echoesText: false,
32
+ output: "outcome"
33
+ }],
34
+ ["fill", {
35
+ detail: "target",
36
+ echoesText: true,
37
+ output: "outcome"
38
+ }],
39
+ ["type", {
40
+ detail: null,
41
+ echoesText: true,
42
+ output: "outcome"
43
+ }],
44
+ ["scroll", {
45
+ detail: "direction",
46
+ echoesText: false,
47
+ output: "outcome"
48
+ }],
49
+ ["back", {
50
+ detail: null,
51
+ echoesText: false,
52
+ output: "outcome"
53
+ }],
54
+ ["wait", READ],
55
+ ["get", READ],
56
+ ["is", READ],
57
+ ["alert", READ]
58
+ ]);
59
+ /**
60
+ * Everything an input schema says about which device, daemon, or workspace a
61
+ * command reaches. The session already pins all of it, and a model that could
62
+ * name a daemon could leave the device this test opened.
63
+ *
64
+ * `recordAs` rides along for a different reason: a `fill` carrying it fails
65
+ * unless script recording is armed, which under touchpress it never is.
66
+ */
67
+ const CUT_KEYS = /* @__PURE__ */ new Set([
68
+ "udid",
69
+ "serial",
70
+ "device",
71
+ "deviceTarget",
72
+ "daemonBaseUrl",
73
+ "daemonAuthToken",
74
+ "tenant",
75
+ "runId",
76
+ "leaseId",
77
+ "cwd",
78
+ "debug",
79
+ "iosSimulatorDeviceSet",
80
+ "iosXctestrunFile",
81
+ "iosXctestDerivedDataPath",
82
+ "iosXctestEnvDir",
83
+ "androidDeviceAllowlist",
84
+ "noRecord",
85
+ "record",
86
+ "saveScript",
87
+ "stateDir",
88
+ "recordAs"
89
+ ]);
90
+ /**
91
+ * The tools a model drives the app with, built from agent-device's own command
92
+ * registry so the descriptions and the executors stay upstream's. Three things
93
+ * change: the set is narrowed to the ten commands that perceive and act, every
94
+ * key that could point a command at another device is cut from the input
95
+ * schema, and each executor is wrapped so what crosses to the model is the
96
+ * shape the model can act on. Building them contacts no device.
97
+ */
98
+ async function createDeviceTools(session, platform) {
99
+ const { createAgentDeviceTools } = await import("agent-device/ai-sdk");
100
+ const { jsonSchema, tool } = await loadAi();
101
+ const { tools } = await createAgentDeviceTools({
102
+ session,
103
+ platform
104
+ });
105
+ const kept = {};
106
+ for (const name of DEVICE_TOOLS.keys()) {
107
+ const built = tools[name];
108
+ if (built === void 0) throw new Error(`agent-device no longer exposes the "${name}" tool`);
109
+ kept[name] = tool({
110
+ description: built.description,
111
+ inputSchema: jsonSchema(prune(built.inputSchema.jsonSchema)),
112
+ execute: wrapDeviceTool(name, platform, built.execute)
113
+ });
114
+ }
115
+ return kept;
116
+ }
117
+ /**
118
+ * The one place a command's input and output are reshaped for the model, so the
119
+ * table above stays the only thing that says which command gets which shape.
120
+ */
121
+ function wrapDeviceTool(name, platform, execute) {
122
+ const shape = DEVICE_TOOLS.get(name)?.output ?? "raw";
123
+ return async (input, options) => {
124
+ const output = await execute(shape === "screen" ? {
125
+ ...asObject(input),
126
+ forceFull: true
127
+ } : withRefSigil(input), options);
128
+ return shape === "screen" ? compactSnapshot(asSnapshot(output), platform) : compactResult(name, output);
129
+ };
130
+ }
131
+ /**
132
+ * The tree the model reads, in the same listing a failure message prints, one
133
+ * node per line indented by depth. The JSON upstream returns says the same thing
134
+ * with rects, indexes, and flags the model never uses, at three to seven times
135
+ * the size across this repo's fixtures, and its refs arrive without the `@` the
136
+ * action schemas demand.
137
+ */
138
+ function compactSnapshot(raw, platform) {
139
+ const screen = parseScreen(raw, platform);
140
+ const listing = renderScreen(screen);
141
+ return screen.truncated ? `${listing}\n ... the tree is truncated, so some nodes are missing` : listing;
142
+ }
143
+ /**
144
+ * What the model needs off an action it just ran: whether it landed, on what,
145
+ * and whether the screen went quiet. Upstream also returns the settle diff, the
146
+ * evidence paths, the resolution, and the cost, which are most of the step's
147
+ * tokens and none of its meaning.
148
+ */
149
+ function compactResult(name, output) {
150
+ if (DEVICE_TOOLS.get(name)?.output !== "outcome") return output;
151
+ if (typeof output !== "object" || output === null) return output;
152
+ const settle = asObject(output)["settle"];
153
+ return {
154
+ ...pick(output, "message"),
155
+ ...pick(output, "targetKind"),
156
+ ...typeof settle === "object" && settle !== null ? { settle: {
157
+ ...pick(settle, "settled"),
158
+ ...pick(settle, "waitedMs")
159
+ } } : {}
160
+ };
161
+ }
162
+ /**
163
+ * Upstream's own snapshot nodes carry a bare `e4` while the action schemas
164
+ * demand `@e4`, and a model that sends the bare form gets an error it tends to
165
+ * read as "refs do not work here" before falling back to coordinates for the
166
+ * rest of the run. The listing above now prints the `@`, and this catches the
167
+ * model that typed it from memory anyway.
168
+ */
169
+ function withRefSigil(input) {
170
+ const target = asObject(asObject(input)["target"]);
171
+ if (target["kind"] !== "ref") return input;
172
+ const ref = target["ref"];
173
+ if (typeof ref !== "string" || ref.startsWith("@")) return input;
174
+ return {
175
+ ...asObject(input),
176
+ target: {
177
+ ...target,
178
+ ref: `@${ref}`
179
+ }
180
+ };
181
+ }
182
+ function asObject(input) {
183
+ return typeof input === "object" && input !== null ? input : {};
184
+ }
185
+ function asSnapshot(output) {
186
+ if (Array.isArray(asObject(output)["nodes"])) return output;
187
+ throw new Error("agent-device returned a snapshot without nodes");
188
+ }
189
+ function pick(source, key) {
190
+ const value = asObject(source)[key];
191
+ return value === void 0 ? {} : { [key]: value };
192
+ }
193
+ function prune(schema) {
194
+ if (schema.properties === void 0) return schema;
195
+ const properties = {};
196
+ for (const [key, value] of Object.entries(schema.properties)) if (!CUT_KEYS.has(key) && !(key === "target" && isDeviceAlias(value))) properties[key] = value;
197
+ return {
198
+ ...schema,
199
+ properties,
200
+ ...schema.required === void 0 ? {} : { required: schema.required.filter((key) => key in properties) }
201
+ };
202
+ }
203
+ /**
204
+ * Upstream spends `target` twice. On `press`, `fill`, and `get` it is the UI
205
+ * element, a `oneOf` over ref, selector, and point. On the rest it is an alias
206
+ * for `deviceTarget`, an enum of device forms, and a model reading both in one
207
+ * tool set answers the second where the first was meant. The session pins the
208
+ * device, so the alias goes and only the UI target survives.
209
+ */
210
+ function isDeviceAlias(schema) {
211
+ return Array.isArray(asObject(schema)["enum"]);
212
+ }
213
+ /** A tool the table does not name still reports, by its name alone. */
214
+ function toolRecord(name, input) {
215
+ const detail = DEVICE_TOOLS.get(name)?.detail ?? null;
216
+ const target = detail === null ? null : readText(input, detail);
217
+ return target === null ? {
218
+ kind: "tool",
219
+ name
220
+ } : {
221
+ kind: "tool",
222
+ name,
223
+ target
224
+ };
225
+ }
226
+ /**
227
+ * The text a `fill` or a `type` wrote, for the nested step under it. Never
228
+ * masked: the model composed this string and already holds it, so hiding it
229
+ * from the report would cost evidence and buy nothing. A credential belongs in
230
+ * a deterministic `fill(text, { secret: true })` outside `act`.
231
+ */
232
+ function typedText(name, input) {
233
+ return DEVICE_TOOLS.get(name)?.echoesText === true ? readText(input, "text") : null;
234
+ }
235
+ function readText(input, key) {
236
+ const value = asObject(input)[key];
237
+ return typeof value === "string" && value.length > 0 ? value : null;
238
+ }
239
+ //#endregion
240
+ //#region src/ai/act.ts
241
+ const TRANSCRIPT_RESULT_LIMIT = 2048;
242
+ function instructionsFor(platform) {
243
+ return [
244
+ `You are driving a ${platform} mobile app that is already launched and in the foreground.`,
245
+ "Start with snapshot.",
246
+ "A snapshot prints one node per line, indented by depth, as in: @e4 [button] \"Sign in\" #signIn",
247
+ "Use press to tap a node and fill to replace a field's text.",
248
+ "Both take the ref as { \"kind\": \"ref\", \"ref\": \"@e4\" }, copied exactly as the line printed it.",
249
+ "Every ref stops working when the next command runs, so snapshot again after each action before you use one.",
250
+ "Use coordinates only when no line on the snapshot is the thing you need.",
251
+ "Once a snapshot shows the instruction is satisfied, call done with outcome \"completed\" and a one-line summary. Do not take a second snapshot to double-check.",
252
+ "If you cannot proceed, call done with outcome \"blocked\" and say what stopped you."
253
+ ].join("\n");
254
+ }
255
+ const EXTRACT_INSTRUCTIONS = [
256
+ "You are reading one accessibility tree captured from a mobile app.",
257
+ "Each line is one node, indented by depth, carrying its ref, role, name, and test id, as in: @e4 [button] \"Sign in\" #signIn",
258
+ "The tree never prints a field's value.",
259
+ "Answer from what the tree shows, not from what the app is expected to show."
260
+ ].join("\n");
261
+ /**
262
+ * Runs the model against the tools until it calls `done`. Nothing here knows
263
+ * about a session, so a test drives it with fake tools and a mock model.
264
+ *
265
+ * A tool error thrown inside `execute` is not caught: the AI SDK hands it back
266
+ * to the model as a tool result, which is what lets it recover from a ref that
267
+ * went stale by taking a fresh snapshot.
268
+ */
269
+ function runAct(run) {
270
+ return run.sink.step(renderTitle({
271
+ kind: "act",
272
+ instruction: run.instruction
273
+ }), async () => {
274
+ const { ToolLoopAgent, hasToolCall, jsonSchema, stepCountIs, tool } = await loadAi();
275
+ const result = await new ToolLoopAgent({
276
+ model: run.model,
277
+ instructions: instructionsFor(run.platform),
278
+ tools: {
279
+ ...reporting(run.tools, run.sink),
280
+ done: tool({
281
+ description: "Finish the instruction. Call this when it is satisfied, or when you cannot proceed.",
282
+ inputSchema: jsonSchema({
283
+ type: "object",
284
+ properties: {
285
+ outcome: {
286
+ type: "string",
287
+ enum: ["completed", "blocked"],
288
+ description: "completed when the instruction is satisfied, blocked when it is not"
289
+ },
290
+ summary: {
291
+ type: "string",
292
+ description: "One line saying what you did, or what stopped you."
293
+ }
294
+ },
295
+ required: ["outcome", "summary"],
296
+ additionalProperties: false
297
+ })
298
+ })
299
+ },
300
+ stopWhen: [hasToolCall("done"), stepCountIs(run.maxSteps)]
301
+ }).generate({
302
+ prompt: run.instruction,
303
+ abortSignal: AbortSignal.timeout(run.timeout)
304
+ }).catch(async (error) => {
305
+ if (!timedOut(error)) throw error;
306
+ throw new TouchpressError({
307
+ kind: "ai-timeout",
308
+ instruction: run.instruction,
309
+ timeoutMs: run.timeout,
310
+ screen: await run.screen()
311
+ });
312
+ });
313
+ await run.sink.attach({
314
+ name: `ai-act-${String(run.attempt)}.json`,
315
+ contentType: "application/json",
316
+ body: JSON.stringify({
317
+ instruction: run.instruction,
318
+ usage: result.usage,
319
+ steps: result.steps.map((step) => {
320
+ const errors = toolErrors(step.content);
321
+ return {
322
+ text: step.text,
323
+ toolCalls: step.toolCalls.map((call) => ({
324
+ name: call.toolName,
325
+ input: call.input
326
+ })),
327
+ toolResults: step.toolResults.map((toolResult) => ({
328
+ name: toolResult.toolName,
329
+ output: clip(toolResult.output)
330
+ })),
331
+ ...errors.length === 0 ? {} : { toolErrors: errors }
332
+ };
333
+ })
334
+ }, null, 2)
335
+ });
336
+ const outcome = outcomeOf(result.steps.at(-1)?.toolCalls);
337
+ if (outcome === null) throw new TouchpressError({
338
+ kind: "ai-incomplete",
339
+ instruction: run.instruction,
340
+ steps: result.steps.length,
341
+ screen: await run.screen()
342
+ });
343
+ if (outcome.kind === "blocked") throw new TouchpressError({
344
+ kind: "ai-blocked",
345
+ instruction: run.instruction,
346
+ summary: outcome.summary,
347
+ screen: await run.screen()
348
+ });
349
+ return outcome.summary;
350
+ });
351
+ }
352
+ /** One capture, one question, one answer. No tools, so the model cannot change the screen it is describing. */
353
+ function runExtract(run) {
354
+ return run.sink.step(renderTitle({
355
+ kind: "extract",
356
+ question: run.question
357
+ }), async () => {
358
+ const { ToolLoopAgent, Output } = await loadAi();
359
+ return (await new ToolLoopAgent({
360
+ model: run.model,
361
+ instructions: EXTRACT_INSTRUCTIONS,
362
+ output: Output.object({ schema: run.schema })
363
+ }).generate({
364
+ prompt: [
365
+ `Question: ${run.question}`,
366
+ ``,
367
+ `Screen:`,
368
+ run.screen
369
+ ].join("\n"),
370
+ abortSignal: AbortSignal.timeout(run.timeout)
371
+ })).output;
372
+ });
373
+ }
374
+ /** Every model action becomes a step wrapping its own execution, so a report shows the loop as it ran. */
375
+ function reporting(tools, sink) {
376
+ const wrapped = {};
377
+ for (const [name, built] of Object.entries(tools)) {
378
+ const { execute } = built;
379
+ if (execute === void 0) {
380
+ wrapped[name] = built;
381
+ continue;
382
+ }
383
+ wrapped[name] = {
384
+ ...built,
385
+ execute: (input, options) => sink.step(renderTitle(toolRecord(name, input)), async () => {
386
+ const text = typedText(name, input);
387
+ if (text !== null) await sink.step(renderTitle({
388
+ kind: "typed",
389
+ typed: {
390
+ kind: "text",
391
+ value: text
392
+ }
393
+ }), () => Promise.resolve(), { box: true });
394
+ return execute(input, options);
395
+ })
396
+ };
397
+ }
398
+ return wrapped;
399
+ }
400
+ /**
401
+ * The `done` call is validated here rather than trusted, because a JSON schema
402
+ * handed to `jsonSchema()` describes the tool to the model and validates
403
+ * nothing. A malformed call is a run that never reached an outcome.
404
+ */
405
+ function outcomeOf(calls) {
406
+ const call = calls?.find((one) => one.toolName === "done");
407
+ if (call === void 0 || typeof call.input !== "object" || call.input === null) return null;
408
+ const summary = Reflect.get(call.input, "summary");
409
+ if (typeof summary !== "string") return null;
410
+ const outcome = Reflect.get(call.input, "outcome");
411
+ if (outcome === "completed") return {
412
+ kind: "completed",
413
+ summary
414
+ };
415
+ if (outcome === "blocked") return {
416
+ kind: "blocked",
417
+ summary
418
+ };
419
+ return null;
420
+ }
421
+ /** `AbortSignal.timeout` rejects with a DOMException named TimeoutError, which a provider surfaces as is or as an AbortError. */
422
+ function timedOut(error) {
423
+ return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
424
+ }
425
+ /**
426
+ * The calls that failed. `step.toolResults` holds only the ones that returned,
427
+ * so a transcript built from it alone shows a loop doing nothing and never says
428
+ * why, which is what a nine-error run looked like from the attachment.
429
+ */
430
+ function toolErrors(content) {
431
+ return content.filter((part) => part.type === "tool-error").map((part) => ({
432
+ name: part.toolName,
433
+ input: part.input,
434
+ error: messageOf(part.error)
435
+ }));
436
+ }
437
+ function messageOf(error) {
438
+ if (error instanceof Error) return error.message;
439
+ return typeof error === "string" ? error : JSON.stringify(error) ?? "unknown error";
440
+ }
441
+ function clip(output) {
442
+ const text = JSON.stringify(output) ?? "undefined";
443
+ return text.length <= TRANSCRIPT_RESULT_LIMIT ? text : `${text.slice(0, TRANSCRIPT_RESULT_LIMIT)}...`;
444
+ }
445
+ //#endregion
446
+ //#region src/ai/device.ts
447
+ const DEFAULT_ACT_TIMEOUT_MS = 12e4;
448
+ const DEFAULT_ACT_STEPS = 25;
449
+ const DEFAULT_EXTRACT_TIMEOUT_MS = 6e4;
450
+ /**
451
+ * Adds `act` and `extract` to a device without touching what is already there.
452
+ *
453
+ * Both run inside `session.run`, so the whole loop holds the session queue. A
454
+ * test body is sequential anyway, and the model's snapshot refs carry the same
455
+ * rule every deterministic action does: they are valid for the next command
456
+ * only, so nothing else may reach the device in between.
457
+ */
458
+ function withAi(device, session, sink, model) {
459
+ let acts = 0;
460
+ let built = null;
461
+ const tools = () => built ??= createDeviceTools(session.name, session.options.platform);
462
+ return {
463
+ ...device,
464
+ act: async (instruction, options) => {
465
+ const configured = configuredModel(model);
466
+ const deviceTools = await tools();
467
+ return session.run((one) => runAct({
468
+ model: configured,
469
+ tools: deviceTools,
470
+ sink,
471
+ instruction,
472
+ platform: session.options.platform,
473
+ maxSteps: options?.maxSteps ?? DEFAULT_ACT_STEPS,
474
+ timeout: options?.timeout ?? DEFAULT_ACT_TIMEOUT_MS,
475
+ screen: async () => renderScreen(await one.capture()),
476
+ attempt: acts += 1
477
+ }));
478
+ },
479
+ extract: async (question, schema, options) => {
480
+ const configured = configuredModel(model);
481
+ return session.run(async (one) => runExtract({
482
+ model: configured,
483
+ screen: renderScreen(await one.capture()),
484
+ question,
485
+ schema,
486
+ sink,
487
+ timeout: options?.timeout ?? DEFAULT_EXTRACT_TIMEOUT_MS
488
+ }));
489
+ }
490
+ };
491
+ }
492
+ /** Checked before the queue, so a project that forgot the key fails at once rather than holding the session. */
493
+ function configuredModel(model) {
494
+ if (model === void 0) throw new TouchpressError({ kind: "ai-not-configured" });
495
+ return model;
496
+ }
497
+ //#endregion
5
498
  //#region src/playwright/fixtures.ts
6
499
  const SESSION_FIXTURE_TIMEOUT_MS = 18e4;
7
500
  const DEVICE_FIXTURE_TIMEOUT_MS = 12e4;
@@ -62,6 +555,10 @@ const setupTest = test$1.extend({
62
555
  sessionPrefix: [TOUCHPRESS_DEFAULTS.sessionPrefix, {
63
556
  option: true,
64
557
  scope: "worker"
558
+ }],
559
+ aiModel: [void 0, {
560
+ option: true,
561
+ scope: "worker"
65
562
  }]
66
563
  });
67
564
  /** The worker session already opened the app with a relaunch, so the first test skips one. */
@@ -105,11 +602,11 @@ const test = setupTest.extend({
105
602
  scope: "worker",
106
603
  timeout: SESSION_FIXTURE_TIMEOUT_MS
107
604
  }],
108
- device: [async ({ session }, use, testInfo) => {
605
+ device: [async ({ session, aiModel }, use, testInfo) => {
109
606
  const sink = playwrightSink();
110
607
  if (session.options.relaunch === "per-test" && startedTests.has(session)) await session.relaunch(sink);
111
608
  startedTests.add(session);
112
- await use(createDevice(session, sink));
609
+ await use(withAi(createDevice(session, sink), session, sink, aiModel));
113
610
  if (shouldCapture(testInfo, session.options.evidence)) await captureEvidence(session, sink);
114
611
  }, {
115
612
  auto: true,
@@ -59,6 +59,36 @@ function formatError(info) {
59
59
  info.screen
60
60
  ].join("\n");
61
61
  case "driver": return `${info.command} failed: ${describeFailure(info.failure)}`;
62
+ case "ai-not-configured": return ["device.act and device.extract need a model.", "Set use.aiModel to a gateway model id, such as 'anthropic/claude-sonnet-5', or to a provider model instance."].join("\n");
63
+ case "ai-missing-peer": return ["device.act and device.extract need the optional peer dependency 'ai'.", "Install it with: pnpm add -D ai"].join("\n");
64
+ case "ai-blocked": return [
65
+ `act stopped without finishing: ${info.summary}`,
66
+ ``,
67
+ `Instruction: ${info.instruction}`,
68
+ ``,
69
+ `Screen:`,
70
+ info.screen
71
+ ].join("\n");
72
+ case "ai-incomplete": return [
73
+ `act ran ${String(info.steps)} steps without reaching an outcome.`,
74
+ ``,
75
+ `Instruction: ${info.instruction}`,
76
+ ``,
77
+ "Raise maxSteps, or split the instruction into smaller ones.",
78
+ ``,
79
+ `Screen:`,
80
+ info.screen
81
+ ].join("\n");
82
+ case "ai-timeout": return [
83
+ `act ran out of its ${String(info.timeoutMs)}ms budget before reaching an outcome.`,
84
+ ``,
85
+ `Instruction: ${info.instruction}`,
86
+ ``,
87
+ "Raise the act timeout, and the test timeout with it.",
88
+ ``,
89
+ `Screen:`,
90
+ info.screen
91
+ ].join("\n");
62
92
  default: throw new Error(`unhandled error info ${JSON.stringify(info)}`);
63
93
  }
64
94
  }
@@ -355,89 +385,6 @@ function fail(field, detail) {
355
385
  });
356
386
  }
357
387
  //#endregion
358
- //#region src/core/checks.ts
359
- /**
360
- * A `many` outcome never passes anything but `toHaveCount`. An ambiguous locator
361
- * is a strictness violation, and it reports through the same message path as a
362
- * plain mismatch rather than guessing which node was meant.
363
- */
364
- function evaluate(check, resolution) {
365
- if (check.name === "toHaveCount") {
366
- const count = countOf(resolution);
367
- return {
368
- pass: count === check.expected,
369
- actual: String(count)
370
- };
371
- }
372
- if (resolution.outcome === "none") return {
373
- pass: false,
374
- actual: null
375
- };
376
- if (resolution.outcome === "many") return {
377
- pass: false,
378
- actual: `${String(resolution.nodes.length)} matching nodes`
379
- };
380
- const node = resolution.node;
381
- switch (check.name) {
382
- case "toBeVisible": return {
383
- pass: true,
384
- actual: describeNode(node)
385
- };
386
- case "toHaveText": {
387
- const text = node.name ?? node.value;
388
- return {
389
- pass: matchesText(check.expected, text),
390
- actual: text === null ? null : `"${text}"`
391
- };
392
- }
393
- case "toHaveValue": return {
394
- pass: matchesText(check.expected, node.value),
395
- actual: node.value === null ? null : `"${node.value}"`
396
- };
397
- case "toBeEnabled": return {
398
- pass: node.enabled,
399
- actual: node.enabled ? "enabled" : "disabled"
400
- };
401
- case "toBeSelected": return {
402
- pass: node.selected,
403
- actual: node.selected ? "selected" : "not selected"
404
- };
405
- case "toBeFocused": return {
406
- pass: node.focused,
407
- actual: node.focused ? "focused" : "not focused"
408
- };
409
- default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
410
- }
411
- }
412
- function countOf(resolution) {
413
- switch (resolution.outcome) {
414
- case "one": return 1;
415
- case "none": return 0;
416
- case "many": return resolution.nodes.length;
417
- default: throw new Error(`unhandled resolution ${JSON.stringify(resolution)}`);
418
- }
419
- }
420
- /** The `Expected:` line. */
421
- function describeCheck(check) {
422
- switch (check.name) {
423
- case "toBeVisible": return "visible";
424
- case "toHaveText": return `text ${describeExpected(check.expected)}`;
425
- case "toHaveValue": return `value ${describeExpected(check.expected)}`;
426
- case "toBeEnabled": return "enabled";
427
- case "toBeSelected": return "selected";
428
- case "toBeFocused": return "focused";
429
- case "toHaveCount": return `count ${String(check.expected)}`;
430
- default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
431
- }
432
- }
433
- function describeExpected(match) {
434
- return match.kind === "regex" ? String(match.value) : `"${match.value}"`;
435
- }
436
- function describeNode(node) {
437
- const name = node.name === null ? "" : ` "${node.name}"`;
438
- return `${node.ref} [${node.role}]${name}`;
439
- }
440
- //#endregion
441
388
  //#region src/core/screen.ts
442
389
  const IOS_ROLES = {
443
390
  Application: "application",
@@ -716,6 +663,7 @@ function renderNode(node) {
716
663
  //#endregion
717
664
  //#region src/core/report.ts
718
665
  const FILL_TEXT_LIMIT = 40;
666
+ const PROMPT_LIMIT = 80;
719
667
  /** Rendered here rather than in an adapter so every runner produces the same text. */
720
668
  function renderTitle(record) {
721
669
  switch (record.kind) {
@@ -729,18 +677,21 @@ function renderTitle(record) {
729
677
  case "relaunch": return `relaunch ${record.app}`;
730
678
  case "dismiss-overlay": return "dismiss the React Native dev overlay";
731
679
  case "screenshot": return `screenshot ${record.path}`;
680
+ case "act": return `act "${truncate(record.instruction, PROMPT_LIMIT)}"`;
681
+ case "extract": return `extract "${truncate(record.question, PROMPT_LIMIT)}"`;
682
+ case "tool": return record.target === void 0 ? record.name : `${record.name} ${record.target}`;
732
683
  default: throw new Error(`unhandled action record ${JSON.stringify(record)}`);
733
684
  }
734
685
  }
735
686
  function renderTyped(typed) {
736
687
  switch (typed.kind) {
737
- case "text": return `type "${truncate(typed.value)}"`;
688
+ case "text": return `type "${truncate(typed.value, FILL_TEXT_LIMIT)}"`;
738
689
  case "hidden": return `type ${String(typed.length)} characters`;
739
690
  default: throw new Error(`unhandled typed value ${JSON.stringify(typed)}`);
740
691
  }
741
692
  }
742
- function truncate(text) {
743
- return text.length <= FILL_TEXT_LIMIT ? text : `${text.slice(0, FILL_TEXT_LIMIT)}...`;
693
+ function truncate(text, limit) {
694
+ return text.length <= limit ? text : `${text.slice(0, limit)}...`;
744
695
  }
745
696
  /** Discards everything. The default for scripts, unit tests, and runners with no reporting. */
746
697
  const silentSink = {
@@ -750,6 +701,89 @@ const silentSink = {
750
701
  outputPath: (fileName) => fileName
751
702
  };
752
703
  //#endregion
704
+ //#region src/core/checks.ts
705
+ /**
706
+ * A `many` outcome never passes anything but `toHaveCount`. An ambiguous locator
707
+ * is a strictness violation, and it reports through the same message path as a
708
+ * plain mismatch rather than guessing which node was meant.
709
+ */
710
+ function evaluate(check, resolution) {
711
+ if (check.name === "toHaveCount") {
712
+ const count = countOf(resolution);
713
+ return {
714
+ pass: count === check.expected,
715
+ actual: String(count)
716
+ };
717
+ }
718
+ if (resolution.outcome === "none") return {
719
+ pass: false,
720
+ actual: null
721
+ };
722
+ if (resolution.outcome === "many") return {
723
+ pass: false,
724
+ actual: `${String(resolution.nodes.length)} matching nodes`
725
+ };
726
+ const node = resolution.node;
727
+ switch (check.name) {
728
+ case "toBeVisible": return {
729
+ pass: true,
730
+ actual: describeNode(node)
731
+ };
732
+ case "toHaveText": {
733
+ const text = node.name ?? node.value;
734
+ return {
735
+ pass: matchesText(check.expected, text),
736
+ actual: text === null ? null : `"${text}"`
737
+ };
738
+ }
739
+ case "toHaveValue": return {
740
+ pass: matchesText(check.expected, node.value),
741
+ actual: node.value === null ? null : `"${node.value}"`
742
+ };
743
+ case "toBeEnabled": return {
744
+ pass: node.enabled,
745
+ actual: node.enabled ? "enabled" : "disabled"
746
+ };
747
+ case "toBeSelected": return {
748
+ pass: node.selected,
749
+ actual: node.selected ? "selected" : "not selected"
750
+ };
751
+ case "toBeFocused": return {
752
+ pass: node.focused,
753
+ actual: node.focused ? "focused" : "not focused"
754
+ };
755
+ default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
756
+ }
757
+ }
758
+ function countOf(resolution) {
759
+ switch (resolution.outcome) {
760
+ case "one": return 1;
761
+ case "none": return 0;
762
+ case "many": return resolution.nodes.length;
763
+ default: throw new Error(`unhandled resolution ${JSON.stringify(resolution)}`);
764
+ }
765
+ }
766
+ /** The `Expected:` line. */
767
+ function describeCheck(check) {
768
+ switch (check.name) {
769
+ case "toBeVisible": return "visible";
770
+ case "toHaveText": return `text ${describeExpected(check.expected)}`;
771
+ case "toHaveValue": return `value ${describeExpected(check.expected)}`;
772
+ case "toBeEnabled": return "enabled";
773
+ case "toBeSelected": return "selected";
774
+ case "toBeFocused": return "focused";
775
+ case "toHaveCount": return `count ${String(check.expected)}`;
776
+ default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
777
+ }
778
+ }
779
+ function describeExpected(match) {
780
+ return match.kind === "regex" ? String(match.value) : `"${match.value}"`;
781
+ }
782
+ function describeNode(node) {
783
+ const name = node.name === null ? "" : ` "${node.name}"`;
784
+ return `${node.ref} [${node.role}]${name}`;
785
+ }
786
+ //#endregion
753
787
  //#region src/core/session.ts
754
788
  const READY_POLL_MS = 250;
755
789
  const SNAPSHOT_TIMEOUT_MS = 15e3;
@@ -1862,4 +1896,4 @@ function notBooted(platform, name, booted) {
1862
1896
  return `No booted ${platform} device is named '${name}'. Booted right now: ${booted.map((device) => `'${device.name}'`).join(", ")}. Set use.deviceName to one of those or boot '${name}'.`;
1863
1897
  }
1864
1898
  //#endregion
1865
- export { textMatch as A, describeCheck as C, parseDeviceOptions as D, deviceNameForSlot as E, describeQuery as O, resolve as S, TOUCHPRESS_DEFAULTS as T, sleep as _, sizeOf as a, parseScreen as b, createClient as c, createScrollSearch as d, directionToward as f, sessionName as g, openSession as h, relativeTo as i, TouchpressError as j, normalizeText as k, captureEvidence as l, probe as m, compareScreenshot as n, toPixelBox as o, formatFailure as p, cropScreenshot as r, createAgentDeviceDriver as s, preflight as t, createDevice as u, renderTitle as v, evaluate as w, renderScreen as x, silentSink as y };
1899
+ export { textMatch as A, renderScreen as C, parseDeviceOptions as D, deviceNameForSlot as E, describeQuery as O, parseScreen as S, TOUCHPRESS_DEFAULTS as T, sleep as _, sizeOf as a, renderTitle as b, createClient as c, createScrollSearch as d, directionToward as f, sessionName as g, openSession as h, relativeTo as i, TouchpressError as j, normalizeText as k, captureEvidence as l, probe as m, compareScreenshot as n, toPixelBox as o, formatFailure as p, cropScreenshot as r, createAgentDeviceDriver as s, preflight as t, createDevice as u, describeCheck as v, resolve as w, silentSink as x, evaluate as y };
@@ -448,6 +448,18 @@ type ActionRecord = {
448
448
  } | {
449
449
  readonly kind: 'screenshot';
450
450
  readonly path: string;
451
+ } | {
452
+ readonly kind: 'act';
453
+ readonly instruction: string;
454
+ } | {
455
+ readonly kind: 'extract';
456
+ readonly question: string;
457
+ } |
458
+ /** One command the model ran inside an `act`. `target` is its snapshot ref, or a scroll's direction. */
459
+ {
460
+ readonly kind: 'tool';
461
+ readonly name: string;
462
+ readonly target?: string;
451
463
  };
452
464
  /** A boxed step reports as one line rather than as something to open. */
453
465
  type StepOptions = {
@@ -825,6 +837,26 @@ type ErrorInfo = {
825
837
  readonly kind: 'driver';
826
838
  readonly command: string;
827
839
  readonly failure: DeviceFailure;
840
+ } | {
841
+ readonly kind: 'ai-not-configured';
842
+ } | {
843
+ readonly kind: 'ai-missing-peer';
844
+ } | {
845
+ readonly kind: 'ai-blocked';
846
+ readonly instruction: string;
847
+ /** The model's own account of what stopped it. */
848
+ readonly summary: string;
849
+ readonly screen: string;
850
+ } | {
851
+ readonly kind: 'ai-incomplete';
852
+ readonly instruction: string;
853
+ readonly steps: number;
854
+ readonly screen: string;
855
+ } | {
856
+ readonly kind: 'ai-timeout';
857
+ readonly instruction: string;
858
+ readonly timeoutMs: number;
859
+ readonly screen: string;
828
860
  };
829
861
  declare class TouchpressError extends Error {
830
862
  readonly info: ErrorInfo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "touchpress",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "description": "End-to-end testing for mobile apps.",
5
5
  "homepage": "https://github.com/wobsoriano/touchpress#readme",
6
6
  "bugs": {
@@ -33,13 +33,21 @@
33
33
  "@playwright/test": "1.63.0",
34
34
  "@types/node": "^26.1.1",
35
35
  "@types/pngjs": "^6.0.5",
36
+ "ai": "^7.0.93",
36
37
  "bumpp": "^11.1.0",
37
38
  "typescript": "^7.0.2",
38
39
  "vite": "npm:@voidzero-dev/vite-plus-core@0.3.0",
39
- "vite-plus": "0.3.0"
40
+ "vite-plus": "0.3.0",
41
+ "zod": "^4.5.4"
40
42
  },
41
43
  "peerDependencies": {
42
- "@playwright/test": ">=1.63"
44
+ "@playwright/test": ">=1.63",
45
+ "ai": "^6.0.0 || ^7.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "ai": {
49
+ "optional": true
50
+ }
43
51
  },
44
52
  "devEngines": {
45
53
  "packageManager": {