tapline-host 1.1.0 → 1.3.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/cli.js +9 -6
- package/dist/cli.js.map +1 -1
- package/dist/crash.d.ts +24 -0
- package/dist/crash.js +91 -0
- package/dist/crash.js.map +1 -0
- package/dist/format.d.ts +20 -1
- package/dist/format.js +55 -5
- package/dist/format.js.map +1 -1
- package/dist/images.d.ts +7 -0
- package/dist/images.js +10 -0
- package/dist/images.js.map +1 -1
- package/dist/mcp.js +12 -1
- package/dist/mcp.js.map +1 -1
- package/dist/perf.d.ts +42 -0
- package/dist/perf.js +95 -0
- package/dist/perf.js.map +1 -0
- package/dist/protocol.d.ts +13 -1
- package/dist/protocol.js +11 -0
- package/dist/protocol.js.map +1 -1
- package/dist/run.d.ts +13 -1
- package/dist/run.js +26 -5
- package/dist/run.js.map +1 -1
- package/dist/script.d.ts +14 -0
- package/dist/script.js +62 -3
- package/dist/script.js.map +1 -1
- package/dist/server.d.ts +64 -0
- package/dist/server.js +145 -5
- package/dist/server.js.map +1 -1
- package/dist/tool-text-data.d.ts +6 -4
- package/dist/tool-text-data.js +6 -4
- package/dist/tool-text-data.js.map +1 -1
- package/dist/tools.d.ts +132 -1
- package/dist/tools.js +960 -89
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/tapline/SKILL.md +4 -3
package/dist/tools.js
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
3
4
|
import { toBuffer as qrPng } from "qrcode";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { annotateScreenshot } from "./annotate.js";
|
|
6
7
|
import { agentKind, captionFor, personKind, reasonOf } from "./captions.js";
|
|
7
|
-
import { composeFilmstrip, diffImages } from "./images.js";
|
|
8
|
-
import { alertNotes, describeElement, formatChanges, formatElement, formatLogs, formatOffScreenAfterList, formatTree } from "./format.js";
|
|
8
|
+
import { composeFilmstrip, diffImages, imageSize } from "./images.js";
|
|
9
|
+
import { alertNotes, describeElement, filterLogs, formatChanges, formatElement, formatLogAnswer, formatLogs, formatOffScreenAfterList, formatTree } from "./format.js";
|
|
9
10
|
import { formatInspect, inspectResultSchema } from "./inspect.js";
|
|
10
11
|
import { AUDIT_TYPES, auditResultSchema, auditTitle, formatAudit } from "./audit.js";
|
|
12
|
+
import { NO_CRASH_TEXT, crashNote, disconnectNote, formatCrash } from "./crash.js";
|
|
11
13
|
import { ActionLog, loggedTarget } from "./actionlog.js";
|
|
12
14
|
import { Confirmations } from "./confirm.js";
|
|
13
15
|
import { DEFAULT_POLICY, elementKey, listsFor, matchWord } from "./policy.js";
|
|
14
16
|
import { readScript, refStep, scriptSteps } from "./script.js";
|
|
15
|
-
import {
|
|
17
|
+
import { badUnderKeys, checkUnder, formatPerfLines, perfFirstLine, perfResultSchema } from "./perf.js";
|
|
18
|
+
import { EXPECT_TIMEOUT_S, MAX_STEPS, RUN_TIMEOUT_MS, RUN_TOOLS, UNDER_KEYS, describeRunStep, expectFailure, parseError, stepNotes, stepSchema, } from "./run.js";
|
|
16
19
|
import { PAIR_CODE_TTL_MS, forgetLink, pairingLink } from "./pairing.js";
|
|
17
20
|
import { elementSchema, logEntrySchema, screenshotResultSchema, treeResultSchema, } from "./protocol.js";
|
|
18
|
-
import { TaplineError } from "./server.js";
|
|
21
|
+
import { TaplineError, identityOf } from "./server.js";
|
|
19
22
|
// ---- input schemas ----------------------------------------------------------
|
|
20
23
|
export const targetSchema = z
|
|
21
24
|
.object({
|
|
@@ -47,6 +50,15 @@ export const targetSchema = z
|
|
|
47
50
|
ctx.addIssue({ code: "custom", message: "index works with id, label or text." });
|
|
48
51
|
}
|
|
49
52
|
});
|
|
53
|
+
const rectSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
x: z.number().describe("Points"),
|
|
56
|
+
y: z.number().describe("Points"),
|
|
57
|
+
width: z.number().positive().describe("Points"),
|
|
58
|
+
height: z.number().positive().describe("Points"),
|
|
59
|
+
})
|
|
60
|
+
.strict();
|
|
61
|
+
const ignoreListSchema = z.array(z.union([rectSchema, targetSchema]));
|
|
50
62
|
const targetField = targetSchema.describe("Exactly one of ref, id, label, text or x and y");
|
|
51
63
|
const lookField = z
|
|
52
64
|
.boolean()
|
|
@@ -175,6 +187,16 @@ export const toolShapes = {
|
|
|
175
187
|
.max(1320)
|
|
176
188
|
.optional()
|
|
177
189
|
.describe("Screenshot width in pixels, 160 to 1320 (default 600); keep it the same for a name"),
|
|
190
|
+
threshold: z
|
|
191
|
+
.number()
|
|
192
|
+
.min(0)
|
|
193
|
+
.max(100)
|
|
194
|
+
.optional()
|
|
195
|
+
.describe("The share of the screen, in percent, 0 to 100 (default 0.5), that may change and still match"),
|
|
196
|
+
ignore: z
|
|
197
|
+
.array(z.union([rectSchema, targetSchema]))
|
|
198
|
+
.optional()
|
|
199
|
+
.describe("Painted out on both pictures before comparing (a clock, an avatar): targets {id}, {label}, {text}, {ref}, or a rect {x, y, width, height} in points"),
|
|
178
200
|
},
|
|
179
201
|
filmstrip: {
|
|
180
202
|
frames: z.number().int().min(2).max(9).optional().describe("How many frames (default 6)"),
|
|
@@ -211,15 +233,29 @@ export const toolShapes = {
|
|
|
211
233
|
},
|
|
212
234
|
logs: {
|
|
213
235
|
since: z.number().optional().describe("Only lines after this timestamp (epoch milliseconds)"),
|
|
236
|
+
level: z.enum(["error", "warning", "info"]).optional().describe("That level and above (default info: everything)"),
|
|
237
|
+
grep: z.string().optional().describe("Only lines containing this text (case-insensitive)"),
|
|
238
|
+
limit: z.number().int().min(1).max(500).optional().describe("At most this many lines, the newest (1 to 500, default 100)"),
|
|
239
|
+
},
|
|
240
|
+
crash: {
|
|
241
|
+
all: z.boolean().optional().describe("The last 5 crashes instead of only the newest"),
|
|
214
242
|
},
|
|
215
243
|
reload: { look: lookField },
|
|
244
|
+
performance: {
|
|
245
|
+
launch: z
|
|
246
|
+
.boolean()
|
|
247
|
+
.optional()
|
|
248
|
+
.describe("true: reload the app's JavaScript and time it to the first settled screen (development builds only; not a cold start)"),
|
|
249
|
+
seconds: z.number().min(0.5).max(30).optional().describe("Measure for this long, 0.5 to 30 seconds (default 3)"),
|
|
250
|
+
tap: targetField.optional().describe("Tap this first, then measure what it sets off"),
|
|
251
|
+
},
|
|
216
252
|
wait: { seconds: z.number().min(0.1).max(30).describe("How long to wait, 0.1 to 30 seconds"), screenshot: screenshotField },
|
|
217
253
|
run: {
|
|
218
254
|
steps: z
|
|
219
255
|
.array(stepSchema)
|
|
220
256
|
.min(1)
|
|
221
257
|
.max(MAX_STEPS)
|
|
222
|
-
.describe('The steps, in order: each is a tool call as an object, {"tool": "tap", "target": {...}, "expect": {...}}. Tools: tap, long_press, type_text, scroll, toggle, back, swipe, open_url, wait, wait_for, app_command. expect (optional): one of text, id or label, with gone, value and timeout (seconds, default 2).'),
|
|
258
|
+
.describe('The steps, in order: each is a tool call as an object, {"tool": "tap", "target": {...}, "expect": {...}}. Tools: tap, long_press, type_text, scroll, toggle, back, swipe, open_url, wait, wait_for, app_command, compare_screen (checks, never saves), performance. expect (optional): one of text, id or label, with gone, value and timeout (seconds, default 2); or matches (a reference name, alone or with one of them) with threshold; on a performance step, under: limits such as {"launch_s": 2, "drops": 0, "fps_min": 50, "busy_ms": 200, "js_heap_mb": 80}.'),
|
|
223
259
|
trace: z.boolean().optional().describe("Also list each step that ran, one line each"),
|
|
224
260
|
dry_run: z
|
|
225
261
|
.boolean()
|
|
@@ -229,6 +265,78 @@ export const toolShapes = {
|
|
|
229
265
|
screenshot: screenshotField,
|
|
230
266
|
},
|
|
231
267
|
};
|
|
268
|
+
// ---- compare_screen ---------------------------------------------------------
|
|
269
|
+
const DEFAULT_MATCH_THRESHOLD = 0.5;
|
|
270
|
+
const MAX_REGION_LINES = 8;
|
|
271
|
+
function describeIgnore(item) {
|
|
272
|
+
if (item.id !== undefined)
|
|
273
|
+
return `id=${item.id}`;
|
|
274
|
+
if (item.label !== undefined)
|
|
275
|
+
return `label ${JSON.stringify(item.label)}`;
|
|
276
|
+
if (item.text !== undefined)
|
|
277
|
+
return JSON.stringify(item.text);
|
|
278
|
+
if (item.ref !== undefined)
|
|
279
|
+
return item.ref;
|
|
280
|
+
return `${String(item.x)},${String(item.y)}`;
|
|
281
|
+
}
|
|
282
|
+
/** The pixel rectangles to paint out for `ignore`, from a fresh element list; targets not on the screen give a note. */
|
|
283
|
+
/**
|
|
284
|
+
* An element as a {label} target matches it: its accessibility label, else its text, as the app's SDK matches
|
|
285
|
+
* (labelOf in the SDK). The ignore masks, recorded indexes and expectations use the same rule as the targets.
|
|
286
|
+
*/
|
|
287
|
+
function labelOfElement(e) {
|
|
288
|
+
return e.label ?? e.text ?? "";
|
|
289
|
+
}
|
|
290
|
+
function ignoreRects(ignore, elements, scale, size) {
|
|
291
|
+
const rects = [];
|
|
292
|
+
const notes = [];
|
|
293
|
+
const add = (x, y, width, height) => {
|
|
294
|
+
const x0 = Math.max(0, Math.floor(x * scale));
|
|
295
|
+
const y0 = Math.max(0, Math.floor(y * scale));
|
|
296
|
+
const x1 = Math.min(size.width, Math.ceil((x + width) * scale));
|
|
297
|
+
const y1 = Math.min(size.height, Math.ceil((y + height) * scale));
|
|
298
|
+
if (x1 > x0 && y1 > y0)
|
|
299
|
+
rects.push({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
|
|
300
|
+
};
|
|
301
|
+
for (const item of ignore) {
|
|
302
|
+
if ("width" in item) {
|
|
303
|
+
add(item.x, item.y, item.width, item.height);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
let found = [];
|
|
307
|
+
if (item.ref !== undefined)
|
|
308
|
+
found = elements.filter((e) => e.ref === item.ref);
|
|
309
|
+
else if (item.id !== undefined)
|
|
310
|
+
found = elements.filter((e) => e.testID === item.id);
|
|
311
|
+
else if (item.label !== undefined) {
|
|
312
|
+
const wanted = item.label.toLowerCase();
|
|
313
|
+
found = elements.filter((e) => labelOfElement(e).toLowerCase() === wanted);
|
|
314
|
+
}
|
|
315
|
+
else if (item.text !== undefined) {
|
|
316
|
+
const wanted = item.text.toLowerCase();
|
|
317
|
+
found = elements.filter((e) => (e.text ?? "").toLowerCase() === wanted);
|
|
318
|
+
if (found.length === 0)
|
|
319
|
+
found = elements.filter((e) => wanted !== "" && (e.text ?? "").toLowerCase().includes(wanted));
|
|
320
|
+
}
|
|
321
|
+
else if (item.x !== undefined && item.y !== undefined) {
|
|
322
|
+
const px = item.x;
|
|
323
|
+
const py = item.y;
|
|
324
|
+
found = elements
|
|
325
|
+
.filter((e) => e.frame && px >= e.frame.x && px <= e.frame.x + e.frame.width && py >= e.frame.y && py <= e.frame.y + e.frame.height)
|
|
326
|
+
.sort((a, b) => (a.frame?.width ?? 0) * (a.frame?.height ?? 0) - (b.frame?.width ?? 0) * (b.frame?.height ?? 0))
|
|
327
|
+
.slice(0, 1);
|
|
328
|
+
}
|
|
329
|
+
found = found.filter((e) => e.frame !== undefined);
|
|
330
|
+
if (item.index !== undefined)
|
|
331
|
+
found = found.slice(item.index, item.index + 1);
|
|
332
|
+
if (found.length === 0)
|
|
333
|
+
notes.push(`ignore ${describeIgnore(item)} isn't on the screen; skipped.`);
|
|
334
|
+
for (const e of found)
|
|
335
|
+
if (e.frame)
|
|
336
|
+
add(e.frame.x, e.frame.y, e.frame.width, e.frame.height);
|
|
337
|
+
}
|
|
338
|
+
return { rects, notes };
|
|
339
|
+
}
|
|
232
340
|
/**
|
|
233
341
|
* Under this many points from its first point, a pointer path is a tap or a hold, not a drag.
|
|
234
342
|
* Tapwright uses the same number, and the viewer page's pending ring (TAP_SLOP in viewer.html)
|
|
@@ -238,6 +346,8 @@ const TAP_SLOP = 10;
|
|
|
238
346
|
/** Held at least this long, a tap is a long press. */
|
|
239
347
|
const LONG_PRESS_MS = 500;
|
|
240
348
|
const SCRIPT_NAME = /^[A-Za-z0-9][\w.-]{0,60}$/;
|
|
349
|
+
/** The answer to a reference name that isn't one: no slashes, so no way out of the references folder. */
|
|
350
|
+
const BAD_REFERENCE_NAME = "Error bad_params: name the reference with letters, digits, '.', '_' and '-'.";
|
|
241
351
|
const scriptSchema = z.object({
|
|
242
352
|
version: z.literal(1),
|
|
243
353
|
name: z.string(),
|
|
@@ -407,6 +517,18 @@ function toWireTarget(target) {
|
|
|
407
517
|
return withIndex({ text: target.text });
|
|
408
518
|
return { x: target.x ?? 0, y: target.y ?? 0 };
|
|
409
519
|
}
|
|
520
|
+
/** A target as the agent wrote it, in words: `id=save`, `"Save"`, `e3`, `120,300`. */
|
|
521
|
+
function targetWords(target) {
|
|
522
|
+
if (target.id !== undefined)
|
|
523
|
+
return `id=${target.id}`;
|
|
524
|
+
if (target.text !== undefined)
|
|
525
|
+
return JSON.stringify(target.text);
|
|
526
|
+
if (target.label !== undefined)
|
|
527
|
+
return JSON.stringify(target.label);
|
|
528
|
+
if (target.ref !== undefined)
|
|
529
|
+
return target.ref;
|
|
530
|
+
return `${target.x ?? 0},${target.y ?? 0}`;
|
|
531
|
+
}
|
|
410
532
|
function candidateLines(details) {
|
|
411
533
|
const lines = [];
|
|
412
534
|
const bareRefs = [];
|
|
@@ -529,6 +651,12 @@ function candidatesFor(target, elements) {
|
|
|
529
651
|
return picked !== undefined ? [picked] : found;
|
|
530
652
|
}
|
|
531
653
|
// ---- tool handlers ----------------------------------------------------------
|
|
654
|
+
/** Every tool whose answer can end with a crash note (all but `crash`, which tells it in full). */
|
|
655
|
+
const CRASH_NOTED_TOOLS = [
|
|
656
|
+
"apps", "openApp", "pair", "look", "run", "inspect", "audit", "checkLayout", "viewer", "compareScreen", "filmstrip",
|
|
657
|
+
"record", "replay", "tap", "longPress", "typeText", "scroll", "toggle", "appCommand", "waitFor", "swipe", "back",
|
|
658
|
+
"openUrl", "logs", "reload", "performance", "wait",
|
|
659
|
+
];
|
|
532
660
|
/** The tool handlers behind the MCP server. They only talk to a TaplineHost, so they are easy to test. */
|
|
533
661
|
export class TaplineTools {
|
|
534
662
|
host;
|
|
@@ -561,10 +689,27 @@ export class TaplineTools {
|
|
|
561
689
|
personNotePending = false;
|
|
562
690
|
recording = null;
|
|
563
691
|
replaying = false;
|
|
692
|
+
/**
|
|
693
|
+
* While a script replays (AGENT-TOOLS 1.9 §14.3): the references folder next to it (looked in first), where a
|
|
694
|
+
* failed check puts its red picture, and whether the checks save instead of compare (`--update-references`).
|
|
695
|
+
*/
|
|
696
|
+
replayContext = null;
|
|
697
|
+
/** Where the latest failed check of the running batch saved its picture. */
|
|
698
|
+
diffSaved = null;
|
|
699
|
+
/** What the last `performance` call measured, for the `under` limits of a `run` step. */
|
|
700
|
+
lastPerf;
|
|
701
|
+
/** The elements the latest comparison painted its ignore list from: a recording saves its mask from the same list. */
|
|
702
|
+
lastMaskElements;
|
|
564
703
|
/** The screen size in points from the latest list or overlay, for the live viewer's 0..1 positions. */
|
|
565
704
|
lastScreen;
|
|
566
705
|
shotCount = 0;
|
|
567
706
|
pairWrites = 0;
|
|
707
|
+
/** Per app (by identity): crash events up to this one have been told to the agent. */
|
|
708
|
+
notedCrashSeqs = new Map();
|
|
709
|
+
/** Crash events up to this one happened before this agent's session: an app with no entry above starts here. */
|
|
710
|
+
crashBaseline;
|
|
711
|
+
/** Set inside a tool call (and everything it awaits), so a call within a call (`run`'s steps) adds no notes. */
|
|
712
|
+
noteScope = new AsyncLocalStorage();
|
|
568
713
|
constructor(host, options) {
|
|
569
714
|
this.host = host;
|
|
570
715
|
this.options = options;
|
|
@@ -580,6 +725,70 @@ export class TaplineTools {
|
|
|
580
725
|
if (this.host.current()?.id === session.id)
|
|
581
726
|
this.markPersonUsedApp();
|
|
582
727
|
});
|
|
728
|
+
// Crashes before this agent's session began are not its news.
|
|
729
|
+
this.crashBaseline = host.crashMark();
|
|
730
|
+
for (const name of CRASH_NOTED_TOOLS) {
|
|
731
|
+
const original = Reflect.get(this, name);
|
|
732
|
+
if (typeof original !== "function")
|
|
733
|
+
continue;
|
|
734
|
+
const method = original;
|
|
735
|
+
Reflect.set(this, name, (...args) => this.noting(() => method.apply(this, args)));
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Runs a tool call and ends its answer with a note when the app stopped (AGENT-TOOLS 15.2): during this
|
|
740
|
+
* call, or earlier, with its time, once. Calls inside a call (`run`'s steps) add nothing of their own.
|
|
741
|
+
*/
|
|
742
|
+
noting(run) {
|
|
743
|
+
// Nesting belongs to the call and what it awaits (not to this object), so calls running side by side each
|
|
744
|
+
// count as outermost, and only a step inside a call is skipped.
|
|
745
|
+
if (this.noteScope.getStore() === true)
|
|
746
|
+
return run();
|
|
747
|
+
const mark = this.host.crashMark();
|
|
748
|
+
const startedWith = this.host.current();
|
|
749
|
+
return this.noteScope.run(true, () => {
|
|
750
|
+
const result = run();
|
|
751
|
+
if (result instanceof Promise)
|
|
752
|
+
return result.then((answer) => this.withCrashNotes(answer, mark, startedWith));
|
|
753
|
+
return this.withCrashNotes(result, mark, startedWith);
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
/** What was told about this app's crashes so far: the crash events up to this number. */
|
|
757
|
+
notedSeqOf(app) {
|
|
758
|
+
return this.notedCrashSeqs.get(identityOf(app)) ?? this.crashBaseline;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* The notes for the app this call used (the one at its start, the one at its end, or, with none connected, the
|
|
762
|
+
* one that dropped last). Other apps' events stay pending until that app is the one being driven.
|
|
763
|
+
*/
|
|
764
|
+
withCrashNotes(answer, mark, startedWith) {
|
|
765
|
+
const apps = new Map();
|
|
766
|
+
const current = this.host.current();
|
|
767
|
+
// A call that started on an app answers for that app only: another app that became current meanwhile (the first
|
|
768
|
+
// one dropped) keeps its crashes pending until it is the one being driven (Codex review of 1.3.0).
|
|
769
|
+
const endedWith = startedWith === undefined ? (current ?? this.host.lastDisconnect()?.session) : current !== undefined && identityOf(current) === identityOf(startedWith) ? current : undefined;
|
|
770
|
+
for (const app of [startedWith, endedWith])
|
|
771
|
+
if (app !== undefined)
|
|
772
|
+
apps.set(identityOf(app), app);
|
|
773
|
+
const events = [...apps.values()]
|
|
774
|
+
.flatMap((app) => this.host.crashEventsAfter(this.notedSeqOf(app), app))
|
|
775
|
+
.sort((a, b) => a.seq - b.seq);
|
|
776
|
+
if (events.length === 0)
|
|
777
|
+
return answer;
|
|
778
|
+
const told = this.host.crashMark();
|
|
779
|
+
for (const key of apps.keys())
|
|
780
|
+
this.notedCrashSeqs.set(key, told);
|
|
781
|
+
let noted = answer;
|
|
782
|
+
for (const event of events) {
|
|
783
|
+
// During this call: no time. A crash the phone had kept and sent later says when it happened.
|
|
784
|
+
const during = event.seq > mark;
|
|
785
|
+
// A crash kept by an earlier run of the app (this host had not started yet) says so, with its date.
|
|
786
|
+
const earlier = event.kind === "crash" && event.crash.source === "saved" && event.crash.ts < this.host.startedAt;
|
|
787
|
+
noted = appendNote(noted, event.kind === "crash"
|
|
788
|
+
? crashNote(event.crash, !(during && event.crash.source === "live"), earlier)
|
|
789
|
+
: disconnectNote(during ? undefined : event.at));
|
|
790
|
+
}
|
|
791
|
+
return noted;
|
|
583
792
|
}
|
|
584
793
|
/** A person used the app: the next answer says so once, and `{ref}` targets are stale until the next full list. */
|
|
585
794
|
markPersonUsedApp() {
|
|
@@ -796,11 +1005,55 @@ export class TaplineTools {
|
|
|
796
1005
|
}
|
|
797
1006
|
const shape = toolShapes[step.tool];
|
|
798
1007
|
const { tool: _tool, expect: _expect, route: _route, ...params } = step;
|
|
1008
|
+
if (step.tool === "compare_screen" && params.update !== undefined) {
|
|
1009
|
+
return fail(`Error bad_params: step ${i + 1}: update can't be used in run (a step never saves a reference)`);
|
|
1010
|
+
}
|
|
1011
|
+
const under = step.expect?.under;
|
|
1012
|
+
if (under !== undefined) {
|
|
1013
|
+
if (step.tool !== "performance") {
|
|
1014
|
+
return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): under goes only on a performance step.`);
|
|
1015
|
+
}
|
|
1016
|
+
const unknown = badUnderKeys(under);
|
|
1017
|
+
if (unknown.length > 0) {
|
|
1018
|
+
return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): under has ${unknown.map((key) => JSON.stringify(key)).join(", ")}; the limits are ${UNDER_KEYS.join(", ")}.`);
|
|
1019
|
+
}
|
|
1020
|
+
if (Object.keys(under).length === 0) {
|
|
1021
|
+
return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): under is empty; give at least one limit (${UNDER_KEYS.join(", ")}).`);
|
|
1022
|
+
}
|
|
1023
|
+
const negative = Object.entries(under).filter(([, limit]) => limit < 0 || !Number.isFinite(limit));
|
|
1024
|
+
if (negative.length > 0) {
|
|
1025
|
+
return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): under limits can't be negative (${negative.map(([key]) => key).join(", ")}).`);
|
|
1026
|
+
}
|
|
1027
|
+
if (under["launch_s"] !== undefined && step.launch !== true) {
|
|
1028
|
+
return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): launch_s goes only on a launch step (performance with launch: true).`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
// A step's own look and screenshot are ignored (§9.1).
|
|
1032
|
+
if (step.tool === "performance") {
|
|
1033
|
+
delete params["look"];
|
|
1034
|
+
delete params["screenshot"];
|
|
1035
|
+
// Checked before any step runs (and in a dry run), not when this step's turn comes.
|
|
1036
|
+
if (params["launch"] === true && params["tap"] !== undefined) {
|
|
1037
|
+
return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): launch reloads the app and tap taps in it; give one of them.`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
799
1040
|
const parsed = z.object(shape).strict().safeParse(params);
|
|
800
1041
|
if (!parsed.success) {
|
|
801
1042
|
return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "params"}: ${issue.message}`).join("; ")}`);
|
|
802
1043
|
}
|
|
1044
|
+
// Every reference name is checked before any step runs: a name with a path in it never reaches the disk.
|
|
1045
|
+
const names = [step.tool === "compare_screen" ? params.name : undefined, step.expect?.matches];
|
|
1046
|
+
if (names.some((name) => typeof name === "string" && !SCRIPT_NAME.test(name.trim()))) {
|
|
1047
|
+
return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): name the reference with letters, digits, '.', '_' and '-'.`);
|
|
1048
|
+
}
|
|
1049
|
+
if (step.expect?.ignore !== undefined) {
|
|
1050
|
+
const ignored = ignoreListSchema.safeParse(step.expect.ignore);
|
|
1051
|
+
if (!ignored.success) {
|
|
1052
|
+
return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): expect.ignore: ${ignored.error.issues.map((issue) => issue.message).join("; ")}`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
803
1055
|
}
|
|
1056
|
+
this.diffSaved = null;
|
|
804
1057
|
if (args.dry_run === true)
|
|
805
1058
|
return this.dryRunBatch(steps);
|
|
806
1059
|
const before = this.unlistedActions.get(session.id) ?? 0;
|
|
@@ -810,12 +1063,15 @@ export class TaplineTools {
|
|
|
810
1063
|
const trace = [];
|
|
811
1064
|
let ran = 0;
|
|
812
1065
|
let stop = null;
|
|
1066
|
+
// The newest log time when the current step began: a failed step shows the app's warnings since then (§15.4).
|
|
1067
|
+
let stepMark;
|
|
813
1068
|
for (const [i, step] of steps.entries()) {
|
|
814
1069
|
if (Date.now() - started > RUN_TIMEOUT_MS) {
|
|
815
1070
|
stop = { index: i, code: "timeout", message: `the batch ran longer than ${RUN_TIMEOUT_MS / 1000} s`, rest: [] };
|
|
816
1071
|
break;
|
|
817
1072
|
}
|
|
818
1073
|
const at = Date.now();
|
|
1074
|
+
stepMark = this.newestLogTs(session.id);
|
|
819
1075
|
this.batch = { id: batchId, step: i + 1 };
|
|
820
1076
|
const answer = await this.runStep(step).finally(() => {
|
|
821
1077
|
this.batch = undefined;
|
|
@@ -826,12 +1082,40 @@ export class TaplineTools {
|
|
|
826
1082
|
break;
|
|
827
1083
|
}
|
|
828
1084
|
notes.push(...stepNotes(said, i + 1));
|
|
829
|
-
if (step.expect !== undefined) {
|
|
1085
|
+
if (step.expect?.under !== undefined) {
|
|
1086
|
+
// AGENT-TOOLS §15.3: the limits a performance step must keep; every broken one is listed.
|
|
1087
|
+
const measured = this.lastPerf;
|
|
1088
|
+
if (measured === undefined) {
|
|
1089
|
+
// A limit nobody measured must not pass.
|
|
1090
|
+
stop = { index: i, code: "expect_failed", message: "no measurement to check.", rest: [] };
|
|
1091
|
+
break;
|
|
1092
|
+
}
|
|
1093
|
+
const checked = checkUnder(step.expect.under, measured);
|
|
1094
|
+
notes.push(...checked.notes.map((note) => `Note (step ${i + 1}): ${note}`));
|
|
1095
|
+
if (checked.missed.length > 0) {
|
|
1096
|
+
stop = { index: i, code: "expect_failed", message: `${checked.missed.join("; ")}.`, rest: [] };
|
|
1097
|
+
break;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
else if (step.expect !== undefined) {
|
|
830
1101
|
const missed = await this.expectMissed(step.expect);
|
|
831
1102
|
if (missed !== null) {
|
|
832
1103
|
stop = { index: i, code: "expect_failed", message: `its action ran, but ${missed}`, rest: [] };
|
|
833
1104
|
break;
|
|
834
1105
|
}
|
|
1106
|
+
// A picture check that held is kept in a recording, as compare_screen's is: right after the action, the
|
|
1107
|
+
// script makes it that step's `matches` again (AGENT-TOOLS 1.9 §14.2).
|
|
1108
|
+
if (step.expect.matches !== undefined) {
|
|
1109
|
+
const recorded = await this.recordCompare({
|
|
1110
|
+
name: step.expect.matches,
|
|
1111
|
+
...(step.expect.threshold !== undefined ? { threshold: step.expect.threshold } : {}),
|
|
1112
|
+
...(step.expect.ignore !== undefined ? { ignore: ignoreListSchema.parse(step.expect.ignore) } : {}),
|
|
1113
|
+
});
|
|
1114
|
+
if (recorded !== null) {
|
|
1115
|
+
stop = { index: i, ...parseError(recorded) };
|
|
1116
|
+
break;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
835
1119
|
}
|
|
836
1120
|
const route = step.route;
|
|
837
1121
|
if (Array.isArray(route) && route.every((part) => typeof part === "string")) {
|
|
@@ -855,29 +1139,61 @@ export class TaplineTools {
|
|
|
855
1139
|
const head = stop === null
|
|
856
1140
|
? `Ran ${total} of ${total} steps in ${((Date.now() - started) / 1000).toFixed(1)} s.`
|
|
857
1141
|
: `Error ${stop.code}: Ran ${ran} of ${total} steps; stopped at step ${stop.index + 1} (${describeRunStep(steps[stop.index])}): ${stop.message}`;
|
|
858
|
-
const
|
|
1142
|
+
const diffNote = stop !== null && this.diffSaved !== null ? [`Note: the difference is saved at ${this.diffSaved}.`] : [];
|
|
1143
|
+
const first = [head, ...trace, ...(stop?.rest ?? []), ...diffNote].join("\n");
|
|
1144
|
+
const tail = [...(stop !== null ? this.failureLogs(session.id, stop.index + 1, stepMark) : []), ...notes];
|
|
859
1145
|
let response;
|
|
860
1146
|
const live = this.host.current();
|
|
861
1147
|
if (args.look === false || live === undefined) {
|
|
862
|
-
response = text([first, ...
|
|
1148
|
+
response = text([first, ...tail].join("\n"));
|
|
863
1149
|
}
|
|
864
1150
|
else {
|
|
865
1151
|
response = await this.withLook(live, first, args.screenshot === true);
|
|
866
|
-
if (
|
|
1152
|
+
if (tail.length > 0) {
|
|
867
1153
|
const [top, ...rest] = response.content;
|
|
868
1154
|
if (top !== undefined && top.type === "text")
|
|
869
|
-
response = { ...response, content: [{ type: "text", text: [top.text.trimEnd(), ...
|
|
1155
|
+
response = { ...response, content: [{ type: "text", text: [top.text.trimEnd(), ...tail].join("\n") }, ...rest] };
|
|
870
1156
|
}
|
|
871
1157
|
}
|
|
872
1158
|
return failed ? { ...response, isError: true } : response;
|
|
873
1159
|
}
|
|
1160
|
+
/** The newest log time of a session (or of the app's current session), undefined without lines. */
|
|
1161
|
+
newestLogTs(sessionId) {
|
|
1162
|
+
return this.sessionLogs(sessionId).reduce((max, e) => (e.ts !== undefined && (max === undefined || e.ts > max) ? e.ts : max), undefined);
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* A session's buffered lines, also when it has ended. When the same app on the same phone came back as a new
|
|
1166
|
+
* session, that session's lines follow. Another app's lines never stand in for it (Codex review of 1.3.0).
|
|
1167
|
+
*/
|
|
1168
|
+
sessionLogs(sessionId) {
|
|
1169
|
+
const record = this.host.sessionRecord(sessionId);
|
|
1170
|
+
if (record === undefined)
|
|
1171
|
+
return [];
|
|
1172
|
+
const now = this.host.current();
|
|
1173
|
+
if (now === undefined || now.id === sessionId || identityOf(now) !== record.identity)
|
|
1174
|
+
return record.events;
|
|
1175
|
+
try {
|
|
1176
|
+
return [...record.events, ...this.host.logs(now.id)];
|
|
1177
|
+
}
|
|
1178
|
+
catch {
|
|
1179
|
+
return record.events;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
/** AGENT-TOOLS §15.4: `Logs (since step <n>):` and up to 20 lines at warning or above since the step began. */
|
|
1183
|
+
failureLogs(sessionId, step, since) {
|
|
1184
|
+
const lines = filterLogs(this.sessionLogs(sessionId), { since, level: "warning", limit: 20 });
|
|
1185
|
+
return lines.length === 0 ? [] : [`Logs (since step ${step}):`, ...formatLogs(lines).split("\n")];
|
|
1186
|
+
}
|
|
874
1187
|
/** `run` with dry_run (§11.4): the steps are checked already; step 1 is resolved, nothing is touched. */
|
|
875
1188
|
async dryRunBatch(steps) {
|
|
876
1189
|
const total = steps.length;
|
|
877
1190
|
const first = steps[0];
|
|
878
1191
|
const head = `Would run ${total} step${total === 1 ? "" : "s"}`;
|
|
879
|
-
|
|
880
|
-
|
|
1192
|
+
// A performance step with a tap: the tap resolves like any touch; nothing is measured (AGENT-TOOLS §15.3).
|
|
1193
|
+
const perfTap = first.tool === "performance" ? first["tap"] : undefined;
|
|
1194
|
+
const probe = perfTap !== undefined ? { tool: "tap", target: perfTap } : first;
|
|
1195
|
+
if (["tap", "long_press", "toggle", "type_text", "scroll", "back", "swipe"].includes(probe.tool)) {
|
|
1196
|
+
const answer = await this.runStep({ ...probe, dry_run: true });
|
|
881
1197
|
const said = answer.content.map((block) => (block.type === "text" ? block.text : "")).join("\n").trim();
|
|
882
1198
|
const [line, ...rest] = said.split("\n");
|
|
883
1199
|
if (answer.isError === true) {
|
|
@@ -887,6 +1203,10 @@ export class TaplineTools {
|
|
|
887
1203
|
const would = (line ?? "").replace(/^Would /, "would ");
|
|
888
1204
|
return text([`${head}; step 1 ${would}`, ...rest].join("\n"));
|
|
889
1205
|
}
|
|
1206
|
+
if (first.tool === "performance") {
|
|
1207
|
+
const p = first;
|
|
1208
|
+
return text(`${head}; step 1 would ${p["launch"] === true ? "reload the app and time it to the first settled screen" : `measure ${Number(p["seconds"] ?? 3)} s`}.`);
|
|
1209
|
+
}
|
|
890
1210
|
return text(`${head}; step 1 would ${describeRunStep(first)}.`);
|
|
891
1211
|
}
|
|
892
1212
|
/** One step of `run`, as its own tool call without a list. */
|
|
@@ -916,6 +1236,10 @@ export class TaplineTools {
|
|
|
916
1236
|
return this.waitFor(quiet);
|
|
917
1237
|
case "app_command":
|
|
918
1238
|
return this.appCommand(quiet);
|
|
1239
|
+
case "compare_screen":
|
|
1240
|
+
return this.compareStep(rest);
|
|
1241
|
+
case "performance":
|
|
1242
|
+
return this.performance(rest);
|
|
919
1243
|
}
|
|
920
1244
|
}
|
|
921
1245
|
/** The screen's route names now (one cheap read), or null when the app has no router or didn't answer. */
|
|
@@ -928,6 +1252,15 @@ export class TaplineTools {
|
|
|
928
1252
|
}
|
|
929
1253
|
/** Null when the expectation holds within its time; else how it failed. */
|
|
930
1254
|
async expectMissed(e) {
|
|
1255
|
+
if (e.text !== undefined || e.id !== undefined || e.label !== undefined) {
|
|
1256
|
+
const missed = await this.textExpectMissed(e);
|
|
1257
|
+
if (missed !== null)
|
|
1258
|
+
return missed;
|
|
1259
|
+
}
|
|
1260
|
+
// A picture check comes last, once the rest held.
|
|
1261
|
+
return e.matches === undefined ? null : this.matchMissed(e.matches, e.threshold ?? DEFAULT_MATCH_THRESHOLD, ignoreListSchema.parse(e.ignore ?? []));
|
|
1262
|
+
}
|
|
1263
|
+
async textExpectMissed(e) {
|
|
931
1264
|
const seconds = e.timeout ?? EXPECT_TIMEOUT_S;
|
|
932
1265
|
const which = e.text !== undefined ? { text: e.text } : e.id !== undefined ? { id: e.id } : { label: e.label };
|
|
933
1266
|
const seen = await this.waitFor({ ...which, ...(e.gone === true ? { gone: true } : {}), timeout: Math.max(0.5, seconds), look: false });
|
|
@@ -944,7 +1277,7 @@ export class TaplineTools {
|
|
|
944
1277
|
for (;;) {
|
|
945
1278
|
const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
|
|
946
1279
|
const element = tree.success
|
|
947
|
-
? tree.data.elements.find((el) => (e.id !== undefined ? el.testID === e.id : (el
|
|
1280
|
+
? tree.data.elements.find((el) => (e.id !== undefined ? el.testID === e.id : labelOfElement(el).toLowerCase() === (e.label ?? "").toLowerCase()))
|
|
948
1281
|
: undefined;
|
|
949
1282
|
if (element !== undefined) {
|
|
950
1283
|
actual = typeof element.checked === "boolean" ? (element.checked ? "on" : "off") : typeof element.value === "string" ? element.value : undefined;
|
|
@@ -1270,7 +1603,7 @@ export class TaplineTools {
|
|
|
1270
1603
|
const same = tree.data.elements.filter((el) => typeof t.testID === "string"
|
|
1271
1604
|
? el.testID === t.testID
|
|
1272
1605
|
: typeof t.label === "string"
|
|
1273
|
-
? (el
|
|
1606
|
+
? labelOfElement(el).toLowerCase() === t.label.toLowerCase()
|
|
1274
1607
|
: typeof t.text === "string"
|
|
1275
1608
|
? el.text === t.text
|
|
1276
1609
|
: false);
|
|
@@ -1555,66 +1888,99 @@ export class TaplineTools {
|
|
|
1555
1888
|
else
|
|
1556
1889
|
content.push({ type: "image", data: image.data, mimeType: image.mime });
|
|
1557
1890
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
if (session === undefined)
|
|
1561
|
-
return this.notConnected();
|
|
1562
|
-
const name = args.name.trim();
|
|
1563
|
-
if (!SCRIPT_NAME.test(name))
|
|
1564
|
-
return fail("Error bad_params: name the reference with letters, digits, '.', '_' and '-'.");
|
|
1891
|
+
/** Where this app's reference pictures are kept, or an error answer. */
|
|
1892
|
+
referenceFolder(session) {
|
|
1565
1893
|
const dir = this.options.baselinesDir;
|
|
1566
1894
|
if (dir === undefined)
|
|
1567
1895
|
return fail("Error internal: no folder for reference pictures is configured.");
|
|
1568
|
-
|
|
1569
|
-
|
|
1896
|
+
return join(dir, (session.app.id ?? session.app.name).replace(/[^\w.-]/g, "_"));
|
|
1897
|
+
}
|
|
1898
|
+
/** The element list for naming regions and masking, read as a peek: the agent's refs and baseline must stay as they are. */
|
|
1899
|
+
async peekElements(sessionId) {
|
|
1900
|
+
try {
|
|
1901
|
+
const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId }));
|
|
1902
|
+
if (tree.success && tree.data.screen)
|
|
1903
|
+
return { scaleBase: tree.data.screen.width, elements: tree.data.elements };
|
|
1904
|
+
}
|
|
1905
|
+
catch {
|
|
1906
|
+
// positions stay in pixels
|
|
1907
|
+
}
|
|
1908
|
+
return { scaleBase: undefined, elements: [] };
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* Takes the screen and compares it with the saved reference called `name`, never saving. `referencesDirs` are the
|
|
1912
|
+
* folders to look in, in order. A missing reference hands back the screenshot, so the caller may save it.
|
|
1913
|
+
*/
|
|
1914
|
+
async checkAgainstReference(session, name, options) {
|
|
1570
1915
|
let shot;
|
|
1571
1916
|
try {
|
|
1572
|
-
shot = await this.takeShot(session.id,
|
|
1917
|
+
shot = await this.takeShot(session.id, options.width, 0.9);
|
|
1573
1918
|
}
|
|
1574
1919
|
catch (error) {
|
|
1575
|
-
return
|
|
1576
|
-
}
|
|
1577
|
-
if (args.update === true || !existsSync(path)) {
|
|
1578
|
-
try {
|
|
1579
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
1580
|
-
writeFileSync(path, Buffer.from(shot.data, "base64"), { mode: 0o600 });
|
|
1581
|
-
}
|
|
1582
|
-
catch (error) {
|
|
1583
|
-
return fail(`Error internal: couldn't save the reference: ${error instanceof Error ? error.message : String(error)}`);
|
|
1584
|
-
}
|
|
1585
|
-
return text(`Saved the reference picture "${name}" (${shot.width ?? "?"}x${shot.height ?? "?"}) in ${path}. Next time, compare_screen { name: "${name}" } shows what changed.`);
|
|
1920
|
+
return { kind: "error", line: describeError(error) };
|
|
1586
1921
|
}
|
|
1587
|
-
|
|
1922
|
+
this.lastMaskElements = undefined;
|
|
1923
|
+
if (!SCRIPT_NAME.test(name))
|
|
1924
|
+
return { kind: "error", line: BAD_REFERENCE_NAME };
|
|
1925
|
+
const path = options.referencesDirs.map((dir) => join(dir, `${name}.jpg`)).find((candidate) => existsSync(candidate));
|
|
1926
|
+
if (path === undefined)
|
|
1927
|
+
return { kind: "missing", shot };
|
|
1928
|
+
let reference;
|
|
1588
1929
|
try {
|
|
1589
|
-
|
|
1930
|
+
reference = { mime: "image/jpeg", data: readFileSync(path).toString("base64") };
|
|
1590
1931
|
}
|
|
1591
1932
|
catch (error) {
|
|
1592
|
-
|
|
1933
|
+
// A reference that can't be read (no permission, a folder in its place) is a failed check, never a crash.
|
|
1934
|
+
return { kind: "error", line: `Error bad_params: the reference "${name}" can't be read (${error instanceof Error ? error.message : String(error)}). To start over, use update: true.` };
|
|
1593
1935
|
}
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
// Name the regions in points, with the elements they touch, from a fresh element list.
|
|
1597
|
-
let scale = 1;
|
|
1598
|
-
let elements = [];
|
|
1936
|
+
let refSize;
|
|
1937
|
+
let shotSize;
|
|
1599
1938
|
try {
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1939
|
+
refSize = imageSize(reference);
|
|
1940
|
+
shotSize = imageSize(shot);
|
|
1941
|
+
}
|
|
1942
|
+
catch (error) {
|
|
1943
|
+
return { kind: "error", line: `Error bad_params: ${error instanceof Error ? error.message : String(error)} To start over, use update: true.` };
|
|
1944
|
+
}
|
|
1945
|
+
if (refSize.width !== shotSize.width || refSize.height !== shotSize.height) {
|
|
1946
|
+
return {
|
|
1947
|
+
kind: "size",
|
|
1948
|
+
message: `the reference "${name}" is ${refSize.width}x${refSize.height} px and this screen is ${shotSize.width}x${shotSize.height}; take it at the same width, or update: true.`,
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
let seen;
|
|
1952
|
+
const notes = [];
|
|
1953
|
+
const mask = [];
|
|
1954
|
+
if (options.ignore.length > 0) {
|
|
1955
|
+
seen = await this.peekElements(session.id);
|
|
1956
|
+
this.lastMaskElements = seen.elements;
|
|
1957
|
+
if (seen.scaleBase === undefined) {
|
|
1958
|
+
return { kind: "error", line: "Error internal: couldn't read the screen's elements to paint out what ignore names." };
|
|
1605
1959
|
}
|
|
1960
|
+
const masked = ignoreRects(options.ignore, seen.elements, shotSize.width / seen.scaleBase, shotSize);
|
|
1961
|
+
mask.push(...masked.rects);
|
|
1962
|
+
notes.push(...masked.notes);
|
|
1606
1963
|
}
|
|
1607
|
-
|
|
1608
|
-
|
|
1964
|
+
let diff;
|
|
1965
|
+
try {
|
|
1966
|
+
diff = diffImages(reference, shot, { mask });
|
|
1609
1967
|
}
|
|
1968
|
+
catch (error) {
|
|
1969
|
+
return { kind: "error", line: `Error bad_params: ${error instanceof Error ? error.message : String(error)} To start over, use update: true.` };
|
|
1970
|
+
}
|
|
1971
|
+
// The pass bar compares the exact share; the rounded percent is only for showing (a few pixels round to 0%).
|
|
1972
|
+
const exactPercent = (diff.changedPixels / Math.max(1, diff.width * diff.height)) * 100;
|
|
1973
|
+
if (diff.changedPixels === 0 || exactPercent <= options.threshold) {
|
|
1974
|
+
return { kind: "match", percent: diff.changedPercent, notes };
|
|
1975
|
+
}
|
|
1976
|
+
// Name the regions in points, with the elements they touch.
|
|
1977
|
+
const found = seen ?? (await this.peekElements(session.id));
|
|
1978
|
+
const scale = found.scaleBase === undefined ? 1 : diff.width / found.scaleBase;
|
|
1610
1979
|
const unit = scale === 1 ? "pixels" : "points";
|
|
1611
|
-
const
|
|
1612
|
-
`${diff.changedPercent}% of the screen changed from the reference "${name}", in ${diff.boxes.length} region${diff.boxes.length === 1 ? "" : "s"} (${unit}):`,
|
|
1613
|
-
];
|
|
1614
|
-
for (const box of diff.boxes) {
|
|
1980
|
+
const regions = diff.boxes.map((box) => {
|
|
1615
1981
|
const b = { x: box.x / scale, y: box.y / scale, width: box.width / scale, height: box.height / scale };
|
|
1616
1982
|
// The smallest named elements there: a page-sized container overlaps every change.
|
|
1617
|
-
const near = elements
|
|
1983
|
+
const near = found.elements
|
|
1618
1984
|
.filter((e) => e.frame && e.frame.x < b.x + b.width && e.frame.x + e.frame.width > b.x && e.frame.y < b.y + b.height && e.frame.y + e.frame.height > b.y)
|
|
1619
1985
|
.filter((e) => (e.text ?? "") !== "" || (e.label ?? "") !== "" || (e.testID ?? "") !== "")
|
|
1620
1986
|
.map((e) => ({ e, area: (e.frame?.width ?? 0) * (e.frame?.height ?? 0) }))
|
|
@@ -1622,12 +1988,289 @@ export class TaplineTools {
|
|
|
1622
1988
|
.sort((x, y) => x.area - y.area)
|
|
1623
1989
|
.slice(0, 2)
|
|
1624
1990
|
.map(({ e }) => describeElement(e));
|
|
1625
|
-
|
|
1991
|
+
return { at: `@${Math.round(b.x)},${Math.round(b.y)} ${Math.round(b.width)}x${Math.round(b.height)}`, near };
|
|
1992
|
+
});
|
|
1993
|
+
const count = regions.length;
|
|
1994
|
+
const where = `in ${count} region${count === 1 ? "" : "s"} (${unit})`;
|
|
1995
|
+
const lines = [`Differs from the reference "${name}": ${diff.changedPercent}% changed, ${where}:`];
|
|
1996
|
+
for (const region of regions.slice(0, MAX_REGION_LINES)) {
|
|
1997
|
+
lines.push(`- ${region.at}${region.near.length > 0 ? `: ${region.near.join(", ")}` : ""}`);
|
|
1998
|
+
}
|
|
1999
|
+
if (count > MAX_REGION_LINES)
|
|
2000
|
+
lines.push(`(+${count - MAX_REGION_LINES} more regions)`);
|
|
2001
|
+
const listed = regions.slice(0, MAX_REGION_LINES).map((region) => region.at).join(", ");
|
|
2002
|
+
const reason = `${diff.changedPercent}% changed from the reference "${name}" (threshold ${options.threshold}%), ${where}: ${listed}${count > MAX_REGION_LINES ? `, +${count - MAX_REGION_LINES} more` : ""}.`;
|
|
2003
|
+
return { kind: "differs", percent: diff.changedPercent, lines, reason, image: diff.image, notes };
|
|
2004
|
+
}
|
|
2005
|
+
/** Why a check does not hold (for `run`, as a step or as `matches`), or null when it matches. */
|
|
2006
|
+
referenceReason(check, name) {
|
|
2007
|
+
switch (check.kind) {
|
|
2008
|
+
case "match":
|
|
2009
|
+
return null;
|
|
2010
|
+
case "missing":
|
|
2011
|
+
return { code: "expect_failed", message: `no reference "${name}" yet; save one with compare_screen { name: "${name}" }.` };
|
|
2012
|
+
case "size":
|
|
2013
|
+
return { code: "bad_params", message: check.message };
|
|
2014
|
+
case "error": {
|
|
2015
|
+
const { code, message } = parseError(check.line);
|
|
2016
|
+
return { code, message };
|
|
2017
|
+
}
|
|
2018
|
+
case "differs":
|
|
2019
|
+
return { code: "expect_failed", message: check.reason };
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
/** Whether the screen matches the reference `name` now (`expect.matches`): null when it does, else the reason. */
|
|
2023
|
+
async matchMissed(name, threshold, ignore) {
|
|
2024
|
+
this.lastMaskElements = undefined;
|
|
2025
|
+
if (!SCRIPT_NAME.test(name))
|
|
2026
|
+
return parseError(BAD_REFERENCE_NAME).message;
|
|
2027
|
+
const session = this.host.current();
|
|
2028
|
+
if (session === undefined)
|
|
2029
|
+
return "the app isn't connected";
|
|
2030
|
+
const folder = this.referenceFolder(session);
|
|
2031
|
+
if (typeof folder !== "string")
|
|
2032
|
+
return parseError(folder.content.map((block) => (block.type === "text" ? block.text : "")).join("\n")).message;
|
|
2033
|
+
if (this.replayContext?.update === true) {
|
|
2034
|
+
// --update-references: the check saves the screen into the script's folder, and holds.
|
|
2035
|
+
const saved = await this.saveReferenceHere(session, name, DEFAULT_SCREENSHOT_WIDTH, this.replayContext.referencesDir);
|
|
2036
|
+
return saved.isError === true ? parseError(saved.content.map((block) => (block.type === "text" ? block.text : "")).join("\n")).message : null;
|
|
2037
|
+
}
|
|
2038
|
+
const check = await this.checkAgainstReference(session, name, {
|
|
2039
|
+
threshold,
|
|
2040
|
+
ignore,
|
|
2041
|
+
width: DEFAULT_SCREENSHOT_WIDTH,
|
|
2042
|
+
referencesDirs: this.referencesDirs(folder),
|
|
2043
|
+
});
|
|
2044
|
+
this.keepDiff(check, name);
|
|
2045
|
+
return this.referenceReason(check, name)?.message ?? null;
|
|
2046
|
+
}
|
|
2047
|
+
/** Where a check looks for `<name>.jpg`: the folder next to a replayed script first, then the app's own store. */
|
|
2048
|
+
referencesDirs(folder) {
|
|
2049
|
+
return this.replayContext !== null ? [this.replayContext.referencesDir, folder] : [folder];
|
|
2050
|
+
}
|
|
2051
|
+
/** In a replay, a failed check leaves its red picture at `<script>.diff/<name>.jpg`. */
|
|
2052
|
+
keepDiff(check, name) {
|
|
2053
|
+
if (this.replayContext === null || check.kind !== "differs" || !SCRIPT_NAME.test(name))
|
|
2054
|
+
return;
|
|
2055
|
+
const path = join(this.replayContext.diffDir, `${name}.jpg`);
|
|
2056
|
+
try {
|
|
2057
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2058
|
+
writeFileSync(path, Buffer.from(check.image.data, "base64"));
|
|
2059
|
+
this.diffSaved = path;
|
|
2060
|
+
}
|
|
2061
|
+
catch {
|
|
2062
|
+
// the failure still reads without the picture
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
/** Takes the screen and saves it as the reference `name` in `dir`. */
|
|
2066
|
+
async saveReferenceHere(session, name, width, dir) {
|
|
2067
|
+
try {
|
|
2068
|
+
return this.writeReference(dir, name, await this.takeShot(session.id, width, 0.9));
|
|
2069
|
+
}
|
|
2070
|
+
catch (error) {
|
|
2071
|
+
return fail(describeError(error));
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
writeReference(folder, name, shot) {
|
|
2075
|
+
// Every write checks the name itself, whatever the caller checked: no way out of the references folder.
|
|
2076
|
+
if (!SCRIPT_NAME.test(name))
|
|
2077
|
+
return fail(BAD_REFERENCE_NAME);
|
|
2078
|
+
const path = join(folder, `${name}.jpg`);
|
|
2079
|
+
try {
|
|
2080
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
2081
|
+
writeFileSync(path, Buffer.from(shot.data, "base64"), { mode: 0o600 });
|
|
2082
|
+
}
|
|
2083
|
+
catch (error) {
|
|
2084
|
+
return fail(`Error internal: couldn't save the reference: ${error instanceof Error ? error.message : String(error)}`);
|
|
2085
|
+
}
|
|
2086
|
+
let size = `${shot.width ?? "?"}x${shot.height ?? "?"}`;
|
|
2087
|
+
try {
|
|
2088
|
+
const real = imageSize(shot);
|
|
2089
|
+
size = `${real.width}x${real.height}`;
|
|
2090
|
+
}
|
|
2091
|
+
catch {
|
|
2092
|
+
// the app's own size stays
|
|
2093
|
+
}
|
|
2094
|
+
return text(`Saved the reference "${name}" (${size} px).`);
|
|
2095
|
+
}
|
|
2096
|
+
/** A `compare_screen` step of `run`: it never saves; it holds on a match and stops the batch otherwise. */
|
|
2097
|
+
async compareStep(args) {
|
|
2098
|
+
this.lastMaskElements = undefined;
|
|
2099
|
+
const session = this.host.current();
|
|
2100
|
+
if (session === undefined)
|
|
2101
|
+
return this.notConnected();
|
|
2102
|
+
const name = args.name.trim();
|
|
2103
|
+
if (!SCRIPT_NAME.test(name))
|
|
2104
|
+
return fail("Error bad_params: name the reference with letters, digits, '.', '_' and '-'.");
|
|
2105
|
+
const folder = this.referenceFolder(session);
|
|
2106
|
+
if (typeof folder !== "string")
|
|
2107
|
+
return folder;
|
|
2108
|
+
if (this.replayContext?.update === true) {
|
|
2109
|
+
return this.saveReferenceHere(session, name, args.width ?? DEFAULT_SCREENSHOT_WIDTH, this.replayContext.referencesDir);
|
|
2110
|
+
}
|
|
2111
|
+
const check = await this.checkAgainstReference(session, name, {
|
|
2112
|
+
threshold: args.threshold ?? DEFAULT_MATCH_THRESHOLD,
|
|
2113
|
+
ignore: args.ignore ?? [],
|
|
2114
|
+
width: args.width ?? DEFAULT_SCREENSHOT_WIDTH,
|
|
2115
|
+
referencesDirs: this.referencesDirs(folder),
|
|
2116
|
+
});
|
|
2117
|
+
this.keepDiff(check, name);
|
|
2118
|
+
const why = this.referenceReason(check, name);
|
|
2119
|
+
if (why !== null)
|
|
2120
|
+
return fail(`Error ${why.code}: ${why.message}`);
|
|
2121
|
+
if (check.kind !== "match")
|
|
2122
|
+
return fail("Error internal: the comparison gave no answer.");
|
|
2123
|
+
// A check that held inside `run` is kept in a recording too, with its options.
|
|
2124
|
+
const recorded = await this.recordCompare({
|
|
2125
|
+
name,
|
|
2126
|
+
...(args.threshold !== undefined ? { threshold: args.threshold } : {}),
|
|
2127
|
+
...(args.ignore !== undefined ? { ignore: args.ignore } : {}),
|
|
2128
|
+
...(args.width !== undefined ? { width: args.width } : {}),
|
|
2129
|
+
});
|
|
2130
|
+
if (recorded !== null)
|
|
2131
|
+
return fail(recorded);
|
|
2132
|
+
return text([`Matches the reference "${name}": ${check.percent}% changed.`, ...check.notes.map((n) => `Note: ${n}`)].join("\n"));
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Saves a visual check that held as a `compareScreen` step of the recording, when one is running: the name, and the
|
|
2136
|
+
* threshold, the ignore list (refs made stable) and the width when given. `update` is never written. Answers an
|
|
2137
|
+
* error line when an ignore ref can't be kept in a script, else null (also when nothing is recording).
|
|
2138
|
+
*/
|
|
2139
|
+
async recordCompare(check) {
|
|
2140
|
+
if (this.recording === null || this.replaying)
|
|
2141
|
+
return null;
|
|
2142
|
+
const session = this.host.current();
|
|
2143
|
+
let kept = check.ignore;
|
|
2144
|
+
if (session !== undefined && kept !== undefined && kept.length > 0) {
|
|
2145
|
+
const stable = await this.stableIgnore(session.id, kept, this.lastMaskElements);
|
|
2146
|
+
if (typeof stable === "string")
|
|
2147
|
+
return stable;
|
|
2148
|
+
kept = stable;
|
|
2149
|
+
}
|
|
2150
|
+
if (this.recording === null)
|
|
2151
|
+
return null;
|
|
2152
|
+
this.recording.steps.push({
|
|
2153
|
+
method: "compareScreen",
|
|
2154
|
+
params: {
|
|
2155
|
+
name: check.name.trim(),
|
|
2156
|
+
...(check.threshold !== undefined ? { threshold: check.threshold } : {}),
|
|
2157
|
+
...(kept !== undefined && kept.length > 0 ? { ignore: kept } : {}),
|
|
2158
|
+
...(check.width !== undefined ? { width: check.width } : {}),
|
|
2159
|
+
},
|
|
2160
|
+
});
|
|
2161
|
+
return null;
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* `ignore` as a script keeps it (AGENT-TOOLS 1.9 §14.2): a ref becomes the element's id, else label, else text,
|
|
2165
|
+
* read from the list now. A string is the error answer when a ref can't be kept.
|
|
2166
|
+
*/
|
|
2167
|
+
async stableIgnore(sessionId, ignore, known) {
|
|
2168
|
+
if (!ignore.some((item) => !("width" in item) && item.ref !== undefined))
|
|
2169
|
+
return ignore;
|
|
2170
|
+
// The elements the comparison painted out when it had them, so the script keeps exactly that mask.
|
|
2171
|
+
const seen = known !== undefined ? { elements: known } : await this.peekElements(sessionId);
|
|
2172
|
+
const kept = [];
|
|
2173
|
+
for (const item of ignore) {
|
|
2174
|
+
if ("width" in item || item.ref === undefined) {
|
|
2175
|
+
kept.push(item);
|
|
2176
|
+
continue;
|
|
2177
|
+
}
|
|
2178
|
+
const element = seen.elements.find((e) => e.ref === item.ref);
|
|
2179
|
+
if (element === undefined)
|
|
2180
|
+
return `Error bad_params: ignore ${item.ref} isn't on the screen, so it can't be saved in a script.`;
|
|
2181
|
+
// When several elements share the name, the saved item keeps which one it was (`index`, counted as ignoreRects
|
|
2182
|
+
// counts: elements with a frame, in list order), so a replay paints out that one and not all of them.
|
|
2183
|
+
const framed = seen.elements.filter((e) => e.frame !== undefined);
|
|
2184
|
+
const withIndex = (saved, same) => {
|
|
2185
|
+
const matching = framed.filter(same);
|
|
2186
|
+
const at = matching.indexOf(element);
|
|
2187
|
+
return matching.length > 1 && at >= 0 ? { ...saved, index: at } : saved;
|
|
2188
|
+
};
|
|
2189
|
+
if (typeof element.testID === "string" && element.testID !== "") {
|
|
2190
|
+
const id = element.testID;
|
|
2191
|
+
kept.push(withIndex({ id }, (e) => e.testID === id));
|
|
2192
|
+
}
|
|
2193
|
+
else if (typeof element.label === "string" && element.label !== "") {
|
|
2194
|
+
const label = element.label.toLowerCase();
|
|
2195
|
+
kept.push(withIndex({ label: element.label }, (e) => labelOfElement(e).toLowerCase() === label));
|
|
2196
|
+
}
|
|
2197
|
+
else if (typeof element.text === "string" && element.text !== "") {
|
|
2198
|
+
const text = element.text.toLowerCase();
|
|
2199
|
+
kept.push(withIndex({ text: element.text }, (e) => (e.text ?? "").toLowerCase() === text));
|
|
2200
|
+
}
|
|
2201
|
+
else {
|
|
2202
|
+
return `Error bad_params: ignore ${describeElement(element)} has no id, label or text, so a script couldn't find it again; give it a testID, or ignore a rect {x, y, width, height}.`;
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
return kept;
|
|
2206
|
+
}
|
|
2207
|
+
async compareScreen(args) {
|
|
2208
|
+
// Each check starts with no mask list: an update, or a first save, never records from an earlier comparison.
|
|
2209
|
+
this.lastMaskElements = undefined;
|
|
2210
|
+
const recording = this.recording !== null && !this.replaying;
|
|
2211
|
+
const session = this.host.current();
|
|
2212
|
+
// An ignore ref that a script couldn't find again is refused before anything is compared.
|
|
2213
|
+
if (recording && session !== undefined && args.ignore !== undefined && args.ignore.length > 0) {
|
|
2214
|
+
const stable = await this.stableIgnore(session.id, args.ignore);
|
|
2215
|
+
if (typeof stable === "string")
|
|
2216
|
+
return fail(stable);
|
|
2217
|
+
}
|
|
2218
|
+
const answer = await this.compareScreenNow(args);
|
|
2219
|
+
if (recording && answer.isError !== true) {
|
|
2220
|
+
// `update` is never written: the script checks against the reference it carries.
|
|
2221
|
+
const recorded = await this.recordCompare({
|
|
2222
|
+
name: args.name,
|
|
2223
|
+
...(args.threshold !== undefined ? { threshold: args.threshold } : {}),
|
|
2224
|
+
...(args.ignore !== undefined ? { ignore: args.ignore } : {}),
|
|
2225
|
+
...(args.width !== undefined ? { width: args.width } : {}),
|
|
2226
|
+
});
|
|
2227
|
+
if (recorded !== null)
|
|
2228
|
+
return fail(recorded);
|
|
2229
|
+
}
|
|
2230
|
+
return answer;
|
|
2231
|
+
}
|
|
2232
|
+
async compareScreenNow(args) {
|
|
2233
|
+
const session = this.host.current();
|
|
2234
|
+
if (session === undefined)
|
|
2235
|
+
return this.notConnected();
|
|
2236
|
+
const name = args.name.trim();
|
|
2237
|
+
if (!SCRIPT_NAME.test(name))
|
|
2238
|
+
return fail("Error bad_params: name the reference with letters, digits, '.', '_' and '-'.");
|
|
2239
|
+
const folder = this.referenceFolder(session);
|
|
2240
|
+
if (typeof folder !== "string")
|
|
2241
|
+
return folder;
|
|
2242
|
+
const width = args.width ?? DEFAULT_SCREENSHOT_WIDTH;
|
|
2243
|
+
const save = (shot) => this.writeReference(folder, name, shot);
|
|
2244
|
+
if (args.update === true) {
|
|
2245
|
+
try {
|
|
2246
|
+
return save(await this.takeShot(session.id, width, 0.9));
|
|
2247
|
+
}
|
|
2248
|
+
catch (error) {
|
|
2249
|
+
return fail(describeError(error));
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
const check = await this.checkAgainstReference(session, name, {
|
|
2253
|
+
threshold: args.threshold ?? DEFAULT_MATCH_THRESHOLD,
|
|
2254
|
+
ignore: args.ignore ?? [],
|
|
2255
|
+
width,
|
|
2256
|
+
referencesDirs: [folder],
|
|
2257
|
+
});
|
|
2258
|
+
switch (check.kind) {
|
|
2259
|
+
case "missing":
|
|
2260
|
+
return save(check.shot);
|
|
2261
|
+
case "error":
|
|
2262
|
+
return fail(check.line);
|
|
2263
|
+
case "size":
|
|
2264
|
+
return fail(`Error bad_params: ${check.message}`);
|
|
2265
|
+
case "match":
|
|
2266
|
+
return text([`Matches the reference "${name}": ${check.percent}% changed.`, ...check.notes.map((n) => `Note: ${n}`)].join("\n"));
|
|
2267
|
+
case "differs": {
|
|
2268
|
+
const lines = [...check.lines, "The image shows the screen now, faded, with the changed pixels in red.", ...check.notes.map((n) => `Note: ${n}`)];
|
|
2269
|
+
const content = [];
|
|
2270
|
+
this.deliver(session.id, check.image, lines, content);
|
|
2271
|
+
return { content: [{ type: "text", text: lines.join("\n") }, ...content] };
|
|
2272
|
+
}
|
|
1626
2273
|
}
|
|
1627
|
-
lines.push("The image shows the screen now, faded, with the changed pixels in red.");
|
|
1628
|
-
const content = [];
|
|
1629
|
-
this.deliver(session.id, diff.image, lines, content);
|
|
1630
|
-
return { content: [{ type: "text", text: lines.join("\n") }, ...content] };
|
|
1631
2274
|
}
|
|
1632
2275
|
async filmstrip(args) {
|
|
1633
2276
|
const session = this.host.current();
|
|
@@ -1644,11 +2287,19 @@ export class TaplineTools {
|
|
|
1644
2287
|
// A screenshot takes a few hundred ms on a phone, so frame 1 is the "before".
|
|
1645
2288
|
// The tap is a touch like any other: the safety policy decides first.
|
|
1646
2289
|
const pressParams = { target: toWireTarget(args.tap) };
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
2290
|
+
// The policy check, the "before" frame and the tap are one turn of the live view's queue: a pause refuses
|
|
2291
|
+
// it, a person's input goes first, and the screen can't change between the check and the tap (PR #4 review).
|
|
2292
|
+
const turn = async () => {
|
|
2293
|
+
const gate = await this.policyGate(session, "tap", "press", pressParams, listsFor(this.policy, session.app));
|
|
2294
|
+
if ("stop" in gate)
|
|
2295
|
+
return gate.stop;
|
|
2296
|
+
frames.push(await this.takeShot(session.id, frameWidth, 0.8));
|
|
2297
|
+
await this.host.request("press", pressParams, { sessionId: session.id });
|
|
2298
|
+
return null;
|
|
2299
|
+
};
|
|
2300
|
+
const stopped = this.options.control ? await this.options.control.runAgent(turn) : await turn();
|
|
2301
|
+
if (stopped !== null)
|
|
2302
|
+
return stopped;
|
|
1652
2303
|
}
|
|
1653
2304
|
for (let i = frames.length; i < count; i++) {
|
|
1654
2305
|
const at = Date.now();
|
|
@@ -1722,7 +2373,10 @@ export class TaplineTools {
|
|
|
1722
2373
|
}
|
|
1723
2374
|
this.recording = null;
|
|
1724
2375
|
const count = script.steps.length;
|
|
1725
|
-
|
|
2376
|
+
// AGENT-TOOLS 1.9 §14.3: the references the steps use travel with the script.
|
|
2377
|
+
const copied = this.copyReferences(script.steps, recording.app, path);
|
|
2378
|
+
const withPictures = copied === null ? "" : `, and ${copied.count} reference picture${copied.count === 1 ? "" : "s"} to ${copied.folder}`;
|
|
2379
|
+
const lines = [`Saved ${count} step${count === 1 ? "" : "s"} to ${path}${withPictures}.`];
|
|
1726
2380
|
const ref = refStep(script.steps);
|
|
1727
2381
|
if (ref !== null) {
|
|
1728
2382
|
lines.push(`Note: step ${ref + 1} targets a ref, which won't exist on a new run, so replay refuses the script. Give that element a testID, or record it again by its text or label.`);
|
|
@@ -1738,6 +2392,41 @@ export class TaplineTools {
|
|
|
1738
2392
|
}
|
|
1739
2393
|
}
|
|
1740
2394
|
}
|
|
2395
|
+
/** `<script dir>/<script name>.references` and `.diff` for a script file. */
|
|
2396
|
+
scriptSideFolder(path, kind) {
|
|
2397
|
+
return join(dirname(path), `${basename(path).replace(/\.json$/i, "")}.${kind}`);
|
|
2398
|
+
}
|
|
2399
|
+
/** Copies each reference the steps use from the app's store next to the script; null when there are none. */
|
|
2400
|
+
copyReferences(steps, app, scriptPath) {
|
|
2401
|
+
const dir = this.options.baselinesDir;
|
|
2402
|
+
if (dir === undefined)
|
|
2403
|
+
return null;
|
|
2404
|
+
const names = new Set();
|
|
2405
|
+
for (const step of steps) {
|
|
2406
|
+
const p = step;
|
|
2407
|
+
if (step.tool === "compare_screen" && typeof p["name"] === "string" && SCRIPT_NAME.test(p["name"]))
|
|
2408
|
+
names.add(p["name"]);
|
|
2409
|
+
if (step.expect?.matches !== undefined && SCRIPT_NAME.test(step.expect.matches))
|
|
2410
|
+
names.add(step.expect.matches);
|
|
2411
|
+
}
|
|
2412
|
+
const store = join(dir, (app.id ?? app.name).replace(/[^\w.-]/g, "_"));
|
|
2413
|
+
const folder = this.scriptSideFolder(scriptPath, "references");
|
|
2414
|
+
let count = 0;
|
|
2415
|
+
for (const name of names) {
|
|
2416
|
+
const from = join(store, `${name}.jpg`);
|
|
2417
|
+
if (!existsSync(from))
|
|
2418
|
+
continue;
|
|
2419
|
+
try {
|
|
2420
|
+
mkdirSync(folder, { recursive: true });
|
|
2421
|
+
copyFileSync(from, join(folder, `${name}.jpg`));
|
|
2422
|
+
count++;
|
|
2423
|
+
}
|
|
2424
|
+
catch {
|
|
2425
|
+
// the script still saved; the picture stays in the store
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
return count === 0 ? null : { count, folder };
|
|
2429
|
+
}
|
|
1741
2430
|
scriptList() {
|
|
1742
2431
|
const dir = this.options.scriptsDir;
|
|
1743
2432
|
if (dir === undefined || !existsSync(dir))
|
|
@@ -1756,7 +2445,7 @@ export class TaplineTools {
|
|
|
1756
2445
|
}
|
|
1757
2446
|
return lines.length === 0 ? "No saved scripts." : ["Saved scripts:", ...lines].join("\n");
|
|
1758
2447
|
}
|
|
1759
|
-
async replay(args) {
|
|
2448
|
+
async replay(args, options = {}) {
|
|
1760
2449
|
const name = (args.name ?? "").trim();
|
|
1761
2450
|
const given = (args.path ?? "").trim();
|
|
1762
2451
|
if (name === "" && given === "")
|
|
@@ -1800,6 +2489,11 @@ export class TaplineTools {
|
|
|
1800
2489
|
notes.push("Note: the app hadn't reconnected after starting fresh when the steps began.");
|
|
1801
2490
|
}
|
|
1802
2491
|
this.replaying = true;
|
|
2492
|
+
this.replayContext = {
|
|
2493
|
+
referencesDir: this.scriptSideFolder(path, "references"),
|
|
2494
|
+
diffDir: this.scriptSideFolder(path, "diff"),
|
|
2495
|
+
update: options.updateReferences === true,
|
|
2496
|
+
};
|
|
1803
2497
|
try {
|
|
1804
2498
|
const answer = await this.run({ steps: script.steps, look: args.look, screenshot: args.screenshot });
|
|
1805
2499
|
if (notes.length === 0)
|
|
@@ -1811,6 +2505,7 @@ export class TaplineTools {
|
|
|
1811
2505
|
}
|
|
1812
2506
|
finally {
|
|
1813
2507
|
this.replaying = false;
|
|
2508
|
+
this.replayContext = null;
|
|
1814
2509
|
}
|
|
1815
2510
|
}
|
|
1816
2511
|
async withLook(session, line, screenshot = false) {
|
|
@@ -2294,37 +2989,213 @@ export class TaplineTools {
|
|
|
2294
2989
|
// fall through with an empty list
|
|
2295
2990
|
}
|
|
2296
2991
|
}
|
|
2297
|
-
const
|
|
2298
|
-
const
|
|
2299
|
-
return text(
|
|
2992
|
+
const newest = entries.reduce((max, e) => (e.ts !== undefined && (max === undefined || e.ts > max) ? e.ts : max), undefined);
|
|
2993
|
+
const query = { since: args.since, level: args.level, grep: args.grep, limit: args.limit };
|
|
2994
|
+
return text(formatLogAnswer(filterLogs(entries, query), query, newest ?? args.since ?? 0));
|
|
2995
|
+
}
|
|
2996
|
+
/** The newest fatal JS error of the app (`all`: the last 5). Reading them tells the agent what the notes did. */
|
|
2997
|
+
crash(args = {}) {
|
|
2998
|
+
// The app being driven, else the one that stopped last. Never every app's crashes at once.
|
|
2999
|
+
const known = this.host.current() ?? this.host.lastCrashEvent()?.of;
|
|
3000
|
+
if (known === undefined)
|
|
3001
|
+
return text(NO_CRASH_TEXT);
|
|
3002
|
+
// Reading them tells the agent what the notes of this app said; other apps' stay pending.
|
|
3003
|
+
this.notedCrashSeqs.set(identityOf(known), this.host.crashMark());
|
|
3004
|
+
const crashes = this.host.crashes(known);
|
|
3005
|
+
if (crashes.length === 0)
|
|
3006
|
+
return text(NO_CRASH_TEXT);
|
|
3007
|
+
return text(crashes.slice(0, args.all === true ? 5 : 1).map(formatCrash).join("\n\n"));
|
|
2300
3008
|
}
|
|
2301
3009
|
async reload(args) {
|
|
2302
3010
|
const session = this.host.current();
|
|
2303
3011
|
if (session === undefined)
|
|
2304
3012
|
return this.notConnected();
|
|
2305
3013
|
try {
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
3014
|
+
try {
|
|
3015
|
+
// The app goes away on purpose: that is no crash.
|
|
3016
|
+
this.host.expectEnd(session.id);
|
|
3017
|
+
await this.host.request("reload", {}, { sessionId: session.id, timeoutMs: 5000 });
|
|
3018
|
+
}
|
|
3019
|
+
catch (error) {
|
|
3020
|
+
// The app dies mid-reload, so a dropped connection or a missing answer means it worked.
|
|
3021
|
+
const worked = error instanceof TaplineError && (error.code === "reconnecting" || error.code === "timeout" || error.code === "not_answering");
|
|
3022
|
+
if (!worked)
|
|
3023
|
+
return fail(describeError(error));
|
|
3024
|
+
}
|
|
3025
|
+
if (args.look === false)
|
|
3026
|
+
return text("Reload requested.");
|
|
3027
|
+
const deadline = Date.now() + this.reloadWaitMs;
|
|
3028
|
+
for (;;) {
|
|
3029
|
+
const now = this.host.current();
|
|
3030
|
+
if (now !== undefined && now.id !== session.id)
|
|
3031
|
+
break;
|
|
3032
|
+
if (Date.now() > deadline)
|
|
3033
|
+
return text("Reload requested, but the app has not reconnected yet. Try look in a few seconds.");
|
|
3034
|
+
await sleep(100);
|
|
3035
|
+
}
|
|
2313
3036
|
}
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
const now = this.host.current();
|
|
2319
|
-
if (now !== undefined && now.id !== session.id)
|
|
2320
|
-
break;
|
|
2321
|
-
if (Date.now() > deadline)
|
|
2322
|
-
return text("Reload requested, but the app has not reconnected yet. Try look in a few seconds.");
|
|
2323
|
-
await sleep(100);
|
|
3037
|
+
finally {
|
|
3038
|
+
// Whatever happened, the wait is over: a reload that "worked" without the app going away must not
|
|
3039
|
+
// leave a later, real crash of this session explained.
|
|
3040
|
+
this.host.expectEnd(session.id, false);
|
|
2324
3041
|
}
|
|
2325
3042
|
await sleep(this.reloadSettleMs);
|
|
2326
3043
|
return this.withLook(this.host.current() ?? session, "Reloaded; the app reconnected.");
|
|
2327
3044
|
}
|
|
3045
|
+
// -- performance (AGENT-TOOLS §15.3)
|
|
3046
|
+
/**
|
|
3047
|
+
* `launch`: a JavaScript reload (development builds), timed from the request to the new session's first
|
|
3048
|
+
* settled screen. Stops with the answer when the reload can't be made or the app doesn't come back.
|
|
3049
|
+
*/
|
|
3050
|
+
async timedReload(session) {
|
|
3051
|
+
const started = Date.now();
|
|
3052
|
+
let fresh;
|
|
3053
|
+
try {
|
|
3054
|
+
try {
|
|
3055
|
+
// The app goes away on purpose: that is no crash.
|
|
3056
|
+
this.host.expectEnd(session.id);
|
|
3057
|
+
await this.host.request("reload", {}, { sessionId: session.id, timeoutMs: 5000 });
|
|
3058
|
+
}
|
|
3059
|
+
catch (error) {
|
|
3060
|
+
// The app dies mid-reload, so a dropped connection or a missing answer means it worked.
|
|
3061
|
+
const worked = error instanceof TaplineError && (error.code === "reconnecting" || error.code === "timeout" || error.code === "not_answering");
|
|
3062
|
+
if (!worked) {
|
|
3063
|
+
if (error instanceof TaplineError && error.code === "unsupported") {
|
|
3064
|
+
return { stop: fail(`Error unsupported: launch is a JavaScript reload, and only development builds can do one (${error.message}).`) };
|
|
3065
|
+
}
|
|
3066
|
+
return { stop: fail(describeError(error)) };
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
const deadline = Date.now() + this.reloadWaitMs;
|
|
3070
|
+
// Only the same app on the same phone, back as a new session, counts: another connected app that became
|
|
3071
|
+
// current meanwhile is never measured as the reload (Codex review of 1.3.0).
|
|
3072
|
+
const same = identityOf(session);
|
|
3073
|
+
while (fresh === undefined) {
|
|
3074
|
+
const back = this.host.sessions().find((s) => s.id !== session.id && identityOf(s) === same);
|
|
3075
|
+
if (back !== undefined) {
|
|
3076
|
+
fresh = back;
|
|
3077
|
+
// and the agent goes on driving it, not whichever app the host picked when the old session dropped
|
|
3078
|
+
if (this.host.current()?.id !== back.id)
|
|
3079
|
+
this.host.select(back.id);
|
|
3080
|
+
}
|
|
3081
|
+
else if (Date.now() > deadline) {
|
|
3082
|
+
return { stop: fail(`Error timeout: the app hadn't reconnected ${Number((this.reloadWaitMs / 1000).toFixed(1))} s after the reload; try look in a few seconds.`) };
|
|
3083
|
+
}
|
|
3084
|
+
else
|
|
3085
|
+
await sleep(100);
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
finally {
|
|
3089
|
+
// The wait is over, whatever happened: a reload that "worked" without the app going away must not leave
|
|
3090
|
+
// a later crash of this session explained away.
|
|
3091
|
+
this.host.expectEnd(session.id, false);
|
|
3092
|
+
}
|
|
3093
|
+
// The first settled screen: the app waits until React hasn't committed for a moment (§6).
|
|
3094
|
+
// The first settled screen: a launch time is only reported when the app said it settled.
|
|
3095
|
+
try {
|
|
3096
|
+
await this.host.request("perfSettle", {}, { sessionId: fresh.id, timeoutMs: 8000 });
|
|
3097
|
+
}
|
|
3098
|
+
catch (error) {
|
|
3099
|
+
return { stop: fail(describeError(error)) };
|
|
3100
|
+
}
|
|
3101
|
+
return { session: fresh, seconds: (Date.now() - started) / 1000 };
|
|
3102
|
+
}
|
|
3103
|
+
/** One measuring window in the app: start, (the tap,) wait out `seconds`, stop. */
|
|
3104
|
+
async measureWindow(session, seconds, press) {
|
|
3105
|
+
const id = { sessionId: session.id };
|
|
3106
|
+
// The window's number: the stop names it, so a call never takes another call's numbers (an older SDK has none).
|
|
3107
|
+
let stopParams = {};
|
|
3108
|
+
try {
|
|
3109
|
+
const started = await this.host.request("perfStart", {}, id);
|
|
3110
|
+
const windowId = typeof started === "object" && started !== null ? started.id : undefined;
|
|
3111
|
+
if (typeof windowId === "number")
|
|
3112
|
+
stopParams = { id: windowId };
|
|
3113
|
+
}
|
|
3114
|
+
catch (error) {
|
|
3115
|
+
if (error instanceof TaplineError && error.code === "unsupported") {
|
|
3116
|
+
throw new TaplineError("unsupported", "this app's Tapline is too old for performance; update react-native-tapline.");
|
|
3117
|
+
}
|
|
3118
|
+
throw error;
|
|
3119
|
+
}
|
|
3120
|
+
const started = Date.now();
|
|
3121
|
+
try {
|
|
3122
|
+
// The caller holds the live view's queue for the whole window when there is a tap (see performance).
|
|
3123
|
+
if (press !== undefined)
|
|
3124
|
+
await this.host.request("press", press, id);
|
|
3125
|
+
const rest = seconds * 1000 - (Date.now() - started);
|
|
3126
|
+
if (rest > 0)
|
|
3127
|
+
await sleep(rest);
|
|
3128
|
+
}
|
|
3129
|
+
catch (error) {
|
|
3130
|
+
await this.host.request("perfStop", stopParams, id).catch(() => undefined);
|
|
3131
|
+
throw error;
|
|
3132
|
+
}
|
|
3133
|
+
const parsed = perfResultSchema.safeParse(await this.host.request("perfStop", stopParams, id));
|
|
3134
|
+
if (!parsed.success)
|
|
3135
|
+
throw new TaplineError("internal", "the app returned performance numbers the host could not read.");
|
|
3136
|
+
return parsed.data;
|
|
3137
|
+
}
|
|
3138
|
+
async performance(args = {}) {
|
|
3139
|
+
this.lastPerf = undefined;
|
|
3140
|
+
const session = this.host.current();
|
|
3141
|
+
if (session === undefined)
|
|
3142
|
+
return this.notConnected();
|
|
3143
|
+
if (args.launch === true && args.tap !== undefined) {
|
|
3144
|
+
return fail("Error bad_params: launch reloads the app and tap taps in it; give one of them.");
|
|
3145
|
+
}
|
|
3146
|
+
const seconds = args.seconds ?? 3;
|
|
3147
|
+
try {
|
|
3148
|
+
let measure;
|
|
3149
|
+
if (args.launch === true) {
|
|
3150
|
+
const launched = await this.timedReload(session);
|
|
3151
|
+
if ("stop" in launched)
|
|
3152
|
+
return launched.stop;
|
|
3153
|
+
measure = { result: await this.measureWindow(launched.session, seconds), launchS: launched.seconds };
|
|
3154
|
+
}
|
|
3155
|
+
else {
|
|
3156
|
+
let press;
|
|
3157
|
+
if (args.tap !== undefined) {
|
|
3158
|
+
// The tap is a touch like any other: the safety policy decides first (as for filmstrip).
|
|
3159
|
+
press = { target: toWireTarget(args.tap) };
|
|
3160
|
+
const stale = this.staleRef(press);
|
|
3161
|
+
if (stale !== null)
|
|
3162
|
+
return stale;
|
|
3163
|
+
}
|
|
3164
|
+
// With a tap, the policy check and the whole window are one turn of the live view's queue: a pause refuses
|
|
3165
|
+
// it, a person's input goes first and waits for the window, and the screen can't change between the check
|
|
3166
|
+
// and the tap (Codex and CodeRabbit reviews of 1.3.0).
|
|
3167
|
+
const tapped = press;
|
|
3168
|
+
const window = async () => {
|
|
3169
|
+
if (tapped !== undefined) {
|
|
3170
|
+
const gate = await this.policyGate(session, "tap", "press", tapped, listsFor(this.policy, session.app));
|
|
3171
|
+
if ("stop" in gate)
|
|
3172
|
+
return { stop: gate.stop };
|
|
3173
|
+
}
|
|
3174
|
+
return { result: await this.measureWindow(session, seconds, tapped) };
|
|
3175
|
+
};
|
|
3176
|
+
const outcome = tapped !== undefined && this.options.control ? await this.options.control.runAgent(window) : await window();
|
|
3177
|
+
if ("stop" in outcome)
|
|
3178
|
+
return outcome.stop;
|
|
3179
|
+
measure = { result: outcome.result };
|
|
3180
|
+
}
|
|
3181
|
+
this.lastPerf = measure;
|
|
3182
|
+
if (this.recording !== null && !this.replaying) {
|
|
3183
|
+
this.recording.steps.push({
|
|
3184
|
+
method: "performance",
|
|
3185
|
+
params: {
|
|
3186
|
+
...(args.launch === true ? { launch: true } : {}),
|
|
3187
|
+
...(args.seconds !== undefined ? { seconds: args.seconds } : {}),
|
|
3188
|
+
...(args.tap !== undefined ? { tap: args.tap } : {}),
|
|
3189
|
+
},
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
const tapWords = args.tap === undefined ? undefined : targetWords(args.tap);
|
|
3193
|
+
return text([perfFirstLine(measure, tapWords), ...formatPerfLines(measure.result), ...this.takePersonNote()].join("\n"));
|
|
3194
|
+
}
|
|
3195
|
+
catch (error) {
|
|
3196
|
+
return fail(describeError(error));
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
2328
3199
|
async wait(args) {
|
|
2329
3200
|
const session = this.host.current();
|
|
2330
3201
|
if (session === undefined)
|