touchpress 0.0.1 → 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 +33 -19
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +2 -2
- package/dist/index.d.mts +51 -7
- package/dist/index.mjs +509 -12
- package/dist/{preflight-C83jCNPs.mjs → preflight-B7KrbqSO.mjs} +146 -112
- package/dist/{preflight--Z-REtl-.d.mts → preflight-Czhj9QFM.d.mts} +40 -8
- package/package.json +14 -6
package/dist/index.mjs
CHANGED
|
@@ -1,12 +1,505 @@
|
|
|
1
|
-
import { A as textMatch, D as parseDeviceOptions, S as
|
|
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;
|
|
8
501
|
/**
|
|
9
|
-
*
|
|
502
|
+
* Touchpress's options and none of its fixtures, for a setup project that reads the
|
|
10
503
|
* configuration before any session exists, such as one calling `preflight`.
|
|
11
504
|
*
|
|
12
505
|
* `platform`, `app`, and `readyWhen` default to `undefined` rather than to a
|
|
@@ -35,31 +528,35 @@ const setupTest = test$1.extend({
|
|
|
35
528
|
option: true,
|
|
36
529
|
scope: "worker"
|
|
37
530
|
}],
|
|
38
|
-
relaunch: [
|
|
531
|
+
relaunch: [TOUCHPRESS_DEFAULTS.relaunch, {
|
|
532
|
+
option: true,
|
|
533
|
+
scope: "worker"
|
|
534
|
+
}],
|
|
535
|
+
onDeviceInUse: [TOUCHPRESS_DEFAULTS.onDeviceInUse, {
|
|
39
536
|
option: true,
|
|
40
537
|
scope: "worker"
|
|
41
538
|
}],
|
|
42
|
-
|
|
539
|
+
settleQuietMs: [TOUCHPRESS_DEFAULTS.settleQuietMs, {
|
|
43
540
|
option: true,
|
|
44
541
|
scope: "worker"
|
|
45
542
|
}],
|
|
46
|
-
|
|
543
|
+
launchTimeout: [TOUCHPRESS_DEFAULTS.launchTimeout, {
|
|
47
544
|
option: true,
|
|
48
545
|
scope: "worker"
|
|
49
546
|
}],
|
|
50
|
-
|
|
547
|
+
dismissDevOverlay: [TOUCHPRESS_DEFAULTS.dismissDevOverlay, {
|
|
51
548
|
option: true,
|
|
52
549
|
scope: "worker"
|
|
53
550
|
}],
|
|
54
|
-
|
|
551
|
+
evidence: [TOUCHPRESS_DEFAULTS.evidence, {
|
|
55
552
|
option: true,
|
|
56
553
|
scope: "worker"
|
|
57
554
|
}],
|
|
58
|
-
|
|
555
|
+
sessionPrefix: [TOUCHPRESS_DEFAULTS.sessionPrefix, {
|
|
59
556
|
option: true,
|
|
60
557
|
scope: "worker"
|
|
61
558
|
}],
|
|
62
|
-
|
|
559
|
+
aiModel: [void 0, {
|
|
63
560
|
option: true,
|
|
64
561
|
scope: "worker"
|
|
65
562
|
}]
|
|
@@ -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,
|
|
@@ -436,4 +933,4 @@ const expect = expect$1.extend({
|
|
|
436
933
|
}
|
|
437
934
|
});
|
|
438
935
|
//#endregion
|
|
439
|
-
export {
|
|
936
|
+
export { TouchpressError, expect, preflight, setupTest, test };
|