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/cli.js +9 -6
- package/dist/cli.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 +1 -1
- package/dist/run.d.ts +7 -1
- package/dist/run.js +13 -4
- package/dist/run.js.map +1 -1
- package/dist/script.d.ts +8 -0
- package/dist/script.js +27 -0
- package/dist/script.js.map +1 -1
- package/dist/tool-text-data.d.ts +3 -3
- package/dist/tool-text-data.js +3 -3
- package/dist/tool-text-data.js.map +1 -1
- package/dist/tools.d.ts +69 -1
- package/dist/tools.js +551 -55
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/tapline/SKILL.md +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 '-'.");
|
|
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
|
-
|
|
1569
|
-
|
|
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,
|
|
1726
|
+
shot = await this.takeShot(session.id, options.width, 0.9);
|
|
1573
1727
|
}
|
|
1574
1728
|
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.`);
|
|
1729
|
+
return { kind: "error", line: describeError(error) };
|
|
1586
1730
|
}
|
|
1587
|
-
|
|
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
|
-
|
|
1739
|
+
reference = { mime: "image/jpeg", data: readFileSync(path).toString("base64") };
|
|
1590
1740
|
}
|
|
1591
1741
|
catch (error) {
|
|
1592
|
-
|
|
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
|
-
|
|
1595
|
-
|
|
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
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
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
|
-
|
|
1608
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|