tapline-host 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -1,10 +1,10 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
3
  import { toBuffer as qrPng } from "qrcode";
4
4
  import { z } from "zod";
5
5
  import { annotateScreenshot } from "./annotate.js";
6
6
  import { agentKind, captionFor, personKind, reasonOf } from "./captions.js";
7
- import { composeFilmstrip, diffImages } from "./images.js";
7
+ import { composeFilmstrip, diffImages, imageSize } from "./images.js";
8
8
  import { alertNotes, describeElement, formatChanges, formatElement, formatLogs, formatOffScreenAfterList, formatTree } from "./format.js";
9
9
  import { formatInspect, inspectResultSchema } from "./inspect.js";
10
10
  import { AUDIT_TYPES, auditResultSchema, auditTitle, formatAudit } from "./audit.js";
@@ -47,6 +47,15 @@ export const targetSchema = z
47
47
  ctx.addIssue({ code: "custom", message: "index works with id, label or text." });
48
48
  }
49
49
  });
50
+ const rectSchema = z
51
+ .object({
52
+ x: z.number().describe("Points"),
53
+ y: z.number().describe("Points"),
54
+ width: z.number().positive().describe("Points"),
55
+ height: z.number().positive().describe("Points"),
56
+ })
57
+ .strict();
58
+ const ignoreListSchema = z.array(z.union([rectSchema, targetSchema]));
50
59
  const targetField = targetSchema.describe("Exactly one of ref, id, label, text or x and y");
51
60
  const lookField = z
52
61
  .boolean()
@@ -175,6 +184,16 @@ export const toolShapes = {
175
184
  .max(1320)
176
185
  .optional()
177
186
  .describe("Screenshot width in pixels, 160 to 1320 (default 600); keep it the same for a name"),
187
+ threshold: z
188
+ .number()
189
+ .min(0)
190
+ .max(100)
191
+ .optional()
192
+ .describe("The share of the screen, in percent, 0 to 100 (default 0.5), that may change and still match"),
193
+ ignore: z
194
+ .array(z.union([rectSchema, targetSchema]))
195
+ .optional()
196
+ .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
197
  },
179
198
  filmstrip: {
180
199
  frames: z.number().int().min(2).max(9).optional().describe("How many frames (default 6)"),
@@ -219,7 +238,7 @@ export const toolShapes = {
219
238
  .array(stepSchema)
220
239
  .min(1)
221
240
  .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).'),
241
+ .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). 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.'),
223
242
  trace: z.boolean().optional().describe("Also list each step that ran, one line each"),
224
243
  dry_run: z
225
244
  .boolean()
@@ -229,6 +248,78 @@ export const toolShapes = {
229
248
  screenshot: screenshotField,
230
249
  },
231
250
  };
251
+ // ---- compare_screen ---------------------------------------------------------
252
+ const DEFAULT_MATCH_THRESHOLD = 0.5;
253
+ const MAX_REGION_LINES = 8;
254
+ function describeIgnore(item) {
255
+ if (item.id !== undefined)
256
+ return `id=${item.id}`;
257
+ if (item.label !== undefined)
258
+ return `label ${JSON.stringify(item.label)}`;
259
+ if (item.text !== undefined)
260
+ return JSON.stringify(item.text);
261
+ if (item.ref !== undefined)
262
+ return item.ref;
263
+ return `${String(item.x)},${String(item.y)}`;
264
+ }
265
+ /** The pixel rectangles to paint out for `ignore`, from a fresh element list; targets not on the screen give a note. */
266
+ /**
267
+ * An element as a {label} target matches it: its accessibility label, else its text, as the app's SDK matches
268
+ * (labelOf in the SDK). The ignore masks, recorded indexes and expectations use the same rule as the targets.
269
+ */
270
+ function labelOfElement(e) {
271
+ return e.label ?? e.text ?? "";
272
+ }
273
+ function ignoreRects(ignore, elements, scale, size) {
274
+ const rects = [];
275
+ const notes = [];
276
+ const add = (x, y, width, height) => {
277
+ const x0 = Math.max(0, Math.floor(x * scale));
278
+ const y0 = Math.max(0, Math.floor(y * scale));
279
+ const x1 = Math.min(size.width, Math.ceil((x + width) * scale));
280
+ const y1 = Math.min(size.height, Math.ceil((y + height) * scale));
281
+ if (x1 > x0 && y1 > y0)
282
+ rects.push({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
283
+ };
284
+ for (const item of ignore) {
285
+ if ("width" in item) {
286
+ add(item.x, item.y, item.width, item.height);
287
+ continue;
288
+ }
289
+ let found = [];
290
+ if (item.ref !== undefined)
291
+ found = elements.filter((e) => e.ref === item.ref);
292
+ else if (item.id !== undefined)
293
+ found = elements.filter((e) => e.testID === item.id);
294
+ else if (item.label !== undefined) {
295
+ const wanted = item.label.toLowerCase();
296
+ found = elements.filter((e) => labelOfElement(e).toLowerCase() === wanted);
297
+ }
298
+ else if (item.text !== undefined) {
299
+ const wanted = item.text.toLowerCase();
300
+ found = elements.filter((e) => (e.text ?? "").toLowerCase() === wanted);
301
+ if (found.length === 0)
302
+ found = elements.filter((e) => wanted !== "" && (e.text ?? "").toLowerCase().includes(wanted));
303
+ }
304
+ else if (item.x !== undefined && item.y !== undefined) {
305
+ const px = item.x;
306
+ const py = item.y;
307
+ found = elements
308
+ .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)
309
+ .sort((a, b) => (a.frame?.width ?? 0) * (a.frame?.height ?? 0) - (b.frame?.width ?? 0) * (b.frame?.height ?? 0))
310
+ .slice(0, 1);
311
+ }
312
+ found = found.filter((e) => e.frame !== undefined);
313
+ if (item.index !== undefined)
314
+ found = found.slice(item.index, item.index + 1);
315
+ if (found.length === 0)
316
+ notes.push(`ignore ${describeIgnore(item)} isn't on the screen; skipped.`);
317
+ for (const e of found)
318
+ if (e.frame)
319
+ add(e.frame.x, e.frame.y, e.frame.width, e.frame.height);
320
+ }
321
+ return { rects, notes };
322
+ }
232
323
  /**
233
324
  * Under this many points from its first point, a pointer path is a tap or a hold, not a drag.
234
325
  * Tapwright uses the same number, and the viewer page's pending ring (TAP_SLOP in viewer.html)
@@ -238,6 +329,8 @@ const TAP_SLOP = 10;
238
329
  /** Held at least this long, a tap is a long press. */
239
330
  const LONG_PRESS_MS = 500;
240
331
  const SCRIPT_NAME = /^[A-Za-z0-9][\w.-]{0,60}$/;
332
+ /** The answer to a reference name that isn't one: no slashes, so no way out of the references folder. */
333
+ const BAD_REFERENCE_NAME = "Error bad_params: name the reference with letters, digits, '.', '_' and '-'.";
241
334
  const scriptSchema = z.object({
242
335
  version: z.literal(1),
243
336
  name: z.string(),
@@ -561,6 +654,15 @@ export class TaplineTools {
561
654
  personNotePending = false;
562
655
  recording = null;
563
656
  replaying = false;
657
+ /**
658
+ * While a script replays (AGENT-TOOLS 1.9 §14.3): the references folder next to it (looked in first), where a
659
+ * failed check puts its red picture, and whether the checks save instead of compare (`--update-references`).
660
+ */
661
+ replayContext = null;
662
+ /** Where the latest failed check of the running batch saved its picture. */
663
+ diffSaved = null;
664
+ /** The elements the latest comparison painted its ignore list from: a recording saves its mask from the same list. */
665
+ lastMaskElements;
564
666
  /** The screen size in points from the latest list or overlay, for the live viewer's 0..1 positions. */
565
667
  lastScreen;
566
668
  shotCount = 0;
@@ -796,11 +898,26 @@ export class TaplineTools {
796
898
  }
797
899
  const shape = toolShapes[step.tool];
798
900
  const { tool: _tool, expect: _expect, route: _route, ...params } = step;
901
+ if (step.tool === "compare_screen" && params.update !== undefined) {
902
+ return fail(`Error bad_params: step ${i + 1}: update can't be used in run (a step never saves a reference)`);
903
+ }
799
904
  const parsed = z.object(shape).strict().safeParse(params);
800
905
  if (!parsed.success) {
801
906
  return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "params"}: ${issue.message}`).join("; ")}`);
802
907
  }
908
+ // Every reference name is checked before any step runs: a name with a path in it never reaches the disk.
909
+ const names = [step.tool === "compare_screen" ? params.name : undefined, step.expect?.matches];
910
+ if (names.some((name) => typeof name === "string" && !SCRIPT_NAME.test(name.trim()))) {
911
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): name the reference with letters, digits, '.', '_' and '-'.`);
912
+ }
913
+ if (step.expect?.ignore !== undefined) {
914
+ const ignored = ignoreListSchema.safeParse(step.expect.ignore);
915
+ if (!ignored.success) {
916
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): expect.ignore: ${ignored.error.issues.map((issue) => issue.message).join("; ")}`);
917
+ }
918
+ }
803
919
  }
920
+ this.diffSaved = null;
804
921
  if (args.dry_run === true)
805
922
  return this.dryRunBatch(steps);
806
923
  const before = this.unlistedActions.get(session.id) ?? 0;
@@ -832,6 +949,19 @@ export class TaplineTools {
832
949
  stop = { index: i, code: "expect_failed", message: `its action ran, but ${missed}`, rest: [] };
833
950
  break;
834
951
  }
952
+ // A picture check that held is kept in a recording, as compare_screen's is: right after the action, the
953
+ // script makes it that step's `matches` again (AGENT-TOOLS 1.9 §14.2).
954
+ if (step.expect.matches !== undefined) {
955
+ const recorded = await this.recordCompare({
956
+ name: step.expect.matches,
957
+ ...(step.expect.threshold !== undefined ? { threshold: step.expect.threshold } : {}),
958
+ ...(step.expect.ignore !== undefined ? { ignore: ignoreListSchema.parse(step.expect.ignore) } : {}),
959
+ });
960
+ if (recorded !== null) {
961
+ stop = { index: i, ...parseError(recorded) };
962
+ break;
963
+ }
964
+ }
835
965
  }
836
966
  const route = step.route;
837
967
  if (Array.isArray(route) && route.every((part) => typeof part === "string")) {
@@ -855,7 +985,8 @@ export class TaplineTools {
855
985
  const head = stop === null
856
986
  ? `Ran ${total} of ${total} steps in ${((Date.now() - started) / 1000).toFixed(1)} s.`
857
987
  : `Error ${stop.code}: Ran ${ran} of ${total} steps; stopped at step ${stop.index + 1} (${describeRunStep(steps[stop.index])}): ${stop.message}`;
858
- const first = [head, ...trace, ...(stop?.rest ?? [])].join("\n");
988
+ const diffNote = stop !== null && this.diffSaved !== null ? [`Note: the difference is saved at ${this.diffSaved}.`] : [];
989
+ const first = [head, ...trace, ...(stop?.rest ?? []), ...diffNote].join("\n");
859
990
  let response;
860
991
  const live = this.host.current();
861
992
  if (args.look === false || live === undefined) {
@@ -916,6 +1047,8 @@ export class TaplineTools {
916
1047
  return this.waitFor(quiet);
917
1048
  case "app_command":
918
1049
  return this.appCommand(quiet);
1050
+ case "compare_screen":
1051
+ return this.compareStep(rest);
919
1052
  }
920
1053
  }
921
1054
  /** The screen's route names now (one cheap read), or null when the app has no router or didn't answer. */
@@ -928,6 +1061,15 @@ export class TaplineTools {
928
1061
  }
929
1062
  /** Null when the expectation holds within its time; else how it failed. */
930
1063
  async expectMissed(e) {
1064
+ if (e.text !== undefined || e.id !== undefined || e.label !== undefined) {
1065
+ const missed = await this.textExpectMissed(e);
1066
+ if (missed !== null)
1067
+ return missed;
1068
+ }
1069
+ // A picture check comes last, once the rest held.
1070
+ return e.matches === undefined ? null : this.matchMissed(e.matches, e.threshold ?? DEFAULT_MATCH_THRESHOLD, ignoreListSchema.parse(e.ignore ?? []));
1071
+ }
1072
+ async textExpectMissed(e) {
931
1073
  const seconds = e.timeout ?? EXPECT_TIMEOUT_S;
932
1074
  const which = e.text !== undefined ? { text: e.text } : e.id !== undefined ? { id: e.id } : { label: e.label };
933
1075
  const seen = await this.waitFor({ ...which, ...(e.gone === true ? { gone: true } : {}), timeout: Math.max(0.5, seconds), look: false });
@@ -944,7 +1086,7 @@ export class TaplineTools {
944
1086
  for (;;) {
945
1087
  const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
946
1088
  const element = tree.success
947
- ? tree.data.elements.find((el) => (e.id !== undefined ? el.testID === e.id : (el.label ?? "").toLowerCase() === (e.label ?? "").toLowerCase()))
1089
+ ? tree.data.elements.find((el) => (e.id !== undefined ? el.testID === e.id : labelOfElement(el).toLowerCase() === (e.label ?? "").toLowerCase()))
948
1090
  : undefined;
949
1091
  if (element !== undefined) {
950
1092
  actual = typeof element.checked === "boolean" ? (element.checked ? "on" : "off") : typeof element.value === "string" ? element.value : undefined;
@@ -1270,7 +1412,7 @@ export class TaplineTools {
1270
1412
  const same = tree.data.elements.filter((el) => typeof t.testID === "string"
1271
1413
  ? el.testID === t.testID
1272
1414
  : typeof t.label === "string"
1273
- ? (el.label ?? "").toLowerCase() === t.label.toLowerCase()
1415
+ ? labelOfElement(el).toLowerCase() === t.label.toLowerCase()
1274
1416
  : typeof t.text === "string"
1275
1417
  ? el.text === t.text
1276
1418
  : false);
@@ -1555,66 +1697,99 @@ export class TaplineTools {
1555
1697
  else
1556
1698
  content.push({ type: "image", data: image.data, mimeType: image.mime });
1557
1699
  }
1558
- async compareScreen(args) {
1559
- const session = this.host.current();
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 '-'.");
1700
+ /** Where this app's reference pictures are kept, or an error answer. */
1701
+ referenceFolder(session) {
1565
1702
  const dir = this.options.baselinesDir;
1566
1703
  if (dir === undefined)
1567
1704
  return fail("Error internal: no folder for reference pictures is configured.");
1568
- const appFolder = (session.app.id ?? session.app.name).replace(/[^\w.-]/g, "_");
1569
- const path = join(dir, appFolder, `${name}.jpg`);
1705
+ return join(dir, (session.app.id ?? session.app.name).replace(/[^\w.-]/g, "_"));
1706
+ }
1707
+ /** The element list for naming regions and masking, read as a peek: the agent's refs and baseline must stay as they are. */
1708
+ async peekElements(sessionId) {
1709
+ try {
1710
+ const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId }));
1711
+ if (tree.success && tree.data.screen)
1712
+ return { scaleBase: tree.data.screen.width, elements: tree.data.elements };
1713
+ }
1714
+ catch {
1715
+ // positions stay in pixels
1716
+ }
1717
+ return { scaleBase: undefined, elements: [] };
1718
+ }
1719
+ /**
1720
+ * Takes the screen and compares it with the saved reference called `name`, never saving. `referencesDirs` are the
1721
+ * folders to look in, in order. A missing reference hands back the screenshot, so the caller may save it.
1722
+ */
1723
+ async checkAgainstReference(session, name, options) {
1570
1724
  let shot;
1571
1725
  try {
1572
- shot = await this.takeShot(session.id, args.width ?? DEFAULT_SCREENSHOT_WIDTH, 0.9);
1726
+ shot = await this.takeShot(session.id, options.width, 0.9);
1573
1727
  }
1574
1728
  catch (error) {
1575
- return fail(describeError(error));
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.`);
1729
+ return { kind: "error", line: describeError(error) };
1586
1730
  }
1587
- let diff;
1731
+ this.lastMaskElements = undefined;
1732
+ if (!SCRIPT_NAME.test(name))
1733
+ return { kind: "error", line: BAD_REFERENCE_NAME };
1734
+ const path = options.referencesDirs.map((dir) => join(dir, `${name}.jpg`)).find((candidate) => existsSync(candidate));
1735
+ if (path === undefined)
1736
+ return { kind: "missing", shot };
1737
+ let reference;
1588
1738
  try {
1589
- diff = diffImages({ mime: "image/jpeg", data: readFileSync(path).toString("base64") }, shot);
1739
+ reference = { mime: "image/jpeg", data: readFileSync(path).toString("base64") };
1590
1740
  }
1591
1741
  catch (error) {
1592
- return fail(`Error bad_params: ${error instanceof Error ? error.message : String(error)} To start over, use update: true.`);
1742
+ // A reference that can't be read (no permission, a folder in its place) is a failed check, never a crash.
1743
+ 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
1744
  }
1594
- if (diff.changedPixels === 0)
1595
- return text(`No visible change from the reference "${name}".`);
1596
- // Name the regions in points, with the elements they touch, from a fresh element list.
1597
- let scale = 1;
1598
- let elements = [];
1745
+ let refSize;
1746
+ let shotSize;
1599
1747
  try {
1600
- // `peek`: the agent isn't given this list, so its refs and its baseline for changes must stay as they are.
1601
- const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId: session.id }));
1602
- if (tree.success && tree.data.screen) {
1603
- scale = diff.width / tree.data.screen.width;
1604
- elements = tree.data.elements;
1748
+ refSize = imageSize(reference);
1749
+ shotSize = imageSize(shot);
1750
+ }
1751
+ catch (error) {
1752
+ return { kind: "error", line: `Error bad_params: ${error instanceof Error ? error.message : String(error)} To start over, use update: true.` };
1753
+ }
1754
+ if (refSize.width !== shotSize.width || refSize.height !== shotSize.height) {
1755
+ return {
1756
+ kind: "size",
1757
+ 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.`,
1758
+ };
1759
+ }
1760
+ let seen;
1761
+ const notes = [];
1762
+ const mask = [];
1763
+ if (options.ignore.length > 0) {
1764
+ seen = await this.peekElements(session.id);
1765
+ this.lastMaskElements = seen.elements;
1766
+ if (seen.scaleBase === undefined) {
1767
+ return { kind: "error", line: "Error internal: couldn't read the screen's elements to paint out what ignore names." };
1605
1768
  }
1769
+ const masked = ignoreRects(options.ignore, seen.elements, shotSize.width / seen.scaleBase, shotSize);
1770
+ mask.push(...masked.rects);
1771
+ notes.push(...masked.notes);
1606
1772
  }
1607
- catch {
1608
- // positions stay in pixels
1773
+ let diff;
1774
+ try {
1775
+ diff = diffImages(reference, shot, { mask });
1776
+ }
1777
+ catch (error) {
1778
+ return { kind: "error", line: `Error bad_params: ${error instanceof Error ? error.message : String(error)} To start over, use update: true.` };
1609
1779
  }
1780
+ // The pass bar compares the exact share; the rounded percent is only for showing (a few pixels round to 0%).
1781
+ const exactPercent = (diff.changedPixels / Math.max(1, diff.width * diff.height)) * 100;
1782
+ if (diff.changedPixels === 0 || exactPercent <= options.threshold) {
1783
+ return { kind: "match", percent: diff.changedPercent, notes };
1784
+ }
1785
+ // Name the regions in points, with the elements they touch.
1786
+ const found = seen ?? (await this.peekElements(session.id));
1787
+ const scale = found.scaleBase === undefined ? 1 : diff.width / found.scaleBase;
1610
1788
  const unit = scale === 1 ? "pixels" : "points";
1611
- const lines = [
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) {
1789
+ const regions = diff.boxes.map((box) => {
1615
1790
  const b = { x: box.x / scale, y: box.y / scale, width: box.width / scale, height: box.height / scale };
1616
1791
  // The smallest named elements there: a page-sized container overlaps every change.
1617
- const near = elements
1792
+ const near = found.elements
1618
1793
  .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
1794
  .filter((e) => (e.text ?? "") !== "" || (e.label ?? "") !== "" || (e.testID ?? "") !== "")
1620
1795
  .map((e) => ({ e, area: (e.frame?.width ?? 0) * (e.frame?.height ?? 0) }))
@@ -1622,12 +1797,289 @@ export class TaplineTools {
1622
1797
  .sort((x, y) => x.area - y.area)
1623
1798
  .slice(0, 2)
1624
1799
  .map(({ e }) => describeElement(e));
1625
- lines.push(`- @${Math.round(b.x)},${Math.round(b.y)} ${Math.round(b.width)}x${Math.round(b.height)}${near.length > 0 ? `: ${near.join(", ")}` : ""}`);
1800
+ return { at: `@${Math.round(b.x)},${Math.round(b.y)} ${Math.round(b.width)}x${Math.round(b.height)}`, near };
1801
+ });
1802
+ const count = regions.length;
1803
+ const where = `in ${count} region${count === 1 ? "" : "s"} (${unit})`;
1804
+ const lines = [`Differs from the reference "${name}": ${diff.changedPercent}% changed, ${where}:`];
1805
+ for (const region of regions.slice(0, MAX_REGION_LINES)) {
1806
+ lines.push(`- ${region.at}${region.near.length > 0 ? `: ${region.near.join(", ")}` : ""}`);
1807
+ }
1808
+ if (count > MAX_REGION_LINES)
1809
+ lines.push(`(+${count - MAX_REGION_LINES} more regions)`);
1810
+ const listed = regions.slice(0, MAX_REGION_LINES).map((region) => region.at).join(", ");
1811
+ const reason = `${diff.changedPercent}% changed from the reference "${name}" (threshold ${options.threshold}%), ${where}: ${listed}${count > MAX_REGION_LINES ? `, +${count - MAX_REGION_LINES} more` : ""}.`;
1812
+ return { kind: "differs", percent: diff.changedPercent, lines, reason, image: diff.image, notes };
1813
+ }
1814
+ /** Why a check does not hold (for `run`, as a step or as `matches`), or null when it matches. */
1815
+ referenceReason(check, name) {
1816
+ switch (check.kind) {
1817
+ case "match":
1818
+ return null;
1819
+ case "missing":
1820
+ return { code: "expect_failed", message: `no reference "${name}" yet; save one with compare_screen { name: "${name}" }.` };
1821
+ case "size":
1822
+ return { code: "bad_params", message: check.message };
1823
+ case "error": {
1824
+ const { code, message } = parseError(check.line);
1825
+ return { code, message };
1826
+ }
1827
+ case "differs":
1828
+ return { code: "expect_failed", message: check.reason };
1829
+ }
1830
+ }
1831
+ /** Whether the screen matches the reference `name` now (`expect.matches`): null when it does, else the reason. */
1832
+ async matchMissed(name, threshold, ignore) {
1833
+ this.lastMaskElements = undefined;
1834
+ if (!SCRIPT_NAME.test(name))
1835
+ return parseError(BAD_REFERENCE_NAME).message;
1836
+ const session = this.host.current();
1837
+ if (session === undefined)
1838
+ return "the app isn't connected";
1839
+ const folder = this.referenceFolder(session);
1840
+ if (typeof folder !== "string")
1841
+ return parseError(folder.content.map((block) => (block.type === "text" ? block.text : "")).join("\n")).message;
1842
+ if (this.replayContext?.update === true) {
1843
+ // --update-references: the check saves the screen into the script's folder, and holds.
1844
+ const saved = await this.saveReferenceHere(session, name, DEFAULT_SCREENSHOT_WIDTH, this.replayContext.referencesDir);
1845
+ return saved.isError === true ? parseError(saved.content.map((block) => (block.type === "text" ? block.text : "")).join("\n")).message : null;
1846
+ }
1847
+ const check = await this.checkAgainstReference(session, name, {
1848
+ threshold,
1849
+ ignore,
1850
+ width: DEFAULT_SCREENSHOT_WIDTH,
1851
+ referencesDirs: this.referencesDirs(folder),
1852
+ });
1853
+ this.keepDiff(check, name);
1854
+ return this.referenceReason(check, name)?.message ?? null;
1855
+ }
1856
+ /** Where a check looks for `<name>.jpg`: the folder next to a replayed script first, then the app's own store. */
1857
+ referencesDirs(folder) {
1858
+ return this.replayContext !== null ? [this.replayContext.referencesDir, folder] : [folder];
1859
+ }
1860
+ /** In a replay, a failed check leaves its red picture at `<script>.diff/<name>.jpg`. */
1861
+ keepDiff(check, name) {
1862
+ if (this.replayContext === null || check.kind !== "differs" || !SCRIPT_NAME.test(name))
1863
+ return;
1864
+ const path = join(this.replayContext.diffDir, `${name}.jpg`);
1865
+ try {
1866
+ mkdirSync(dirname(path), { recursive: true });
1867
+ writeFileSync(path, Buffer.from(check.image.data, "base64"));
1868
+ this.diffSaved = path;
1869
+ }
1870
+ catch {
1871
+ // the failure still reads without the picture
1872
+ }
1873
+ }
1874
+ /** Takes the screen and saves it as the reference `name` in `dir`. */
1875
+ async saveReferenceHere(session, name, width, dir) {
1876
+ try {
1877
+ return this.writeReference(dir, name, await this.takeShot(session.id, width, 0.9));
1878
+ }
1879
+ catch (error) {
1880
+ return fail(describeError(error));
1881
+ }
1882
+ }
1883
+ writeReference(folder, name, shot) {
1884
+ // Every write checks the name itself, whatever the caller checked: no way out of the references folder.
1885
+ if (!SCRIPT_NAME.test(name))
1886
+ return fail(BAD_REFERENCE_NAME);
1887
+ const path = join(folder, `${name}.jpg`);
1888
+ try {
1889
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1890
+ writeFileSync(path, Buffer.from(shot.data, "base64"), { mode: 0o600 });
1891
+ }
1892
+ catch (error) {
1893
+ return fail(`Error internal: couldn't save the reference: ${error instanceof Error ? error.message : String(error)}`);
1894
+ }
1895
+ let size = `${shot.width ?? "?"}x${shot.height ?? "?"}`;
1896
+ try {
1897
+ const real = imageSize(shot);
1898
+ size = `${real.width}x${real.height}`;
1899
+ }
1900
+ catch {
1901
+ // the app's own size stays
1902
+ }
1903
+ return text(`Saved the reference "${name}" (${size} px).`);
1904
+ }
1905
+ /** A `compare_screen` step of `run`: it never saves; it holds on a match and stops the batch otherwise. */
1906
+ async compareStep(args) {
1907
+ this.lastMaskElements = undefined;
1908
+ const session = this.host.current();
1909
+ if (session === undefined)
1910
+ return this.notConnected();
1911
+ const name = args.name.trim();
1912
+ if (!SCRIPT_NAME.test(name))
1913
+ return fail("Error bad_params: name the reference with letters, digits, '.', '_' and '-'.");
1914
+ const folder = this.referenceFolder(session);
1915
+ if (typeof folder !== "string")
1916
+ return folder;
1917
+ if (this.replayContext?.update === true) {
1918
+ return this.saveReferenceHere(session, name, args.width ?? DEFAULT_SCREENSHOT_WIDTH, this.replayContext.referencesDir);
1919
+ }
1920
+ const check = await this.checkAgainstReference(session, name, {
1921
+ threshold: args.threshold ?? DEFAULT_MATCH_THRESHOLD,
1922
+ ignore: args.ignore ?? [],
1923
+ width: args.width ?? DEFAULT_SCREENSHOT_WIDTH,
1924
+ referencesDirs: this.referencesDirs(folder),
1925
+ });
1926
+ this.keepDiff(check, name);
1927
+ const why = this.referenceReason(check, name);
1928
+ if (why !== null)
1929
+ return fail(`Error ${why.code}: ${why.message}`);
1930
+ if (check.kind !== "match")
1931
+ return fail("Error internal: the comparison gave no answer.");
1932
+ // A check that held inside `run` is kept in a recording too, with its options.
1933
+ const recorded = await this.recordCompare({
1934
+ name,
1935
+ ...(args.threshold !== undefined ? { threshold: args.threshold } : {}),
1936
+ ...(args.ignore !== undefined ? { ignore: args.ignore } : {}),
1937
+ ...(args.width !== undefined ? { width: args.width } : {}),
1938
+ });
1939
+ if (recorded !== null)
1940
+ return fail(recorded);
1941
+ return text([`Matches the reference "${name}": ${check.percent}% changed.`, ...check.notes.map((n) => `Note: ${n}`)].join("\n"));
1942
+ }
1943
+ /**
1944
+ * Saves a visual check that held as a `compareScreen` step of the recording, when one is running: the name, and the
1945
+ * threshold, the ignore list (refs made stable) and the width when given. `update` is never written. Answers an
1946
+ * error line when an ignore ref can't be kept in a script, else null (also when nothing is recording).
1947
+ */
1948
+ async recordCompare(check) {
1949
+ if (this.recording === null || this.replaying)
1950
+ return null;
1951
+ const session = this.host.current();
1952
+ let kept = check.ignore;
1953
+ if (session !== undefined && kept !== undefined && kept.length > 0) {
1954
+ const stable = await this.stableIgnore(session.id, kept, this.lastMaskElements);
1955
+ if (typeof stable === "string")
1956
+ return stable;
1957
+ kept = stable;
1958
+ }
1959
+ if (this.recording === null)
1960
+ return null;
1961
+ this.recording.steps.push({
1962
+ method: "compareScreen",
1963
+ params: {
1964
+ name: check.name.trim(),
1965
+ ...(check.threshold !== undefined ? { threshold: check.threshold } : {}),
1966
+ ...(kept !== undefined && kept.length > 0 ? { ignore: kept } : {}),
1967
+ ...(check.width !== undefined ? { width: check.width } : {}),
1968
+ },
1969
+ });
1970
+ return null;
1971
+ }
1972
+ /**
1973
+ * `ignore` as a script keeps it (AGENT-TOOLS 1.9 §14.2): a ref becomes the element's id, else label, else text,
1974
+ * read from the list now. A string is the error answer when a ref can't be kept.
1975
+ */
1976
+ async stableIgnore(sessionId, ignore, known) {
1977
+ if (!ignore.some((item) => !("width" in item) && item.ref !== undefined))
1978
+ return ignore;
1979
+ // The elements the comparison painted out when it had them, so the script keeps exactly that mask.
1980
+ const seen = known !== undefined ? { elements: known } : await this.peekElements(sessionId);
1981
+ const kept = [];
1982
+ for (const item of ignore) {
1983
+ if ("width" in item || item.ref === undefined) {
1984
+ kept.push(item);
1985
+ continue;
1986
+ }
1987
+ const element = seen.elements.find((e) => e.ref === item.ref);
1988
+ if (element === undefined)
1989
+ return `Error bad_params: ignore ${item.ref} isn't on the screen, so it can't be saved in a script.`;
1990
+ // When several elements share the name, the saved item keeps which one it was (`index`, counted as ignoreRects
1991
+ // counts: elements with a frame, in list order), so a replay paints out that one and not all of them.
1992
+ const framed = seen.elements.filter((e) => e.frame !== undefined);
1993
+ const withIndex = (saved, same) => {
1994
+ const matching = framed.filter(same);
1995
+ const at = matching.indexOf(element);
1996
+ return matching.length > 1 && at >= 0 ? { ...saved, index: at } : saved;
1997
+ };
1998
+ if (typeof element.testID === "string" && element.testID !== "") {
1999
+ const id = element.testID;
2000
+ kept.push(withIndex({ id }, (e) => e.testID === id));
2001
+ }
2002
+ else if (typeof element.label === "string" && element.label !== "") {
2003
+ const label = element.label.toLowerCase();
2004
+ kept.push(withIndex({ label: element.label }, (e) => labelOfElement(e).toLowerCase() === label));
2005
+ }
2006
+ else if (typeof element.text === "string" && element.text !== "") {
2007
+ const text = element.text.toLowerCase();
2008
+ kept.push(withIndex({ text: element.text }, (e) => (e.text ?? "").toLowerCase() === text));
2009
+ }
2010
+ else {
2011
+ 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}.`;
2012
+ }
2013
+ }
2014
+ return kept;
2015
+ }
2016
+ async compareScreen(args) {
2017
+ // Each check starts with no mask list: an update, or a first save, never records from an earlier comparison.
2018
+ this.lastMaskElements = undefined;
2019
+ const recording = this.recording !== null && !this.replaying;
2020
+ const session = this.host.current();
2021
+ // An ignore ref that a script couldn't find again is refused before anything is compared.
2022
+ if (recording && session !== undefined && args.ignore !== undefined && args.ignore.length > 0) {
2023
+ const stable = await this.stableIgnore(session.id, args.ignore);
2024
+ if (typeof stable === "string")
2025
+ return fail(stable);
2026
+ }
2027
+ const answer = await this.compareScreenNow(args);
2028
+ if (recording && answer.isError !== true) {
2029
+ // `update` is never written: the script checks against the reference it carries.
2030
+ const recorded = await this.recordCompare({
2031
+ name: args.name,
2032
+ ...(args.threshold !== undefined ? { threshold: args.threshold } : {}),
2033
+ ...(args.ignore !== undefined ? { ignore: args.ignore } : {}),
2034
+ ...(args.width !== undefined ? { width: args.width } : {}),
2035
+ });
2036
+ if (recorded !== null)
2037
+ return fail(recorded);
2038
+ }
2039
+ return answer;
2040
+ }
2041
+ async compareScreenNow(args) {
2042
+ const session = this.host.current();
2043
+ if (session === undefined)
2044
+ return this.notConnected();
2045
+ const name = args.name.trim();
2046
+ if (!SCRIPT_NAME.test(name))
2047
+ return fail("Error bad_params: name the reference with letters, digits, '.', '_' and '-'.");
2048
+ const folder = this.referenceFolder(session);
2049
+ if (typeof folder !== "string")
2050
+ return folder;
2051
+ const width = args.width ?? DEFAULT_SCREENSHOT_WIDTH;
2052
+ const save = (shot) => this.writeReference(folder, name, shot);
2053
+ if (args.update === true) {
2054
+ try {
2055
+ return save(await this.takeShot(session.id, width, 0.9));
2056
+ }
2057
+ catch (error) {
2058
+ return fail(describeError(error));
2059
+ }
2060
+ }
2061
+ const check = await this.checkAgainstReference(session, name, {
2062
+ threshold: args.threshold ?? DEFAULT_MATCH_THRESHOLD,
2063
+ ignore: args.ignore ?? [],
2064
+ width,
2065
+ referencesDirs: [folder],
2066
+ });
2067
+ switch (check.kind) {
2068
+ case "missing":
2069
+ return save(check.shot);
2070
+ case "error":
2071
+ return fail(check.line);
2072
+ case "size":
2073
+ return fail(`Error bad_params: ${check.message}`);
2074
+ case "match":
2075
+ return text([`Matches the reference "${name}": ${check.percent}% changed.`, ...check.notes.map((n) => `Note: ${n}`)].join("\n"));
2076
+ case "differs": {
2077
+ const lines = [...check.lines, "The image shows the screen now, faded, with the changed pixels in red.", ...check.notes.map((n) => `Note: ${n}`)];
2078
+ const content = [];
2079
+ this.deliver(session.id, check.image, lines, content);
2080
+ return { content: [{ type: "text", text: lines.join("\n") }, ...content] };
2081
+ }
1626
2082
  }
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
2083
  }
1632
2084
  async filmstrip(args) {
1633
2085
  const session = this.host.current();
@@ -1722,7 +2174,10 @@ export class TaplineTools {
1722
2174
  }
1723
2175
  this.recording = null;
1724
2176
  const count = script.steps.length;
1725
- const lines = [`Saved ${count} step${count === 1 ? "" : "s"} to ${path}.`];
2177
+ // AGENT-TOOLS 1.9 §14.3: the references the steps use travel with the script.
2178
+ const copied = this.copyReferences(script.steps, recording.app, path);
2179
+ const withPictures = copied === null ? "" : `, and ${copied.count} reference picture${copied.count === 1 ? "" : "s"} to ${copied.folder}`;
2180
+ const lines = [`Saved ${count} step${count === 1 ? "" : "s"} to ${path}${withPictures}.`];
1726
2181
  const ref = refStep(script.steps);
1727
2182
  if (ref !== null) {
1728
2183
  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 +2193,41 @@ export class TaplineTools {
1738
2193
  }
1739
2194
  }
1740
2195
  }
2196
+ /** `<script dir>/<script name>.references` and `.diff` for a script file. */
2197
+ scriptSideFolder(path, kind) {
2198
+ return join(dirname(path), `${basename(path).replace(/\.json$/i, "")}.${kind}`);
2199
+ }
2200
+ /** Copies each reference the steps use from the app's store next to the script; null when there are none. */
2201
+ copyReferences(steps, app, scriptPath) {
2202
+ const dir = this.options.baselinesDir;
2203
+ if (dir === undefined)
2204
+ return null;
2205
+ const names = new Set();
2206
+ for (const step of steps) {
2207
+ const p = step;
2208
+ if (step.tool === "compare_screen" && typeof p["name"] === "string" && SCRIPT_NAME.test(p["name"]))
2209
+ names.add(p["name"]);
2210
+ if (step.expect?.matches !== undefined && SCRIPT_NAME.test(step.expect.matches))
2211
+ names.add(step.expect.matches);
2212
+ }
2213
+ const store = join(dir, (app.id ?? app.name).replace(/[^\w.-]/g, "_"));
2214
+ const folder = this.scriptSideFolder(scriptPath, "references");
2215
+ let count = 0;
2216
+ for (const name of names) {
2217
+ const from = join(store, `${name}.jpg`);
2218
+ if (!existsSync(from))
2219
+ continue;
2220
+ try {
2221
+ mkdirSync(folder, { recursive: true });
2222
+ copyFileSync(from, join(folder, `${name}.jpg`));
2223
+ count++;
2224
+ }
2225
+ catch {
2226
+ // the script still saved; the picture stays in the store
2227
+ }
2228
+ }
2229
+ return count === 0 ? null : { count, folder };
2230
+ }
1741
2231
  scriptList() {
1742
2232
  const dir = this.options.scriptsDir;
1743
2233
  if (dir === undefined || !existsSync(dir))
@@ -1756,7 +2246,7 @@ export class TaplineTools {
1756
2246
  }
1757
2247
  return lines.length === 0 ? "No saved scripts." : ["Saved scripts:", ...lines].join("\n");
1758
2248
  }
1759
- async replay(args) {
2249
+ async replay(args, options = {}) {
1760
2250
  const name = (args.name ?? "").trim();
1761
2251
  const given = (args.path ?? "").trim();
1762
2252
  if (name === "" && given === "")
@@ -1800,6 +2290,11 @@ export class TaplineTools {
1800
2290
  notes.push("Note: the app hadn't reconnected after starting fresh when the steps began.");
1801
2291
  }
1802
2292
  this.replaying = true;
2293
+ this.replayContext = {
2294
+ referencesDir: this.scriptSideFolder(path, "references"),
2295
+ diffDir: this.scriptSideFolder(path, "diff"),
2296
+ update: options.updateReferences === true,
2297
+ };
1803
2298
  try {
1804
2299
  const answer = await this.run({ steps: script.steps, look: args.look, screenshot: args.screenshot });
1805
2300
  if (notes.length === 0)
@@ -1811,6 +2306,7 @@ export class TaplineTools {
1811
2306
  }
1812
2307
  finally {
1813
2308
  this.replaying = false;
2309
+ this.replayContext = null;
1814
2310
  }
1815
2311
  }
1816
2312
  async withLook(session, line, screenshot = false) {