touchpress 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -0
- package/dist/core/index.d.mts +71 -0
- package/dist/core/index.mjs +2 -0
- package/dist/index.d.mts +76 -0
- package/dist/index.mjs +439 -0
- package/dist/preflight--Z-REtl-.d.mts +853 -0
- package/dist/preflight-C83jCNPs.mjs +1865 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1865 @@
|
|
|
1
|
+
import { createAgentDeviceClient, normalizeAgentDeviceError } from "agent-device";
|
|
2
|
+
import pixelmatch from "pixelmatch";
|
|
3
|
+
import { PNG } from "pngjs";
|
|
4
|
+
//#region src/core/errors.ts
|
|
5
|
+
var TangereError = class extends Error {
|
|
6
|
+
info;
|
|
7
|
+
constructor(info) {
|
|
8
|
+
super(formatError(info));
|
|
9
|
+
this.name = "TangereError";
|
|
10
|
+
this.info = info;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
function formatError(info) {
|
|
14
|
+
switch (info.kind) {
|
|
15
|
+
case "config": return `Invalid tangere option: use.${info.field} ${info.detail}`;
|
|
16
|
+
case "device-in-use": return [
|
|
17
|
+
`Device ${info.device} is held by ${info.owner === null ? "another session" : `session "${info.owner}"`}.`,
|
|
18
|
+
`Release it with: ${info.releaseCommand}`,
|
|
19
|
+
...info.canReclaim ? ["Or set use.onDeviceInUse to 'reclaim'."] : []
|
|
20
|
+
].join("\n");
|
|
21
|
+
case "launch-failed": return `Could not open ${info.app} on ${info.device}: ${describeFailure(info.failure)}`;
|
|
22
|
+
case "not-ready": return [
|
|
23
|
+
`App launched but never became ready.`,
|
|
24
|
+
`Waited ${String(info.timeoutMs)}ms for readyWhen: ${info.locator}`,
|
|
25
|
+
``,
|
|
26
|
+
`Screen:`,
|
|
27
|
+
info.screen
|
|
28
|
+
].join("\n");
|
|
29
|
+
case "session-closed": return `Cannot ${info.command}: the device session is closed.`;
|
|
30
|
+
case "strict-mode": return [
|
|
31
|
+
`Locator resolved to ${String(info.matches.length)} nodes but an action needs exactly one.`,
|
|
32
|
+
``,
|
|
33
|
+
`Locator: ${info.locator}`,
|
|
34
|
+
`Matches:`,
|
|
35
|
+
...info.matches.map((match) => ` ${match}`),
|
|
36
|
+
``,
|
|
37
|
+
`Narrow it with getByRole, or take one deliberately with .first() or .nth(n).`,
|
|
38
|
+
``,
|
|
39
|
+
`Screen:`,
|
|
40
|
+
info.screen
|
|
41
|
+
].join("\n");
|
|
42
|
+
case "not-found": return [
|
|
43
|
+
`Locator never resolved to a node within ${String(info.timeoutMs)}ms.`,
|
|
44
|
+
``,
|
|
45
|
+
`Locator: ${info.locator}`,
|
|
46
|
+
...info.scrolled === null ? [] : [`Scrolled: ${describeTrail(info.scrolled)}`],
|
|
47
|
+
``,
|
|
48
|
+
`Screen:`,
|
|
49
|
+
info.screen
|
|
50
|
+
].join("\n");
|
|
51
|
+
case "fill-unconfirmed": return [
|
|
52
|
+
`fill left the field holding something else after ${String(info.attempts)} attempts in ${String(info.timeoutMs)}ms.`,
|
|
53
|
+
``,
|
|
54
|
+
`Locator: ${info.locator}`,
|
|
55
|
+
`Expected value: ${describeValue(info.expected)}${info.expected.kind === "masked" ? " (a secure field reports a mask, not its contents)" : ""}`,
|
|
56
|
+
`Actual value: ${info.actual === null ? "the locator stopped resolving" : describeValue(info.actual)}`,
|
|
57
|
+
``,
|
|
58
|
+
`Screen:`,
|
|
59
|
+
info.screen
|
|
60
|
+
].join("\n");
|
|
61
|
+
case "driver": return `${info.command} failed: ${describeFailure(info.failure)}`;
|
|
62
|
+
default: throw new Error(`unhandled error info ${JSON.stringify(info)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function describeTrail(trail) {
|
|
66
|
+
return `${String(trail.steps)} step${trail.steps === 1 ? "" : "s"} ${trail.direction}`;
|
|
67
|
+
}
|
|
68
|
+
/** A masked value is reported by its length alone, which is all a secure or secret field may disclose. */
|
|
69
|
+
function describeValue(value) {
|
|
70
|
+
switch (value.kind) {
|
|
71
|
+
case "exact": return `"${value.value}"`;
|
|
72
|
+
case "masked": return `${String(value.length)} characters`;
|
|
73
|
+
default: throw new Error(`unhandled expected value ${JSON.stringify(value)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function describeFailure(failure) {
|
|
77
|
+
switch (failure.kind) {
|
|
78
|
+
case "device-busy": return `device is in use${failure.owner === null ? "" : ` by session "${failure.owner}"`} (${failure.detail})`;
|
|
79
|
+
case "device-missing": return `no matching device is booted (${failure.detail})`;
|
|
80
|
+
case "app-missing": return `the app is not installed on this device (${failure.detail})`;
|
|
81
|
+
case "session-rebound": return `the session is already bound to ${failure.boundTo} (${failure.detail})`;
|
|
82
|
+
case "stale-ref": return `the screen changed before the action reached it (${failure.detail})`;
|
|
83
|
+
case "ambiguous": return `the driver matched more than one element (${failure.detail})`;
|
|
84
|
+
case "timeout": return `the driver timed out (${failure.detail})`;
|
|
85
|
+
case "unknown": return `${failure.code}: ${failure.detail}${failure.logPath === null ? "" : `\nDiagnostics: ${failure.logPath}`}`;
|
|
86
|
+
default: throw new Error(`unhandled failure ${JSON.stringify(failure)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/core/query.ts
|
|
91
|
+
/** Applied to both sides of every string comparison, so a label wrapped across lines still matches one typed on one line. */
|
|
92
|
+
function normalizeText(raw) {
|
|
93
|
+
return raw.replace(/\s+/g, " ").trim();
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Playwright's default text semantics: case-insensitive substring after
|
|
97
|
+
* whitespace normalization. `exact` is whole-string and case-sensitive, still
|
|
98
|
+
* normalized.
|
|
99
|
+
*/
|
|
100
|
+
function textMatch(value, exact) {
|
|
101
|
+
if (value instanceof RegExp) return {
|
|
102
|
+
kind: "regex",
|
|
103
|
+
value
|
|
104
|
+
};
|
|
105
|
+
return exact === true ? {
|
|
106
|
+
kind: "exact",
|
|
107
|
+
value: normalizeText(value)
|
|
108
|
+
} : {
|
|
109
|
+
kind: "substring",
|
|
110
|
+
value: normalizeText(value)
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function matchesText(match, candidate) {
|
|
114
|
+
if (candidate === null) return false;
|
|
115
|
+
switch (match.kind) {
|
|
116
|
+
case "exact": return candidate === match.value;
|
|
117
|
+
case "substring": return candidate.toLowerCase().includes(match.value.toLowerCase());
|
|
118
|
+
case "regex": return match.value.test(candidate);
|
|
119
|
+
default: throw new Error(`unhandled text match ${JSON.stringify(match)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Renders a query back into the factory call that produces it, so the `Locator:`
|
|
124
|
+
* line of a failure reads like the line the author wrote.
|
|
125
|
+
*/
|
|
126
|
+
function describeQuery(query) {
|
|
127
|
+
const suffix = `${describeFilters(query.filters)}${describeIndex(query.index)}`;
|
|
128
|
+
const fields = describeExtraFields(query);
|
|
129
|
+
const plain = fields.length === 0 && query.value === void 0;
|
|
130
|
+
if (plain && query.testId !== void 0 && query.name === void 0 && query.role === void 0) return `getByTestId(${describeMatch(query.testId)})${suffix}`;
|
|
131
|
+
if (plain && query.role !== void 0 && query.testId === void 0) {
|
|
132
|
+
const name = query.name === void 0 ? "" : `, { name: ${describeMatch(query.name)}${describeExact(query.name)} }`;
|
|
133
|
+
return `getByRole('${query.role}'${name})${suffix}`;
|
|
134
|
+
}
|
|
135
|
+
if (plain && query.name !== void 0 && query.testId === void 0 && query.role === void 0) {
|
|
136
|
+
const exact = query.name.kind === "exact" ? ", { exact: true }" : "";
|
|
137
|
+
return `getByText(${describeMatch(query.name)}${exact})${suffix}`;
|
|
138
|
+
}
|
|
139
|
+
return `locator({ ${[
|
|
140
|
+
query.testId === void 0 ? null : `testId: ${describeMatch(query.testId)}`,
|
|
141
|
+
query.name === void 0 ? null : `name: ${describeMatch(query.name)}`,
|
|
142
|
+
query.value === void 0 ? null : `value: ${describeMatch(query.value)}`,
|
|
143
|
+
query.role === void 0 ? null : `role: '${query.role}'`,
|
|
144
|
+
...fields
|
|
145
|
+
].filter((part) => part !== null).join(", ")} })${suffix}`;
|
|
146
|
+
}
|
|
147
|
+
function describeExtraFields(query) {
|
|
148
|
+
return [
|
|
149
|
+
query.enabled === void 0 ? null : `enabled: ${String(query.enabled)}`,
|
|
150
|
+
query.selected === void 0 ? null : `selected: ${String(query.selected)}`,
|
|
151
|
+
query.focused === void 0 ? null : `focused: ${String(query.focused)}`,
|
|
152
|
+
query.where === void 0 ? null : "where: <predicate>"
|
|
153
|
+
].filter((part) => part !== null);
|
|
154
|
+
}
|
|
155
|
+
function describeFilters(filters) {
|
|
156
|
+
if (filters === void 0) return "";
|
|
157
|
+
return filters.map((filter) => describeFilter(filter)).join("");
|
|
158
|
+
}
|
|
159
|
+
function describeFilter(filter) {
|
|
160
|
+
const fields = [
|
|
161
|
+
filter.hasText === void 0 ? null : `hasText: ${describeMatch(filter.hasText)}`,
|
|
162
|
+
filter.hasNotText === void 0 ? null : `hasNotText: ${describeMatch(filter.hasNotText)}`,
|
|
163
|
+
filter.has === void 0 ? null : `has: ${describeQuery(filter.has)}`,
|
|
164
|
+
filter.hasNot === void 0 ? null : `hasNot: ${describeQuery(filter.hasNot)}`
|
|
165
|
+
].filter((part) => part !== null);
|
|
166
|
+
return fields.length === 0 ? ".filter({})" : `.filter({ ${fields.join(", ")} })`;
|
|
167
|
+
}
|
|
168
|
+
function describeExact(match) {
|
|
169
|
+
return match.kind === "exact" ? ", exact: true" : "";
|
|
170
|
+
}
|
|
171
|
+
/** A match rendered back into the literal an author would have typed. */
|
|
172
|
+
function describeMatch(match) {
|
|
173
|
+
return match.kind === "regex" ? String(match.value) : `'${match.value}'`;
|
|
174
|
+
}
|
|
175
|
+
function describeIndex(index) {
|
|
176
|
+
if (index === void 0) return "";
|
|
177
|
+
return index === 0 ? ".first()" : `.nth(${String(index)})`;
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/core/config.ts
|
|
181
|
+
/**
|
|
182
|
+
* The option fixtures declare these as their defaults and the parser falls back
|
|
183
|
+
* to the same values, so a caller that reaches the parser without the fixtures,
|
|
184
|
+
* such as `preflight`, resolves identically.
|
|
185
|
+
*/
|
|
186
|
+
const TANGERE_DEFAULTS = {
|
|
187
|
+
relaunch: "per-test",
|
|
188
|
+
onDeviceInUse: "fail",
|
|
189
|
+
settleQuietMs: 500,
|
|
190
|
+
launchTimeout: 9e4,
|
|
191
|
+
dismissDevOverlay: false,
|
|
192
|
+
evidence: "on-failure",
|
|
193
|
+
sessionPrefix: "tangere"
|
|
194
|
+
};
|
|
195
|
+
const DEFAULT_ACTION_TIMEOUT_MS = 1e4;
|
|
196
|
+
const ROLES = [
|
|
197
|
+
"application",
|
|
198
|
+
"window",
|
|
199
|
+
"button",
|
|
200
|
+
"text",
|
|
201
|
+
"text-field",
|
|
202
|
+
"secure-text-field",
|
|
203
|
+
"link",
|
|
204
|
+
"image",
|
|
205
|
+
"switch",
|
|
206
|
+
"slider",
|
|
207
|
+
"tab-bar",
|
|
208
|
+
"scroll-area",
|
|
209
|
+
"cell",
|
|
210
|
+
"alert",
|
|
211
|
+
"other"
|
|
212
|
+
];
|
|
213
|
+
/**
|
|
214
|
+
* The config boundary. Options arrive as `unknown` because a project can omit
|
|
215
|
+
* any of them, or be written in JavaScript, and because `actionTimeout` rides
|
|
216
|
+
* along from Playwright's own options. Every message names the key to fix.
|
|
217
|
+
*/
|
|
218
|
+
function parseDeviceOptions(raw) {
|
|
219
|
+
const platform = read(raw, "platform");
|
|
220
|
+
if (platform !== "ios" && platform !== "android") throw fail("platform", "must be 'ios' or 'android'.");
|
|
221
|
+
const app = read(raw, "app");
|
|
222
|
+
if (typeof app !== "string" || app.length === 0) throw fail("app", "must be the bundle id or package name of the app under test.");
|
|
223
|
+
return {
|
|
224
|
+
platform,
|
|
225
|
+
app,
|
|
226
|
+
readyWhen: parseReadyWhen(read(raw, "readyWhen")),
|
|
227
|
+
device: parseDeviceChoice(read(raw, "deviceName")),
|
|
228
|
+
launchUrl: optionalText("launchUrl", read(raw, "launchUrl")),
|
|
229
|
+
relaunch: oneOf("relaunch", read(raw, "relaunch"), ["per-test", "per-worker"], TANGERE_DEFAULTS.relaunch),
|
|
230
|
+
onDeviceInUse: oneOf("onDeviceInUse", read(raw, "onDeviceInUse"), ["fail", "reclaim"], TANGERE_DEFAULTS.onDeviceInUse),
|
|
231
|
+
actionTimeout: parseActionTimeout(read(raw, "actionTimeout")),
|
|
232
|
+
settleQuietMs: positive("settleQuietMs", read(raw, "settleQuietMs"), TANGERE_DEFAULTS.settleQuietMs),
|
|
233
|
+
launchTimeout: positive("launchTimeout", read(raw, "launchTimeout"), TANGERE_DEFAULTS.launchTimeout),
|
|
234
|
+
dismissDevOverlay: flag("dismissDevOverlay", read(raw, "dismissDevOverlay"), TANGERE_DEFAULTS.dismissDevOverlay),
|
|
235
|
+
evidence: oneOf("evidence", read(raw, "evidence"), [
|
|
236
|
+
"on-failure",
|
|
237
|
+
"always",
|
|
238
|
+
"off"
|
|
239
|
+
], TANGERE_DEFAULTS.evidence),
|
|
240
|
+
sessionPrefix: text("sessionPrefix", read(raw, "sessionPrefix"), TANGERE_DEFAULTS.sessionPrefix)
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** A non-object source reads as every key unset, so a caller that passes nothing fails on the first required key. */
|
|
244
|
+
function read(source, key) {
|
|
245
|
+
if (typeof source !== "object" || source === null) return void 0;
|
|
246
|
+
return key in source ? Reflect.get(source, key) : void 0;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Playwright's own `use.actionTimeout`, not one of tangere's. Playwright defaults
|
|
250
|
+
* it to 0, which means "no timeout" there and would mean "give up at once" here,
|
|
251
|
+
* so 0 falls back the way an unset value does.
|
|
252
|
+
*/
|
|
253
|
+
function parseActionTimeout(value) {
|
|
254
|
+
if (value === 0) return DEFAULT_ACTION_TIMEOUT_MS;
|
|
255
|
+
return positive("actionTimeout", value, DEFAULT_ACTION_TIMEOUT_MS);
|
|
256
|
+
}
|
|
257
|
+
function parseReadyWhen(raw) {
|
|
258
|
+
if (typeof raw !== "object" || raw === null) throw fail("readyWhen", "is required. Name something that only appears once the bundle has loaded, such as { text: 'Welcome' }.");
|
|
259
|
+
const wanted = read(raw, "text");
|
|
260
|
+
if (wanted !== void 0) {
|
|
261
|
+
if (typeof wanted !== "string" || wanted.length === 0) throw fail("readyWhen.text", "must be a non-empty string.");
|
|
262
|
+
return { name: textMatch(wanted, flag("readyWhen.exact", read(raw, "exact"), false)) };
|
|
263
|
+
}
|
|
264
|
+
const testId = read(raw, "testId");
|
|
265
|
+
if (testId !== void 0) {
|
|
266
|
+
if (typeof testId !== "string" || testId.length === 0) throw fail("readyWhen.testId", "must be a non-empty string.");
|
|
267
|
+
return { testId: textMatch(testId, true) };
|
|
268
|
+
}
|
|
269
|
+
const wantedRole = read(raw, "role");
|
|
270
|
+
const role = ROLES.find((candidate) => candidate === wantedRole);
|
|
271
|
+
if (role === void 0) throw fail("readyWhen", "must be one of { text }, { testId }, or { role, name }.");
|
|
272
|
+
const name = read(raw, "name");
|
|
273
|
+
if (name === void 0) return { role };
|
|
274
|
+
if (typeof name !== "string") throw fail("readyWhen.name", "must be a string.");
|
|
275
|
+
return {
|
|
276
|
+
role,
|
|
277
|
+
name: textMatch(name)
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function parseDeviceChoice(raw) {
|
|
281
|
+
if (raw === void 0) return { kind: "first-booted" };
|
|
282
|
+
if (typeof raw === "string") {
|
|
283
|
+
if (raw.length === 0) throw fail("deviceName", "must not be empty.");
|
|
284
|
+
return {
|
|
285
|
+
kind: "named",
|
|
286
|
+
name: raw
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (!isStringArray(raw) || raw.length === 0) throw fail("deviceName", "must be a device name or a non-empty array of device names.");
|
|
290
|
+
return {
|
|
291
|
+
kind: "pool",
|
|
292
|
+
names: raw
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function isStringArray(raw) {
|
|
296
|
+
return Array.isArray(raw) && raw.every((entry) => typeof entry === "string");
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Anything but a pool serves slot 0 only. Two workers pointed at one device both
|
|
300
|
+
* try to claim it, and because leftovers carrying this library's session prefix
|
|
301
|
+
* are always reclaimed, the second worker would close the first worker's live
|
|
302
|
+
* session mid-test. That has to be a config error, not a race.
|
|
303
|
+
*/
|
|
304
|
+
function deviceNameForSlot(options, slot) {
|
|
305
|
+
const choice = options.device;
|
|
306
|
+
switch (choice.kind) {
|
|
307
|
+
case "first-booted":
|
|
308
|
+
if (slot > 0) throw tooFewDevices("is unset, so every worker would target the same booted device", slot);
|
|
309
|
+
return null;
|
|
310
|
+
case "named":
|
|
311
|
+
if (slot > 0) throw tooFewDevices(`names one device, "${choice.name}"`, slot);
|
|
312
|
+
return choice.name;
|
|
313
|
+
case "pool": {
|
|
314
|
+
const name = choice.names[slot];
|
|
315
|
+
if (name === void 0) throw tooFewDevices(`lists ${String(choice.names.length)} devices`, slot);
|
|
316
|
+
return name;
|
|
317
|
+
}
|
|
318
|
+
default: throw new Error(`unhandled device choice ${JSON.stringify(choice)}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function tooFewDevices(problem, slot) {
|
|
322
|
+
return fail("deviceName", `${problem}, but Playwright asked for worker slot ${String(slot)}. List one device name per worker, or set \`workers: 1\`.`);
|
|
323
|
+
}
|
|
324
|
+
function oneOf(field, value, allowed, fallback) {
|
|
325
|
+
if (value === void 0) return fallback;
|
|
326
|
+
const found = allowed.find((candidate) => candidate === value);
|
|
327
|
+
if (found === void 0) throw fail(field, `must be one of ${allowed.map((one) => `'${one}'`).join(", ")}.`);
|
|
328
|
+
return found;
|
|
329
|
+
}
|
|
330
|
+
function positive(field, value, fallback) {
|
|
331
|
+
if (value === void 0) return fallback;
|
|
332
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw fail(field, "must be a positive number of milliseconds.");
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
function flag(field, value, fallback) {
|
|
336
|
+
if (value === void 0) return fallback;
|
|
337
|
+
if (typeof value !== "boolean") throw fail(field, "must be true or false.");
|
|
338
|
+
return value;
|
|
339
|
+
}
|
|
340
|
+
function optionalText(field, value) {
|
|
341
|
+
if (value === void 0) return null;
|
|
342
|
+
if (typeof value !== "string" || value.length === 0) throw fail(field, "must be a non-empty string.");
|
|
343
|
+
return value;
|
|
344
|
+
}
|
|
345
|
+
function text(field, value, fallback) {
|
|
346
|
+
if (value === void 0) return fallback;
|
|
347
|
+
if (typeof value !== "string" || value.length === 0) throw fail(field, "must be a non-empty string.");
|
|
348
|
+
return value;
|
|
349
|
+
}
|
|
350
|
+
function fail(field, detail) {
|
|
351
|
+
return new TangereError({
|
|
352
|
+
kind: "config",
|
|
353
|
+
field,
|
|
354
|
+
detail
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region src/core/checks.ts
|
|
359
|
+
/**
|
|
360
|
+
* A `many` outcome never passes anything but `toHaveCount`. An ambiguous locator
|
|
361
|
+
* is a strictness violation, and it reports through the same message path as a
|
|
362
|
+
* plain mismatch rather than guessing which node was meant.
|
|
363
|
+
*/
|
|
364
|
+
function evaluate(check, resolution) {
|
|
365
|
+
if (check.name === "toHaveCount") {
|
|
366
|
+
const count = countOf(resolution);
|
|
367
|
+
return {
|
|
368
|
+
pass: count === check.expected,
|
|
369
|
+
actual: String(count)
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (resolution.outcome === "none") return {
|
|
373
|
+
pass: false,
|
|
374
|
+
actual: null
|
|
375
|
+
};
|
|
376
|
+
if (resolution.outcome === "many") return {
|
|
377
|
+
pass: false,
|
|
378
|
+
actual: `${String(resolution.nodes.length)} matching nodes`
|
|
379
|
+
};
|
|
380
|
+
const node = resolution.node;
|
|
381
|
+
switch (check.name) {
|
|
382
|
+
case "toBeVisible": return {
|
|
383
|
+
pass: true,
|
|
384
|
+
actual: describeNode(node)
|
|
385
|
+
};
|
|
386
|
+
case "toHaveText": {
|
|
387
|
+
const text = node.name ?? node.value;
|
|
388
|
+
return {
|
|
389
|
+
pass: matchesText(check.expected, text),
|
|
390
|
+
actual: text === null ? null : `"${text}"`
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
case "toHaveValue": return {
|
|
394
|
+
pass: matchesText(check.expected, node.value),
|
|
395
|
+
actual: node.value === null ? null : `"${node.value}"`
|
|
396
|
+
};
|
|
397
|
+
case "toBeEnabled": return {
|
|
398
|
+
pass: node.enabled,
|
|
399
|
+
actual: node.enabled ? "enabled" : "disabled"
|
|
400
|
+
};
|
|
401
|
+
case "toBeSelected": return {
|
|
402
|
+
pass: node.selected,
|
|
403
|
+
actual: node.selected ? "selected" : "not selected"
|
|
404
|
+
};
|
|
405
|
+
case "toBeFocused": return {
|
|
406
|
+
pass: node.focused,
|
|
407
|
+
actual: node.focused ? "focused" : "not focused"
|
|
408
|
+
};
|
|
409
|
+
default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function countOf(resolution) {
|
|
413
|
+
switch (resolution.outcome) {
|
|
414
|
+
case "one": return 1;
|
|
415
|
+
case "none": return 0;
|
|
416
|
+
case "many": return resolution.nodes.length;
|
|
417
|
+
default: throw new Error(`unhandled resolution ${JSON.stringify(resolution)}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** The `Expected:` line. */
|
|
421
|
+
function describeCheck(check) {
|
|
422
|
+
switch (check.name) {
|
|
423
|
+
case "toBeVisible": return "visible";
|
|
424
|
+
case "toHaveText": return `text ${describeExpected(check.expected)}`;
|
|
425
|
+
case "toHaveValue": return `value ${describeExpected(check.expected)}`;
|
|
426
|
+
case "toBeEnabled": return "enabled";
|
|
427
|
+
case "toBeSelected": return "selected";
|
|
428
|
+
case "toBeFocused": return "focused";
|
|
429
|
+
case "toHaveCount": return `count ${String(check.expected)}`;
|
|
430
|
+
default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function describeExpected(match) {
|
|
434
|
+
return match.kind === "regex" ? String(match.value) : `"${match.value}"`;
|
|
435
|
+
}
|
|
436
|
+
function describeNode(node) {
|
|
437
|
+
const name = node.name === null ? "" : ` "${node.name}"`;
|
|
438
|
+
return `${node.ref} [${node.role}]${name}`;
|
|
439
|
+
}
|
|
440
|
+
//#endregion
|
|
441
|
+
//#region src/core/screen.ts
|
|
442
|
+
const IOS_ROLES = {
|
|
443
|
+
Application: "application",
|
|
444
|
+
Window: "window",
|
|
445
|
+
Button: "button",
|
|
446
|
+
StaticText: "text",
|
|
447
|
+
TextView: "text",
|
|
448
|
+
TextField: "text-field",
|
|
449
|
+
SearchField: "text-field",
|
|
450
|
+
SecureTextField: "secure-text-field",
|
|
451
|
+
Link: "link",
|
|
452
|
+
Image: "image",
|
|
453
|
+
Icon: "image",
|
|
454
|
+
Switch: "switch",
|
|
455
|
+
Toggle: "switch",
|
|
456
|
+
Slider: "slider",
|
|
457
|
+
TabBar: "tab-bar",
|
|
458
|
+
Tab: "button",
|
|
459
|
+
ScrollView: "scroll-area",
|
|
460
|
+
ScrollArea: "scroll-area",
|
|
461
|
+
Table: "scroll-area",
|
|
462
|
+
CollectionView: "scroll-area",
|
|
463
|
+
Cell: "cell",
|
|
464
|
+
Alert: "alert",
|
|
465
|
+
Sheet: "alert",
|
|
466
|
+
Other: "other"
|
|
467
|
+
};
|
|
468
|
+
/**
|
|
469
|
+
* Observed on a booted Android emulator (API 36) snapshotting this repo's React
|
|
470
|
+
* Native 0.86 sample app, except the trailing widget block, which this app never
|
|
471
|
+
* renders and stays a plausible guess. `android.view.ViewGroup` is what every
|
|
472
|
+
* React Native `View` reports, testID containers included, so it is stated here
|
|
473
|
+
* rather than left to the `other` fall-through in `roleOf`.
|
|
474
|
+
*/
|
|
475
|
+
const ANDROID_ROLES = {
|
|
476
|
+
"android.widget.Button": "button",
|
|
477
|
+
"android.widget.TextView": "text",
|
|
478
|
+
"android.widget.EditText": "text-field",
|
|
479
|
+
"android.widget.ImageView": "image",
|
|
480
|
+
"android.widget.ScrollView": "scroll-area",
|
|
481
|
+
"android.view.ViewGroup": "other",
|
|
482
|
+
"android.widget.FrameLayout": "other",
|
|
483
|
+
"android.widget.LinearLayout": "other",
|
|
484
|
+
"android.widget.ImageButton": "button",
|
|
485
|
+
"android.widget.Switch": "switch",
|
|
486
|
+
"android.widget.CheckBox": "switch",
|
|
487
|
+
"android.widget.SeekBar": "slider",
|
|
488
|
+
"android.widget.HorizontalScrollView": "scroll-area",
|
|
489
|
+
"androidx.recyclerview.widget.RecyclerView": "scroll-area"
|
|
490
|
+
};
|
|
491
|
+
function roleOf(rawType, platform) {
|
|
492
|
+
const table = platform === "ios" ? IOS_ROLES : ANDROID_ROLES;
|
|
493
|
+
const direct = table[rawType];
|
|
494
|
+
if (direct !== void 0) return direct;
|
|
495
|
+
return table[rawType.slice(rawType.lastIndexOf(".") + 1)] ?? "other";
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The parse boundary. Everything past it trusts its types.
|
|
499
|
+
*
|
|
500
|
+
* `inheritsLabel` and `inheritsIdentifier` mean the driver omitted a value that
|
|
501
|
+
* string-equals the nearest ancestor's, so those are restored here rather than
|
|
502
|
+
* leaving a hole a matcher would read as absent.
|
|
503
|
+
*/
|
|
504
|
+
function parseScreen(raw, platform) {
|
|
505
|
+
const nodes = [];
|
|
506
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
507
|
+
for (const source of raw.nodes) {
|
|
508
|
+
const parent = source.parentIndex === void 0 ? null : byIndex.get(source.parentIndex) ?? null;
|
|
509
|
+
const rawType = source.type ?? source.role ?? "Other";
|
|
510
|
+
const node = {
|
|
511
|
+
ref: source.ref.startsWith("@") ? source.ref : `@${source.ref}`,
|
|
512
|
+
index: source.index,
|
|
513
|
+
parent,
|
|
514
|
+
depth: source.depth ?? (parent === null ? 0 : parent.depth + 1),
|
|
515
|
+
role: roleOf(rawType, platform),
|
|
516
|
+
rawType,
|
|
517
|
+
name: inherited(source.label, source.inheritsLabel, parent, (a) => a.name),
|
|
518
|
+
value: parsedValue(source),
|
|
519
|
+
testId: inherited(source.identifier, source.inheritsIdentifier, parent, (a) => a.testId),
|
|
520
|
+
rect: source.rect ?? null,
|
|
521
|
+
enabled: source.enabled ?? true,
|
|
522
|
+
selected: source.selected ?? false,
|
|
523
|
+
focused: source.focused ?? false,
|
|
524
|
+
hiddenContentAbove: source.hiddenContentAbove ?? false,
|
|
525
|
+
hiddenContentBelow: source.hiddenContentBelow ?? false
|
|
526
|
+
};
|
|
527
|
+
nodes.push(node);
|
|
528
|
+
byIndex.set(node.index, node);
|
|
529
|
+
}
|
|
530
|
+
return Object.freeze({
|
|
531
|
+
nodes: Object.freeze(nodes),
|
|
532
|
+
generation: raw.refsGeneration ?? null,
|
|
533
|
+
appId: raw.appBundleId ?? null,
|
|
534
|
+
truncated: raw.truncated ?? false,
|
|
535
|
+
capturedAt: Date.now()
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* On Android a field showing its hint reports the hint as its `value`, so the
|
|
540
|
+
* flag is the only thing separating an empty field from one a user typed into.
|
|
541
|
+
* agent-device 0.20.10's Android snapshot helper does not emit `hintShowing`
|
|
542
|
+
* yet, which is why no fixture on disk carries it.
|
|
543
|
+
*/
|
|
544
|
+
function parsedValue(source) {
|
|
545
|
+
if (source.hintShowing === true) return "";
|
|
546
|
+
return source.value === void 0 ? null : normalizeText(source.value);
|
|
547
|
+
}
|
|
548
|
+
function inherited(own, inherits, parent, read) {
|
|
549
|
+
if (own !== void 0) return normalizeText(own);
|
|
550
|
+
if (inherits !== true) return null;
|
|
551
|
+
for (let ancestor = parent; ancestor !== null; ancestor = ancestor.parent) {
|
|
552
|
+
const value = read(ancestor);
|
|
553
|
+
if (value !== null) return value;
|
|
554
|
+
}
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* The single resolver, shared by actions and assertions so the two can never
|
|
559
|
+
* disagree about which node was meant. Rule order: match every query field,
|
|
560
|
+
* apply every `.filter()`, absorb ancestors, then `index`.
|
|
561
|
+
*
|
|
562
|
+
* Ancestor absorption drops a match when a descendant match carries the same
|
|
563
|
+
* string the query matched on. On the sample app an `[other]` container and its
|
|
564
|
+
* `[text]` child both carry "Live from the cloud", which is one thing on screen.
|
|
565
|
+
* Matches in disjoint subtrees stay distinct, so "Explore" on the Explore screen
|
|
566
|
+
* is still the heading and the tab button.
|
|
567
|
+
*/
|
|
568
|
+
function resolve(screen, query) {
|
|
569
|
+
const distinct = matchesOf(screen, query);
|
|
570
|
+
if (query.index !== void 0) {
|
|
571
|
+
const picked = distinct.at(query.index);
|
|
572
|
+
if (picked === void 0) return {
|
|
573
|
+
outcome: "none",
|
|
574
|
+
nearest: nearestTo(screen, query)
|
|
575
|
+
};
|
|
576
|
+
return {
|
|
577
|
+
outcome: "one",
|
|
578
|
+
node: picked
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const first = distinct[0];
|
|
582
|
+
if (first === void 0) return {
|
|
583
|
+
outcome: "none",
|
|
584
|
+
nearest: nearestTo(screen, query)
|
|
585
|
+
};
|
|
586
|
+
if (distinct.length > 1) return {
|
|
587
|
+
outcome: "many",
|
|
588
|
+
nodes: distinct
|
|
589
|
+
};
|
|
590
|
+
return {
|
|
591
|
+
outcome: "one",
|
|
592
|
+
node: first
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Everything a query matches, before `index` and before strictness. `resolve`
|
|
597
|
+
* and the `has` filters share it, so an inner query means what the same locator
|
|
598
|
+
* would mean on its own.
|
|
599
|
+
*/
|
|
600
|
+
function matchesOf(screen, query) {
|
|
601
|
+
return absorbAncestors(screen.nodes.filter((node) => matchesQuery(node, query) && (query.filters ?? []).every((filter) => matchesFilter(screen, node, filter))), query);
|
|
602
|
+
}
|
|
603
|
+
/** `hasText` includes the candidate's own text, while `has` needs a strict descendant. Mirrors Playwright. */
|
|
604
|
+
function matchesFilter(screen, node, filter) {
|
|
605
|
+
if (filter.hasText !== void 0 && !subtreeHasText(screen, node, filter.hasText)) return false;
|
|
606
|
+
if (filter.hasNotText !== void 0 && subtreeHasText(screen, node, filter.hasNotText)) return false;
|
|
607
|
+
if (filter.has !== void 0 && !containsMatch(screen, node, filter.has)) return false;
|
|
608
|
+
if (filter.hasNot !== void 0 && containsMatch(screen, node, filter.hasNot)) return false;
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
function subtreeHasText(screen, candidate, match) {
|
|
612
|
+
return screen.nodes.some((node) => (node === candidate || isDescendant(node, candidate)) && (matchesText(match, node.name) || matchesText(match, node.value)));
|
|
613
|
+
}
|
|
614
|
+
function containsMatch(screen, candidate, inner) {
|
|
615
|
+
return matchesOf(screen, inner).some((node) => isDescendant(node, candidate));
|
|
616
|
+
}
|
|
617
|
+
function matchesQuery(node, query) {
|
|
618
|
+
if (query.role !== void 0 && node.role !== query.role) return false;
|
|
619
|
+
if (query.testId !== void 0 && !matchesText(query.testId, node.testId)) return false;
|
|
620
|
+
if (query.value !== void 0 && !matchesText(query.value, node.value)) return false;
|
|
621
|
+
if (query.name !== void 0 && !matchesText(query.name, node.name) && !matchesText(query.name, node.value)) return false;
|
|
622
|
+
if (query.enabled !== void 0 && node.enabled !== query.enabled) return false;
|
|
623
|
+
if (query.selected !== void 0 && node.selected !== query.selected) return false;
|
|
624
|
+
if (query.focused !== void 0 && node.focused !== query.focused) return false;
|
|
625
|
+
if (query.where !== void 0 && !query.where(node)) return false;
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* The string the query matched this node on. Two nodes on one ancestor chain
|
|
630
|
+
* sharing it are one thing on screen. A query that constrains no text falls back
|
|
631
|
+
* to the node's name, so `getByRole('button')` does not collapse two nested
|
|
632
|
+
* buttons that say different things.
|
|
633
|
+
*
|
|
634
|
+
* A `hasText` filter names a string on screen, so it contributes the same
|
|
635
|
+
* pattern to every candidate and collapses a chain to the innermost container
|
|
636
|
+
* holding that text. Almost every React Native container reports role `other`,
|
|
637
|
+
* so without this `getByRole('other').filter({ hasText })` would match every
|
|
638
|
+
* wrapper rather than the row the author meant. `hasNotText`, `has` and `hasNot`
|
|
639
|
+
* name structure or an absence, so they contribute nothing.
|
|
640
|
+
*/
|
|
641
|
+
function matchedText(node, query) {
|
|
642
|
+
const parts = [];
|
|
643
|
+
if (query.testId !== void 0) parts.push(node.testId ?? "");
|
|
644
|
+
if (query.value !== void 0) parts.push(node.value ?? "");
|
|
645
|
+
if (query.name !== void 0) parts.push(node.name ?? node.value ?? "");
|
|
646
|
+
for (const filter of query.filters ?? []) if (filter.hasText !== void 0) parts.push(describeMatch(filter.hasText));
|
|
647
|
+
return parts.length === 0 ? node.name ?? "" : parts.join(" ");
|
|
648
|
+
}
|
|
649
|
+
function absorbAncestors(matched, query) {
|
|
650
|
+
return matched.filter((candidate) => !matched.some((other) => other !== candidate && isDescendant(other, candidate) && matchedText(other, query) === matchedText(candidate, query)));
|
|
651
|
+
}
|
|
652
|
+
function isDescendant(node, ancestor) {
|
|
653
|
+
for (let walk = node.parent; walk !== null; walk = walk.parent) if (walk === ancestor) return true;
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
/** The named nodes closest to what the query asked for. A miss is usually a wording drift. */
|
|
657
|
+
function nearestTo(screen, query) {
|
|
658
|
+
const wanted = wantedText(query);
|
|
659
|
+
const named = screen.nodes.filter((node) => node.name !== null || node.testId !== null);
|
|
660
|
+
if (wanted === null) return named.slice(0, 5);
|
|
661
|
+
const target = wanted.toLowerCase();
|
|
662
|
+
return named.map((node) => ({
|
|
663
|
+
node,
|
|
664
|
+
score: overlap(target, (node.name ?? node.testId ?? "").toLowerCase())
|
|
665
|
+
})).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score).slice(0, 5).map((entry) => entry.node);
|
|
666
|
+
}
|
|
667
|
+
function wantedText(query) {
|
|
668
|
+
const asked = (query.filters ?? []).map((filter) => filter.hasText);
|
|
669
|
+
for (const match of [
|
|
670
|
+
query.name,
|
|
671
|
+
query.testId,
|
|
672
|
+
query.value,
|
|
673
|
+
...asked
|
|
674
|
+
]) if (match !== void 0 && match.kind !== "regex") return match.value;
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
function overlap(wanted, candidate) {
|
|
678
|
+
const words = wanted.split(" ").filter((word) => word.length > 2);
|
|
679
|
+
const hits = words.filter((word) => candidate.includes(word)).length;
|
|
680
|
+
if (hits > 0) return hits / words.length;
|
|
681
|
+
return candidate.includes(wanted) || wanted.includes(candidate) ? .5 : 0;
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* With a generation this is the driver's `@e12~s776575` form, which the driver
|
|
685
|
+
* rejects once that generation is superseded. Without one the bare ref is still
|
|
686
|
+
* correct, because the caller acts on the very next command and a frame
|
|
687
|
+
* authorizes the refs it just emitted. Weaker, not wrong, so this cannot fail.
|
|
688
|
+
*/
|
|
689
|
+
function pin(screen, node) {
|
|
690
|
+
return screen.generation === null ? node.ref : `${node.ref}~s${String(screen.generation)}`;
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* The one tree renderer, used by failure messages and by the `screen.txt`
|
|
694
|
+
* attachment so terminal and report agree. The vocabulary is the CLI's own
|
|
695
|
+
* `[role] "label"` form.
|
|
696
|
+
*/
|
|
697
|
+
function renderScreen(screen, options) {
|
|
698
|
+
const max = options?.maxNodes ?? screen.nodes.length;
|
|
699
|
+
const lines = screen.nodes.slice(0, max).map((node) => renderNode(node));
|
|
700
|
+
if (screen.nodes.length > max) lines.push(` ... ${String(screen.nodes.length - max)} more nodes`);
|
|
701
|
+
return lines.join("\n");
|
|
702
|
+
}
|
|
703
|
+
function renderNode(node) {
|
|
704
|
+
const indent = " ".repeat(node.depth);
|
|
705
|
+
const name = node.name === null ? "" : ` "${node.name}"`;
|
|
706
|
+
const testId = node.testId === null ? "" : ` #${node.testId}`;
|
|
707
|
+
const flags = [
|
|
708
|
+
node.selected ? "selected" : null,
|
|
709
|
+
node.focused ? "focused" : null,
|
|
710
|
+
node.enabled ? null : "disabled",
|
|
711
|
+
node.hiddenContentAbove ? "more above" : null,
|
|
712
|
+
node.hiddenContentBelow ? "more below" : null
|
|
713
|
+
].filter((flag) => flag !== null).map((flag) => ` [${flag}]`).join("");
|
|
714
|
+
return `${indent}${node.ref} [${node.role}]${name}${testId}${flags}`;
|
|
715
|
+
}
|
|
716
|
+
//#endregion
|
|
717
|
+
//#region src/core/report.ts
|
|
718
|
+
const FILL_TEXT_LIMIT = 40;
|
|
719
|
+
/** Rendered here rather than in an adapter so every runner produces the same text. */
|
|
720
|
+
function renderTitle(record) {
|
|
721
|
+
switch (record.kind) {
|
|
722
|
+
case "open": return `open ${record.app} on ${record.device} as ${record.session}`;
|
|
723
|
+
case "tap": return `tap ${describeQuery(record.query)}`;
|
|
724
|
+
case "long-press": return `longPress ${describeQuery(record.query)} for ${String(record.durationMs)}ms`;
|
|
725
|
+
case "fill": return `fill ${describeQuery(record.query)}`;
|
|
726
|
+
case "typed": return renderTyped(record.typed);
|
|
727
|
+
case "scroll": return `scroll ${record.direction}`;
|
|
728
|
+
case "scroll-into-view": return `scrollIntoView ${describeQuery(record.query)}`;
|
|
729
|
+
case "relaunch": return `relaunch ${record.app}`;
|
|
730
|
+
case "dismiss-overlay": return "dismiss the React Native dev overlay";
|
|
731
|
+
case "screenshot": return `screenshot ${record.path}`;
|
|
732
|
+
default: throw new Error(`unhandled action record ${JSON.stringify(record)}`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function renderTyped(typed) {
|
|
736
|
+
switch (typed.kind) {
|
|
737
|
+
case "text": return `type "${truncate(typed.value)}"`;
|
|
738
|
+
case "hidden": return `type ${String(typed.length)} characters`;
|
|
739
|
+
default: throw new Error(`unhandled typed value ${JSON.stringify(typed)}`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
function truncate(text) {
|
|
743
|
+
return text.length <= FILL_TEXT_LIMIT ? text : `${text.slice(0, FILL_TEXT_LIMIT)}...`;
|
|
744
|
+
}
|
|
745
|
+
/** Discards everything. The default for scripts, unit tests, and runners with no reporting. */
|
|
746
|
+
const silentSink = {
|
|
747
|
+
step: (_title, body) => body(),
|
|
748
|
+
attach: () => Promise.resolve(),
|
|
749
|
+
note: () => {},
|
|
750
|
+
outputPath: (fileName) => fileName
|
|
751
|
+
};
|
|
752
|
+
//#endregion
|
|
753
|
+
//#region src/core/session.ts
|
|
754
|
+
const READY_POLL_MS = 250;
|
|
755
|
+
const SNAPSHOT_TIMEOUT_MS = 15e3;
|
|
756
|
+
function createQueue() {
|
|
757
|
+
let tail = Promise.resolve();
|
|
758
|
+
return { enqueue(body) {
|
|
759
|
+
const next = tail.then(body);
|
|
760
|
+
tail = next.catch(() => void 0);
|
|
761
|
+
return next;
|
|
762
|
+
} };
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Convergent startup. Running it twice settles on one ready session. In order:
|
|
766
|
+
* reclaim a leftover session of the same name, open with the selection carried
|
|
767
|
+
* on that first command, recover once from a device claimed by a leftover or
|
|
768
|
+
* under `onDeviceInUse: 'reclaim'`, recover once from a session bound to another
|
|
769
|
+
* device, then hold until `readyWhen` resolves, because `open` returns while the
|
|
770
|
+
* JavaScript bundle is still loading.
|
|
771
|
+
*/
|
|
772
|
+
async function openSession(input) {
|
|
773
|
+
const { options, sink } = input;
|
|
774
|
+
const name = sessionName(options, input.scope, input.slot);
|
|
775
|
+
const deviceName = deviceNameForSlot(options, input.slot);
|
|
776
|
+
const driver = input.createDriver(name, {
|
|
777
|
+
platform: options.platform,
|
|
778
|
+
name: deviceName
|
|
779
|
+
});
|
|
780
|
+
const deadline = Date.now() + options.launchTimeout;
|
|
781
|
+
await driver.close(name);
|
|
782
|
+
const binding = await openWithRecovery(driver, options, name, deviceName);
|
|
783
|
+
sink.note("device", `${binding.deviceLabel} (${binding.platform}) session ${binding.session}`);
|
|
784
|
+
const session = createSession(driver, options, name, binding);
|
|
785
|
+
await sink.step(renderTitle({
|
|
786
|
+
kind: "open",
|
|
787
|
+
app: options.app,
|
|
788
|
+
device: binding.deviceLabel,
|
|
789
|
+
session: name
|
|
790
|
+
}), async () => {
|
|
791
|
+
if (options.dismissDevOverlay) await session.dismissDevOverlay();
|
|
792
|
+
await session.awaitReady(deadline);
|
|
793
|
+
});
|
|
794
|
+
return session;
|
|
795
|
+
}
|
|
796
|
+
async function openWithRecovery(driver, options, name, deviceName) {
|
|
797
|
+
const request = {
|
|
798
|
+
app: options.app,
|
|
799
|
+
relaunch: true,
|
|
800
|
+
url: options.launchUrl
|
|
801
|
+
};
|
|
802
|
+
try {
|
|
803
|
+
return await driver.open(request);
|
|
804
|
+
} catch (error) {
|
|
805
|
+
const failure = failureOf(error);
|
|
806
|
+
if (failure === null) throw error;
|
|
807
|
+
if (failure.kind === "device-busy") {
|
|
808
|
+
const owner = failure.owner;
|
|
809
|
+
if (!(owner !== null && (owner.startsWith(options.sessionPrefix) || options.onDeviceInUse === "reclaim"))) throw new TangereError({
|
|
810
|
+
kind: "device-in-use",
|
|
811
|
+
owner,
|
|
812
|
+
device: deviceName ?? options.platform,
|
|
813
|
+
releaseCommand: `agent-device close --session ${owner ?? "<owner>"}`,
|
|
814
|
+
canReclaim: options.onDeviceInUse === "fail"
|
|
815
|
+
});
|
|
816
|
+
await driver.close(owner);
|
|
817
|
+
return await driver.open(request);
|
|
818
|
+
}
|
|
819
|
+
if (failure.kind === "session-rebound") {
|
|
820
|
+
await driver.close(name);
|
|
821
|
+
return await driver.open(request);
|
|
822
|
+
}
|
|
823
|
+
throw new TangereError({
|
|
824
|
+
kind: "launch-failed",
|
|
825
|
+
app: options.app,
|
|
826
|
+
device: deviceName ?? options.platform,
|
|
827
|
+
failure
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
function createSession(driver, options, name, binding) {
|
|
832
|
+
const queue = createQueue();
|
|
833
|
+
let state = {
|
|
834
|
+
phase: "ready",
|
|
835
|
+
binding
|
|
836
|
+
};
|
|
837
|
+
const settle = (budgetMs) => ({
|
|
838
|
+
settleQuietMs: options.settleQuietMs,
|
|
839
|
+
timeoutMs: Math.max(budgetMs, options.settleQuietMs)
|
|
840
|
+
});
|
|
841
|
+
const device = {
|
|
842
|
+
capture: async () => parseScreen(await driver.capture({
|
|
843
|
+
timeoutMs: SNAPSHOT_TIMEOUT_MS,
|
|
844
|
+
tree: "default"
|
|
845
|
+
}), options.platform),
|
|
846
|
+
captureRaw: async () => parseScreen(await driver.capture({
|
|
847
|
+
timeoutMs: SNAPSHOT_TIMEOUT_MS,
|
|
848
|
+
tree: "raw"
|
|
849
|
+
}), options.platform),
|
|
850
|
+
tap: (ref, budgetMs) => driver.tap(ref, settle(budgetMs)),
|
|
851
|
+
longPress: (ref, durationMs, budgetMs) => driver.longPress(ref, durationMs, settle(budgetMs)),
|
|
852
|
+
fill: (ref, text, budgetMs) => driver.fill(ref, text, settle(budgetMs)),
|
|
853
|
+
scroll: (direction, budgetMs) => driver.scroll(direction, settle(budgetMs))
|
|
854
|
+
};
|
|
855
|
+
function run(body) {
|
|
856
|
+
return queue.enqueue(async () => {
|
|
857
|
+
if (state.phase !== "ready") throw unusable(state);
|
|
858
|
+
try {
|
|
859
|
+
return await body(device);
|
|
860
|
+
} catch (error) {
|
|
861
|
+
const failure = failureOf(error);
|
|
862
|
+
if (failure !== null && breaksTheSession(failure)) state = {
|
|
863
|
+
phase: "broken",
|
|
864
|
+
failure
|
|
865
|
+
};
|
|
866
|
+
throw error;
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
async function awaitReady(deadline) {
|
|
871
|
+
const started = Date.now();
|
|
872
|
+
let screen;
|
|
873
|
+
for (;;) {
|
|
874
|
+
screen = await run((one) => one.capture());
|
|
875
|
+
if (resolve(screen, options.readyWhen).outcome !== "none") return;
|
|
876
|
+
const remaining = deadline - Date.now();
|
|
877
|
+
if (remaining <= 0) break;
|
|
878
|
+
await sleep(Math.min(READY_POLL_MS, remaining));
|
|
879
|
+
}
|
|
880
|
+
throw new TangereError({
|
|
881
|
+
kind: "not-ready",
|
|
882
|
+
locator: describeQuery(options.readyWhen),
|
|
883
|
+
timeoutMs: Date.now() - started,
|
|
884
|
+
screen: renderScreen(screen)
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
return {
|
|
888
|
+
name,
|
|
889
|
+
options,
|
|
890
|
+
state: () => state,
|
|
891
|
+
failure: () => state.phase === "broken" ? state.failure : null,
|
|
892
|
+
run,
|
|
893
|
+
screen: () => run((one) => one.capture()),
|
|
894
|
+
screenshot: (path) => queue.enqueue(() => driver.screenshot(path)),
|
|
895
|
+
relaunch: (sink) => sink.step(renderTitle({
|
|
896
|
+
kind: "relaunch",
|
|
897
|
+
app: options.app
|
|
898
|
+
}), async () => {
|
|
899
|
+
await run(() => driver.open({
|
|
900
|
+
app: options.app,
|
|
901
|
+
relaunch: true,
|
|
902
|
+
url: options.launchUrl
|
|
903
|
+
}));
|
|
904
|
+
if (options.dismissDevOverlay) await run(() => driver.dismissDevOverlay());
|
|
905
|
+
await awaitReady(Date.now() + options.launchTimeout);
|
|
906
|
+
}),
|
|
907
|
+
dismissDevOverlay: () => run(() => driver.dismissDevOverlay()),
|
|
908
|
+
awaitReady,
|
|
909
|
+
close: async (reason) => {
|
|
910
|
+
if (state.phase === "closed") return;
|
|
911
|
+
try {
|
|
912
|
+
await driver.close(name);
|
|
913
|
+
} finally {
|
|
914
|
+
state = {
|
|
915
|
+
phase: "closed",
|
|
916
|
+
reason
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
function unusable(state) {
|
|
923
|
+
if (state.phase === "broken") return new TangereError({
|
|
924
|
+
kind: "driver",
|
|
925
|
+
command: "device command",
|
|
926
|
+
failure: state.failure
|
|
927
|
+
});
|
|
928
|
+
return new TangereError({
|
|
929
|
+
kind: "session-closed",
|
|
930
|
+
command: "run a device command"
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Only a failure meaning the device or the session itself is gone breaks the
|
|
935
|
+
* session. A stale ref, an ambiguous match, or one timed-out command is a
|
|
936
|
+
* per-command outcome the caller recovers from.
|
|
937
|
+
*/
|
|
938
|
+
function breaksTheSession(failure) {
|
|
939
|
+
return failure.kind === "device-busy" || failure.kind === "device-missing" || failure.kind === "session-rebound";
|
|
940
|
+
}
|
|
941
|
+
/** Deterministic, so a worker replaced after a failure reconnects to the session it left behind. */
|
|
942
|
+
function sessionName(options, project, slot) {
|
|
943
|
+
return `${options.sessionPrefix}-${project === "" ? "default" : project}-${String(slot)}`;
|
|
944
|
+
}
|
|
945
|
+
function failureOf(error) {
|
|
946
|
+
if (!(error instanceof TangereError)) return null;
|
|
947
|
+
if (error.info.kind === "driver") return error.info.failure;
|
|
948
|
+
if (error.info.kind === "launch-failed") return error.info.failure;
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
function sleep(ms) {
|
|
952
|
+
return new Promise((done) => setTimeout(done, ms));
|
|
953
|
+
}
|
|
954
|
+
//#endregion
|
|
955
|
+
//#region src/core/probe.ts
|
|
956
|
+
const POLL_INTERVAL_MS = 250;
|
|
957
|
+
const SCREEN_LISTING_NODES = 60;
|
|
958
|
+
/**
|
|
959
|
+
* Polls a fresh screen until the check agrees with `negate`, the budget runs
|
|
960
|
+
* out, or the session breaks. Never throws for a failed expectation, because the
|
|
961
|
+
* adapter hands `{ pass, message }` to its assertion library.
|
|
962
|
+
*
|
|
963
|
+
* Every early exit reports `pass: options.negate`, the value that fails the
|
|
964
|
+
* assertion whether or not the caller wrote `.not`. An ambiguous locator and a
|
|
965
|
+
* dead session are wrong under `.not` too, so neither may pass by inversion.
|
|
966
|
+
*/
|
|
967
|
+
async function probe(target, check, options) {
|
|
968
|
+
const interval = options.intervalMs ?? POLL_INTERVAL_MS;
|
|
969
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
970
|
+
let screen = null;
|
|
971
|
+
let resolution = {
|
|
972
|
+
outcome: "none",
|
|
973
|
+
nearest: []
|
|
974
|
+
};
|
|
975
|
+
let polls = 0;
|
|
976
|
+
for (;;) {
|
|
977
|
+
const broken = target.failure();
|
|
978
|
+
if (broken !== null) return {
|
|
979
|
+
pass: options.negate,
|
|
980
|
+
message: `Device session is unusable: ${describeFailure(broken)}`,
|
|
981
|
+
actual: null,
|
|
982
|
+
expected: describeCheck(check)
|
|
983
|
+
};
|
|
984
|
+
screen = await target.capture();
|
|
985
|
+
polls += 1;
|
|
986
|
+
resolution = resolve(screen, target.query);
|
|
987
|
+
if (resolution.outcome === "many" && check.name !== "toHaveCount") return {
|
|
988
|
+
pass: options.negate,
|
|
989
|
+
actual: `${String(resolution.nodes.length)} matching nodes`,
|
|
990
|
+
expected: describeCheck(check),
|
|
991
|
+
message: formatFailure({
|
|
992
|
+
locator: target.description,
|
|
993
|
+
check,
|
|
994
|
+
negate: options.negate,
|
|
995
|
+
resolution,
|
|
996
|
+
screen,
|
|
997
|
+
timeoutMs: options.timeoutMs,
|
|
998
|
+
polls
|
|
999
|
+
})
|
|
1000
|
+
};
|
|
1001
|
+
const verdict = evaluate(check, resolution);
|
|
1002
|
+
if (verdict.pass !== options.negate) return {
|
|
1003
|
+
pass: !options.negate,
|
|
1004
|
+
message: "",
|
|
1005
|
+
actual: verdict.actual,
|
|
1006
|
+
expected: describeCheck(check)
|
|
1007
|
+
};
|
|
1008
|
+
const remaining = deadline - Date.now();
|
|
1009
|
+
if (remaining <= 0) break;
|
|
1010
|
+
await sleep(Math.min(interval, remaining));
|
|
1011
|
+
}
|
|
1012
|
+
const verdict = evaluate(check, resolution);
|
|
1013
|
+
return {
|
|
1014
|
+
pass: options.negate,
|
|
1015
|
+
actual: verdict.actual,
|
|
1016
|
+
expected: describeCheck(check),
|
|
1017
|
+
message: formatFailure({
|
|
1018
|
+
locator: target.description,
|
|
1019
|
+
check,
|
|
1020
|
+
negate: options.negate,
|
|
1021
|
+
resolution,
|
|
1022
|
+
screen,
|
|
1023
|
+
timeoutMs: options.timeoutMs,
|
|
1024
|
+
polls
|
|
1025
|
+
})
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* The screen listing comes from `renderScreen`, the same function that writes
|
|
1030
|
+
* `screen.txt`, so terminal and report agree.
|
|
1031
|
+
*/
|
|
1032
|
+
function formatFailure(input) {
|
|
1033
|
+
return [
|
|
1034
|
+
`Expected ${input.negate ? "not." : ""}${input.check.name} but it never held.`,
|
|
1035
|
+
``,
|
|
1036
|
+
`Locator: ${input.locator}`,
|
|
1037
|
+
`Expected: ${input.negate ? "not " : ""}${describeCheck(input.check)}`,
|
|
1038
|
+
`Received: ${received(input.resolution, input.check)}`,
|
|
1039
|
+
`Timeout: ${String(input.timeoutMs)}ms (${String(input.polls)} snapshot${input.polls === 1 ? "" : "s"})`,
|
|
1040
|
+
``,
|
|
1041
|
+
`Screen:`,
|
|
1042
|
+
input.screen === null ? " (no screen captured)" : renderScreen(input.screen, { maxNodes: SCREEN_LISTING_NODES }),
|
|
1043
|
+
``,
|
|
1044
|
+
`screen.png and screen.txt are attached to this test in the HTML report.`
|
|
1045
|
+
].join("\n");
|
|
1046
|
+
}
|
|
1047
|
+
function received(resolution, check) {
|
|
1048
|
+
switch (resolution.outcome) {
|
|
1049
|
+
case "one": return evaluate(check, resolution).actual ?? describeNode(resolution.node);
|
|
1050
|
+
case "many": return [`${String(resolution.nodes.length)} nodes matched, which is ambiguous. Narrow the locator or use .first() / .nth(n).`, ...resolution.nodes.map((node) => ` ${describeNode(node)}`)].join("\n");
|
|
1051
|
+
case "none": return resolution.nearest.length === 0 ? "no node matched" : [`no node matched. Closest names on screen:`, ...resolution.nearest.map((node) => ` ${describeNode(node)}`)].join("\n");
|
|
1052
|
+
default: throw new Error(`unhandled resolution ${JSON.stringify(resolution)}`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
//#endregion
|
|
1056
|
+
//#region src/core/scroll.ts
|
|
1057
|
+
const MAX_SCROLL_STEPS = 20;
|
|
1058
|
+
function createScrollSearch(device, maxSteps = MAX_SCROLL_STEPS) {
|
|
1059
|
+
let committed = null;
|
|
1060
|
+
let steps = 0;
|
|
1061
|
+
return {
|
|
1062
|
+
trail: () => committed === null ? null : {
|
|
1063
|
+
steps,
|
|
1064
|
+
direction: committed
|
|
1065
|
+
},
|
|
1066
|
+
step: async (screen, query, budgetMs) => {
|
|
1067
|
+
if (steps >= maxSteps) return false;
|
|
1068
|
+
const next = directionToward(screen, await device.captureRaw(), query);
|
|
1069
|
+
if (next === null || committed !== null && next !== committed) return false;
|
|
1070
|
+
committed = next;
|
|
1071
|
+
await device.scroll(next, budgetMs);
|
|
1072
|
+
steps += 1;
|
|
1073
|
+
return true;
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Which way the target lies, or null when nothing on screen says.
|
|
1079
|
+
*
|
|
1080
|
+
* The raw tree places the target itself, so when it carries the node, its rect
|
|
1081
|
+
* against the clipping container's rect is an answer rather than a guess. That
|
|
1082
|
+
* is the iOS case. Android's raw tree stops at the window, so the only evidence
|
|
1083
|
+
* left is the container reporting it holds content out of view.
|
|
1084
|
+
*/
|
|
1085
|
+
function directionToward(screen, raw, query) {
|
|
1086
|
+
return (raw === null ? null : outsideViewport(raw, query)) ?? hiddenContentIn(screen);
|
|
1087
|
+
}
|
|
1088
|
+
function outsideViewport(raw, query) {
|
|
1089
|
+
const found = resolve(raw, query);
|
|
1090
|
+
if (found.outcome !== "one") return null;
|
|
1091
|
+
const target = found.node.rect;
|
|
1092
|
+
const viewport = clippingRect(found.node);
|
|
1093
|
+
if (target === null || viewport === null) return null;
|
|
1094
|
+
if (target.y >= viewport.y + viewport.height) return "down";
|
|
1095
|
+
if (target.y + target.height <= viewport.y) return "up";
|
|
1096
|
+
if (target.x >= viewport.x + viewport.width) return "right";
|
|
1097
|
+
if (target.x + target.width <= viewport.x) return "left";
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* A scroll container reports its own visible rect rather than the rect of
|
|
1102
|
+
* everything it holds, so a target beyond that rect is one it scrolled away.
|
|
1103
|
+
*/
|
|
1104
|
+
function clippingRect(node) {
|
|
1105
|
+
for (let walk = node.parent; walk !== null; walk = walk.parent) if (walk.role === "scroll-area" && walk.rect !== null) return walk.rect;
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
function hiddenContentIn(screen) {
|
|
1109
|
+
if (screen.nodes.some((node) => node.hiddenContentBelow)) return "down";
|
|
1110
|
+
if (screen.nodes.some((node) => node.hiddenContentAbove)) return "up";
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
//#endregion
|
|
1114
|
+
//#region src/core/device.ts
|
|
1115
|
+
const ACTION_POLL_MS = 250;
|
|
1116
|
+
const DEFAULT_LONG_PRESS_MS = 1e3;
|
|
1117
|
+
function createDevice(session, sink) {
|
|
1118
|
+
const build = (query) => createLocator(session, sink, query, device);
|
|
1119
|
+
let screenshots = 0;
|
|
1120
|
+
const device = {
|
|
1121
|
+
getByText: (text, options) => build({ name: textMatch(text, options?.exact) }),
|
|
1122
|
+
getByRole: (role, options) => build(options?.name === void 0 ? { role } : {
|
|
1123
|
+
role,
|
|
1124
|
+
name: textMatch(options.name, options.exact)
|
|
1125
|
+
}),
|
|
1126
|
+
getByTestId: (testId) => build({ testId: textMatch(testId, true) }),
|
|
1127
|
+
locator: build,
|
|
1128
|
+
scroll: (direction) => sink.step(renderTitle({
|
|
1129
|
+
kind: "scroll",
|
|
1130
|
+
direction
|
|
1131
|
+
}), async () => {
|
|
1132
|
+
await session.run((device) => device.scroll(direction, session.options.actionTimeout));
|
|
1133
|
+
}),
|
|
1134
|
+
relaunch: () => session.relaunch(sink),
|
|
1135
|
+
dismissDevOverlay: () => sink.step(renderTitle({ kind: "dismiss-overlay" }), () => session.dismissDevOverlay()),
|
|
1136
|
+
screen: () => session.screen(),
|
|
1137
|
+
screenshot: (options) => {
|
|
1138
|
+
if (options?.path === void 0) screenshots += 1;
|
|
1139
|
+
const path = options?.path ?? sink.outputPath(`screenshot-${String(screenshots)}.png`);
|
|
1140
|
+
return sink.step(renderTitle({
|
|
1141
|
+
kind: "screenshot",
|
|
1142
|
+
path
|
|
1143
|
+
}), () => session.screenshot(path));
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
return device;
|
|
1147
|
+
}
|
|
1148
|
+
function createLocator(session, sink, query, device) {
|
|
1149
|
+
const description = describeQuery(query);
|
|
1150
|
+
const withIndex = (index) => createLocator(session, sink, {
|
|
1151
|
+
...query,
|
|
1152
|
+
index
|
|
1153
|
+
}, device);
|
|
1154
|
+
return {
|
|
1155
|
+
query,
|
|
1156
|
+
device,
|
|
1157
|
+
description,
|
|
1158
|
+
first: () => withIndex(0),
|
|
1159
|
+
nth: (index) => withIndex(index),
|
|
1160
|
+
filter: (options) => createLocator(session, sink, {
|
|
1161
|
+
...query,
|
|
1162
|
+
filters: [...query.filters ?? [], filterOf(options)]
|
|
1163
|
+
}, device),
|
|
1164
|
+
tap: (options) => perform(session, sink, {
|
|
1165
|
+
kind: "tap",
|
|
1166
|
+
query
|
|
1167
|
+
}, options, (device, ref, budget) => device.tap(ref, budget)),
|
|
1168
|
+
fill: (text, options) => perform(session, sink, {
|
|
1169
|
+
kind: "fill",
|
|
1170
|
+
query
|
|
1171
|
+
}, options, (device, ref, budget) => device.fill(ref, text, budget), {
|
|
1172
|
+
text,
|
|
1173
|
+
secret: options?.secret ?? false
|
|
1174
|
+
}),
|
|
1175
|
+
longPress: (durationMs, options) => {
|
|
1176
|
+
const held = durationMs ?? DEFAULT_LONG_PRESS_MS;
|
|
1177
|
+
return perform(session, sink, {
|
|
1178
|
+
kind: "long-press",
|
|
1179
|
+
query,
|
|
1180
|
+
durationMs: held
|
|
1181
|
+
}, options, (device, ref, budget) => device.longPress(ref, held, budget));
|
|
1182
|
+
},
|
|
1183
|
+
scrollIntoView: (options) => scrollIntoView(session, sink, query, options),
|
|
1184
|
+
count: async () => {
|
|
1185
|
+
const resolution = resolve(await session.screen(), query);
|
|
1186
|
+
return resolution.outcome === "many" ? resolution.nodes.length : resolution.outcome === "one" ? 1 : 0;
|
|
1187
|
+
},
|
|
1188
|
+
textContent: async () => {
|
|
1189
|
+
const screen = await session.screen();
|
|
1190
|
+
const resolution = resolve(screen, query);
|
|
1191
|
+
switch (resolution.outcome) {
|
|
1192
|
+
case "many": throw ambiguous(description, resolution.nodes, screen);
|
|
1193
|
+
case "none": return null;
|
|
1194
|
+
case "one": return resolution.node.name ?? resolution.node.value;
|
|
1195
|
+
default: throw new Error(`unhandled resolution ${JSON.stringify(resolution)}`);
|
|
1196
|
+
}
|
|
1197
|
+
},
|
|
1198
|
+
expect: (check, options) => probe({
|
|
1199
|
+
query,
|
|
1200
|
+
description,
|
|
1201
|
+
capture: () => session.screen(),
|
|
1202
|
+
failure: () => session.failure()
|
|
1203
|
+
}, check, options)
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
function filterOf(options) {
|
|
1207
|
+
return {
|
|
1208
|
+
hasText: options.hasText === void 0 ? void 0 : textMatch(options.hasText),
|
|
1209
|
+
hasNotText: options.hasNotText === void 0 ? void 0 : textMatch(options.hasNotText),
|
|
1210
|
+
has: options.has?.query,
|
|
1211
|
+
hasNot: options.hasNot?.query
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* One action is one queued unit and one reported step. Ambiguity fails at once,
|
|
1216
|
+
* because waiting cannot make a locator less ambiguous. A stale ref retries
|
|
1217
|
+
* once, and a second one means the screen is changing faster than an action can
|
|
1218
|
+
* land.
|
|
1219
|
+
*
|
|
1220
|
+
* `write` makes this a write that is read back. A device keyboard drops early
|
|
1221
|
+
* keystrokes often enough that a fill can under-deliver and still report
|
|
1222
|
+
* success, so fill dispatches again until the field holds what it was given.
|
|
1223
|
+
* Re-filling is safe because a fill replaces the field's contents.
|
|
1224
|
+
*
|
|
1225
|
+
* The read back is two reads. Behind a controlled component the field is
|
|
1226
|
+
* written twice, by the driver and then by the app's own render, which can push
|
|
1227
|
+
* a stale string over what the driver typed. The second read, after the quiet
|
|
1228
|
+
* period, proves the value held. What counts as proof comes from the node's
|
|
1229
|
+
* role, because a secure field reports a mask. See `confirmationOf`.
|
|
1230
|
+
*/
|
|
1231
|
+
function perform(session, sink, record, options, dispatch, write) {
|
|
1232
|
+
const timeout = options?.timeout ?? session.options.actionTimeout;
|
|
1233
|
+
const locator = describeQuery(record.query);
|
|
1234
|
+
return sink.step(renderTitle(record), async () => {
|
|
1235
|
+
await session.run(async (device) => {
|
|
1236
|
+
const deadline = Date.now() + timeout;
|
|
1237
|
+
const search = createScrollSearch(reportingScrolls(device, sink));
|
|
1238
|
+
let retriedStaleRef = false;
|
|
1239
|
+
let attempts = 0;
|
|
1240
|
+
let target = record.query;
|
|
1241
|
+
let lastActual = null;
|
|
1242
|
+
let screen = await device.capture();
|
|
1243
|
+
const unconfirmed = (expected) => new TangereError({
|
|
1244
|
+
kind: "fill-unconfirmed",
|
|
1245
|
+
locator,
|
|
1246
|
+
expected,
|
|
1247
|
+
actual: lastActual === null ? null : disclose(expected, lastActual),
|
|
1248
|
+
attempts,
|
|
1249
|
+
timeoutMs: timeout,
|
|
1250
|
+
screen: renderScreen(screen)
|
|
1251
|
+
});
|
|
1252
|
+
for (;;) {
|
|
1253
|
+
const resolution = resolve(screen, target);
|
|
1254
|
+
if (resolution.outcome === "many") throw ambiguous(locator, resolution.nodes, screen);
|
|
1255
|
+
if (resolution.outcome === "one") {
|
|
1256
|
+
const confirmation = write === void 0 ? null : confirmationOf(resolution.node.role, write);
|
|
1257
|
+
if (confirmation !== null && attempts > 0 && deadline - Date.now() <= session.options.settleQuietMs) throw unconfirmed(expectedOf(confirmation));
|
|
1258
|
+
let settled;
|
|
1259
|
+
try {
|
|
1260
|
+
settled = await dispatch(device, pin(screen, resolution.node), deadline - Date.now());
|
|
1261
|
+
} catch (error) {
|
|
1262
|
+
if (failureOf(error)?.kind !== "stale-ref" || retriedStaleRef) throw error;
|
|
1263
|
+
retriedStaleRef = true;
|
|
1264
|
+
screen = await device.capture();
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
attempts += 1;
|
|
1268
|
+
if (!settled.settled) sink.note("settle", `${renderTitle(record)} finished before the screen went quiet`);
|
|
1269
|
+
if (confirmation === null) return;
|
|
1270
|
+
if (attempts === 1) await sink.step(renderTitle({
|
|
1271
|
+
kind: "typed",
|
|
1272
|
+
typed: typedOf(confirmation)
|
|
1273
|
+
}), () => Promise.resolve(), { box: true });
|
|
1274
|
+
target = identityOf(screen, resolution.node);
|
|
1275
|
+
screen = await device.capture();
|
|
1276
|
+
lastActual = valueAt(screen, target);
|
|
1277
|
+
if (lastActual !== null && holds(confirmation, lastActual)) {
|
|
1278
|
+
await sleep(session.options.settleQuietMs);
|
|
1279
|
+
screen = await device.capture();
|
|
1280
|
+
const second = valueAt(screen, target);
|
|
1281
|
+
if (second === null || holds(confirmation, second)) return;
|
|
1282
|
+
lastActual = second;
|
|
1283
|
+
}
|
|
1284
|
+
const left = deadline - Date.now();
|
|
1285
|
+
if (left <= 0) throw unconfirmed(expectedOf(confirmation));
|
|
1286
|
+
await sleep(Math.min(ACTION_POLL_MS, left));
|
|
1287
|
+
screen = await device.capture();
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
const remaining = deadline - Date.now();
|
|
1291
|
+
if (remaining <= 0) throw new TangereError({
|
|
1292
|
+
kind: "not-found",
|
|
1293
|
+
locator,
|
|
1294
|
+
timeoutMs: timeout,
|
|
1295
|
+
screen: renderScreen(screen),
|
|
1296
|
+
scrolled: search.trail()
|
|
1297
|
+
});
|
|
1298
|
+
if (attempts === 0 && await search.step(screen, target, remaining)) {
|
|
1299
|
+
screen = await device.capture();
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
await sleep(Math.min(ACTION_POLL_MS, remaining));
|
|
1303
|
+
screen = await device.capture();
|
|
1304
|
+
}
|
|
1305
|
+
});
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* The stop condition is the default tree, never the raw one. The raw tree says
|
|
1310
|
+
* which way to go, but a node it carries may still be off screen, so stopping
|
|
1311
|
+
* on it would hand an action a ref the user cannot reach.
|
|
1312
|
+
*/
|
|
1313
|
+
function scrollIntoView(session, sink, query, options) {
|
|
1314
|
+
const timeout = options?.timeout ?? session.options.actionTimeout;
|
|
1315
|
+
const locator = describeQuery(query);
|
|
1316
|
+
return sink.step(renderTitle({
|
|
1317
|
+
kind: "scroll-into-view",
|
|
1318
|
+
query
|
|
1319
|
+
}), async () => {
|
|
1320
|
+
await session.run(async (device) => {
|
|
1321
|
+
const deadline = Date.now() + timeout;
|
|
1322
|
+
const search = createScrollSearch(reportingScrolls(device, sink));
|
|
1323
|
+
let screen = await device.capture();
|
|
1324
|
+
for (;;) {
|
|
1325
|
+
const resolution = resolve(screen, query);
|
|
1326
|
+
if (resolution.outcome === "many") throw ambiguous(locator, resolution.nodes, screen);
|
|
1327
|
+
if (resolution.outcome === "one") return;
|
|
1328
|
+
const remaining = deadline - Date.now();
|
|
1329
|
+
if (remaining <= 0 || !await search.step(screen, query, remaining)) throw new TangereError({
|
|
1330
|
+
kind: "not-found",
|
|
1331
|
+
locator,
|
|
1332
|
+
timeoutMs: timeout,
|
|
1333
|
+
screen: renderScreen(screen),
|
|
1334
|
+
scrolled: search.trail()
|
|
1335
|
+
});
|
|
1336
|
+
screen = await device.capture();
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
/** Every scroll a search takes is a nested step, so a report shows what an action did to reach its target. */
|
|
1342
|
+
function reportingScrolls(device, sink) {
|
|
1343
|
+
return {
|
|
1344
|
+
captureRaw: () => device.captureRaw(),
|
|
1345
|
+
scroll: (direction, budgetMs) => sink.step(renderTitle({
|
|
1346
|
+
kind: "scroll",
|
|
1347
|
+
direction
|
|
1348
|
+
}), () => device.scroll(direction, budgetMs))
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
function ambiguous(locator, nodes, screen) {
|
|
1352
|
+
return new TangereError({
|
|
1353
|
+
kind: "strict-mode",
|
|
1354
|
+
locator,
|
|
1355
|
+
matches: nodes.map((node) => describeNode(node)),
|
|
1356
|
+
screen: renderScreen(screen)
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
function confirmationOf(role, write) {
|
|
1360
|
+
if (role === "secure-text-field") return {
|
|
1361
|
+
kind: "mask",
|
|
1362
|
+
length: write.text.length
|
|
1363
|
+
};
|
|
1364
|
+
return write.secret ? {
|
|
1365
|
+
kind: "secret",
|
|
1366
|
+
value: write.text
|
|
1367
|
+
} : {
|
|
1368
|
+
kind: "open",
|
|
1369
|
+
value: write.text
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
function holds(confirmation, actual) {
|
|
1373
|
+
switch (confirmation.kind) {
|
|
1374
|
+
case "mask": return actual.length === confirmation.length && new Set(actual).size <= 1;
|
|
1375
|
+
case "secret":
|
|
1376
|
+
case "open": return actual === confirmation.value;
|
|
1377
|
+
default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
function expectedOf(confirmation) {
|
|
1381
|
+
switch (confirmation.kind) {
|
|
1382
|
+
case "mask": return {
|
|
1383
|
+
kind: "masked",
|
|
1384
|
+
length: confirmation.length
|
|
1385
|
+
};
|
|
1386
|
+
case "secret": return {
|
|
1387
|
+
kind: "masked",
|
|
1388
|
+
length: confirmation.value.length
|
|
1389
|
+
};
|
|
1390
|
+
case "open": return {
|
|
1391
|
+
kind: "exact",
|
|
1392
|
+
value: confirmation.value
|
|
1393
|
+
};
|
|
1394
|
+
default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
function typedOf(confirmation) {
|
|
1398
|
+
switch (confirmation.kind) {
|
|
1399
|
+
case "mask": return {
|
|
1400
|
+
kind: "hidden",
|
|
1401
|
+
length: confirmation.length
|
|
1402
|
+
};
|
|
1403
|
+
case "secret": return {
|
|
1404
|
+
kind: "hidden",
|
|
1405
|
+
length: confirmation.value.length
|
|
1406
|
+
};
|
|
1407
|
+
case "open": return {
|
|
1408
|
+
kind: "text",
|
|
1409
|
+
value: confirmation.value
|
|
1410
|
+
};
|
|
1411
|
+
default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
/** An actual value may be repeated back only as far as the expected one could be. */
|
|
1415
|
+
function disclose(expected, actual) {
|
|
1416
|
+
return expected.kind === "masked" ? {
|
|
1417
|
+
kind: "masked",
|
|
1418
|
+
length: actual.length
|
|
1419
|
+
} : {
|
|
1420
|
+
kind: "exact",
|
|
1421
|
+
value: actual
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
/** The field's current contents, or null once the locator stops resolving to exactly one node. */
|
|
1425
|
+
function valueAt(screen, target) {
|
|
1426
|
+
const found = resolve(screen, target);
|
|
1427
|
+
return found.outcome === "one" ? found.node.value ?? "" : null;
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* How the written node is found again on the next snapshot.
|
|
1431
|
+
*
|
|
1432
|
+
* The driver copies an ancestor's identifier onto every descendant that
|
|
1433
|
+
* inherits it, so a testId names this node alone only when it resolves to this
|
|
1434
|
+
* node alone. Two inheriting siblings would otherwise turn a landed fill into a
|
|
1435
|
+
* strict-mode failure. Position in the tree is the fallback.
|
|
1436
|
+
*/
|
|
1437
|
+
function identityOf(screen, node) {
|
|
1438
|
+
if (node.testId !== null) {
|
|
1439
|
+
const byTestId = { testId: textMatch(node.testId, true) };
|
|
1440
|
+
const resolution = resolve(screen, byTestId);
|
|
1441
|
+
if (resolution.outcome === "one" && resolution.node === node) return byTestId;
|
|
1442
|
+
}
|
|
1443
|
+
return { where: (other) => other.index === node.index };
|
|
1444
|
+
}
|
|
1445
|
+
//#endregion
|
|
1446
|
+
//#region src/core/evidence.ts
|
|
1447
|
+
/**
|
|
1448
|
+
* Never throws. A capture that fails records a note and returns, because masking
|
|
1449
|
+
* the test's real error with a screenshot error is worse than no screenshot.
|
|
1450
|
+
*/
|
|
1451
|
+
async function captureEvidence(session, sink) {
|
|
1452
|
+
try {
|
|
1453
|
+
const path = await session.screenshot(sink.outputPath("screen.png"));
|
|
1454
|
+
await sink.attach({
|
|
1455
|
+
name: "screen.png",
|
|
1456
|
+
path,
|
|
1457
|
+
contentType: "image/png"
|
|
1458
|
+
});
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
sink.note("evidence", `screenshot failed: ${messageOf(error)}`);
|
|
1461
|
+
}
|
|
1462
|
+
try {
|
|
1463
|
+
const screen = await session.screen();
|
|
1464
|
+
await sink.attach({
|
|
1465
|
+
name: "screen.txt",
|
|
1466
|
+
body: renderScreen(screen),
|
|
1467
|
+
contentType: "text/plain"
|
|
1468
|
+
});
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
sink.note("evidence", `screen listing failed: ${messageOf(error)}`);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
function messageOf(error) {
|
|
1474
|
+
return error instanceof Error ? error.message : String(error);
|
|
1475
|
+
}
|
|
1476
|
+
//#endregion
|
|
1477
|
+
//#region src/driver/agent-device.ts
|
|
1478
|
+
/** `full` is explicit because a digest-level response omits `nodes`, and the whole library matches on nodes. */
|
|
1479
|
+
function createClient() {
|
|
1480
|
+
return createAgentDeviceClient({ responseLevel: "full" });
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* The only file that imports `agent-device`. Two jobs: translate domain requests
|
|
1484
|
+
* into client calls carrying the session and device selection, and translate the
|
|
1485
|
+
* driver's error codes into `DeviceFailure`. Snapshot parsing is not one of
|
|
1486
|
+
* them, because role normalization and label rules live in `core/screen.ts`.
|
|
1487
|
+
*/
|
|
1488
|
+
function createAgentDeviceDriver(client, session, selection) {
|
|
1489
|
+
const where = {
|
|
1490
|
+
session,
|
|
1491
|
+
platform: selection.platform,
|
|
1492
|
+
...selection.name === null ? {} : { device: selection.name }
|
|
1493
|
+
};
|
|
1494
|
+
async function run(command, body) {
|
|
1495
|
+
try {
|
|
1496
|
+
return await body();
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
throw new TangereError({
|
|
1499
|
+
kind: "driver",
|
|
1500
|
+
command,
|
|
1501
|
+
failure: classifyError(error)
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
return {
|
|
1506
|
+
listDevices: () => run("listDevices", async () => {
|
|
1507
|
+
return (await client.devices.list({ platform: selection.platform })).map((device) => ({
|
|
1508
|
+
id: device.id,
|
|
1509
|
+
name: device.name,
|
|
1510
|
+
booted: device.booted ?? false
|
|
1511
|
+
}));
|
|
1512
|
+
}),
|
|
1513
|
+
open: (request) => run("open", async () => {
|
|
1514
|
+
const result = await client.apps.open({
|
|
1515
|
+
...where,
|
|
1516
|
+
app: request.app,
|
|
1517
|
+
relaunch: request.relaunch,
|
|
1518
|
+
...request.url === null ? {} : { url: request.url }
|
|
1519
|
+
});
|
|
1520
|
+
return {
|
|
1521
|
+
session: result.session,
|
|
1522
|
+
platform: selection.platform,
|
|
1523
|
+
deviceLabel: result.device?.name ?? result.identifiers.deviceName ?? selection.name ?? selection.platform,
|
|
1524
|
+
appId: result.appBundleId ?? result.appId ?? request.app,
|
|
1525
|
+
stateDir: result.sessionStateDir ?? null
|
|
1526
|
+
};
|
|
1527
|
+
}),
|
|
1528
|
+
capture: (options) => run("snapshot", () => client.capture.snapshot({
|
|
1529
|
+
...where,
|
|
1530
|
+
forceFull: true,
|
|
1531
|
+
raw: options.tree === "raw",
|
|
1532
|
+
timeoutMs: options.timeoutMs
|
|
1533
|
+
})),
|
|
1534
|
+
screenshot: (path) => run("screenshot", async () => (await client.capture.screenshot({
|
|
1535
|
+
...where,
|
|
1536
|
+
path
|
|
1537
|
+
})).path),
|
|
1538
|
+
tap: (ref, options) => run("tap", async () => toSettled(await client.interactions.press({
|
|
1539
|
+
...where,
|
|
1540
|
+
ref,
|
|
1541
|
+
...settle(options)
|
|
1542
|
+
}))),
|
|
1543
|
+
longPress: (ref, durationMs, options) => run("longPress", async () => toSettled(await client.interactions.longPress({
|
|
1544
|
+
...where,
|
|
1545
|
+
ref,
|
|
1546
|
+
durationMs,
|
|
1547
|
+
...settle(options)
|
|
1548
|
+
}))),
|
|
1549
|
+
fill: (ref, text, options) => run("fill", async () => toSettled(await client.interactions.fill({
|
|
1550
|
+
...where,
|
|
1551
|
+
ref,
|
|
1552
|
+
text,
|
|
1553
|
+
...settle(options)
|
|
1554
|
+
}))),
|
|
1555
|
+
scroll: (direction, options) => run("scroll", async () => {
|
|
1556
|
+
await client.interactions.scroll({
|
|
1557
|
+
...where,
|
|
1558
|
+
direction,
|
|
1559
|
+
...settle(options)
|
|
1560
|
+
});
|
|
1561
|
+
}),
|
|
1562
|
+
dismissDevOverlay: () => run("dismissDevOverlay", async () => {
|
|
1563
|
+
await client.command.reactNative({
|
|
1564
|
+
...where,
|
|
1565
|
+
action: "dismiss-overlay"
|
|
1566
|
+
});
|
|
1567
|
+
}),
|
|
1568
|
+
close: (target) => run("close", async () => {
|
|
1569
|
+
try {
|
|
1570
|
+
await client.sessions.close({ session: target });
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
if (readNormalized(error).code !== "SESSION_NOT_FOUND") throw error;
|
|
1573
|
+
}
|
|
1574
|
+
})
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
function settle(options) {
|
|
1578
|
+
return {
|
|
1579
|
+
settle: true,
|
|
1580
|
+
settleQuietMs: options.settleQuietMs,
|
|
1581
|
+
timeoutMs: options.timeoutMs
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
/** `settle` is best-effort upstream and never fails an action, so an absent observation is not an error. */
|
|
1585
|
+
function toSettled(result) {
|
|
1586
|
+
return {
|
|
1587
|
+
settled: result.settle?.settled ?? false,
|
|
1588
|
+
waitedMs: result.settle?.waitedMs ?? 0
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
function readNormalized(error) {
|
|
1592
|
+
return normalizeAgentDeviceError(error);
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Timeouts and transport faults both arrive as `COMMAND_FAILED`, so the message
|
|
1596
|
+
* and `details.reason` separate them. That matches against upstream text, and it
|
|
1597
|
+
* is confined to this function for exactly that reason.
|
|
1598
|
+
*/
|
|
1599
|
+
function classifyError(error) {
|
|
1600
|
+
const normalized = readNormalized(error);
|
|
1601
|
+
const { code, message } = normalized;
|
|
1602
|
+
const details = normalized.details ?? {};
|
|
1603
|
+
const logPath = normalized.logPath ?? null;
|
|
1604
|
+
switch (code) {
|
|
1605
|
+
case "DEVICE_IN_USE": return {
|
|
1606
|
+
kind: "device-busy",
|
|
1607
|
+
owner: ownerOf(details, message),
|
|
1608
|
+
detail: message
|
|
1609
|
+
};
|
|
1610
|
+
case "DEVICE_NOT_FOUND": return {
|
|
1611
|
+
kind: "device-missing",
|
|
1612
|
+
detail: message
|
|
1613
|
+
};
|
|
1614
|
+
case "APP_NOT_INSTALLED": return {
|
|
1615
|
+
kind: "app-missing",
|
|
1616
|
+
detail: message
|
|
1617
|
+
};
|
|
1618
|
+
case "AMBIGUOUS_MATCH": return {
|
|
1619
|
+
kind: "ambiguous",
|
|
1620
|
+
detail: message
|
|
1621
|
+
};
|
|
1622
|
+
case "INVALID_ARGS": {
|
|
1623
|
+
const bound = /bound to (.+?)(?:[.]|$)/i.exec(message);
|
|
1624
|
+
if (bound !== null) return {
|
|
1625
|
+
kind: "session-rebound",
|
|
1626
|
+
boundTo: bound[1] ?? message,
|
|
1627
|
+
detail: message
|
|
1628
|
+
};
|
|
1629
|
+
return {
|
|
1630
|
+
kind: "unknown",
|
|
1631
|
+
code,
|
|
1632
|
+
detail: message,
|
|
1633
|
+
logPath
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
case "COMMAND_FAILED":
|
|
1637
|
+
if (details["reason"] === "ref_generation_mismatch") return {
|
|
1638
|
+
kind: "stale-ref",
|
|
1639
|
+
detail: message
|
|
1640
|
+
};
|
|
1641
|
+
if (/timed out|timeout/i.test(message)) return {
|
|
1642
|
+
kind: "timeout",
|
|
1643
|
+
detail: message
|
|
1644
|
+
};
|
|
1645
|
+
return {
|
|
1646
|
+
kind: "unknown",
|
|
1647
|
+
code,
|
|
1648
|
+
detail: message,
|
|
1649
|
+
logPath
|
|
1650
|
+
};
|
|
1651
|
+
default: return {
|
|
1652
|
+
kind: "unknown",
|
|
1653
|
+
code,
|
|
1654
|
+
detail: message,
|
|
1655
|
+
logPath
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* A device claim made in another workspace does not appear in a session listing
|
|
1661
|
+
* run from here, so the owning session name in the error is the only way to name
|
|
1662
|
+
* it. It arrives in `details` on some paths and only in the message text
|
|
1663
|
+
* (`by session "lex"`) on others.
|
|
1664
|
+
*/
|
|
1665
|
+
function ownerOf(details, message) {
|
|
1666
|
+
for (const key of [
|
|
1667
|
+
"session",
|
|
1668
|
+
"owner",
|
|
1669
|
+
"ownerSession"
|
|
1670
|
+
]) {
|
|
1671
|
+
const value = details[key];
|
|
1672
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
1673
|
+
}
|
|
1674
|
+
return /by session "([^"]+)"/.exec(message)?.[1] ?? null;
|
|
1675
|
+
}
|
|
1676
|
+
//#endregion
|
|
1677
|
+
//#region src/core/screenshot.ts
|
|
1678
|
+
/**
|
|
1679
|
+
* The ratio is mismatched pixels over the image's own pixel count, so it means
|
|
1680
|
+
* the same whether the images are a whole device or one cropped button.
|
|
1681
|
+
*/
|
|
1682
|
+
function compareScreenshot(expected, actual, options) {
|
|
1683
|
+
const before = PNG.sync.read(expected);
|
|
1684
|
+
const after = PNG.sync.read(actual);
|
|
1685
|
+
if (before.width !== after.width || before.height !== after.height) return {
|
|
1686
|
+
kind: "size-mismatch",
|
|
1687
|
+
expected: {
|
|
1688
|
+
width: before.width,
|
|
1689
|
+
height: before.height
|
|
1690
|
+
},
|
|
1691
|
+
actual: {
|
|
1692
|
+
width: after.width,
|
|
1693
|
+
height: after.height
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
for (const box of options.mask) {
|
|
1697
|
+
paintBlack(before, box);
|
|
1698
|
+
paintBlack(after, box);
|
|
1699
|
+
}
|
|
1700
|
+
const diff = new PNG({
|
|
1701
|
+
width: before.width,
|
|
1702
|
+
height: before.height
|
|
1703
|
+
});
|
|
1704
|
+
const ratio = pixelmatch(before.data, after.data, diff.data, before.width, before.height, { threshold: options.threshold }) / (before.width * before.height);
|
|
1705
|
+
if (ratio <= options.maxDiffPixelRatio) return {
|
|
1706
|
+
kind: "match",
|
|
1707
|
+
ratio
|
|
1708
|
+
};
|
|
1709
|
+
return {
|
|
1710
|
+
kind: "mismatch",
|
|
1711
|
+
ratio,
|
|
1712
|
+
diff: PNG.sync.write(diff)
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* The box is clamped to the image, because a rect comes from a snapshot and a
|
|
1717
|
+
* screenshot is a separate capture. A control flush against the bottom edge can
|
|
1718
|
+
* round a pixel past it, and that is not a reason to fail an assertion.
|
|
1719
|
+
*/
|
|
1720
|
+
function cropScreenshot(source, box) {
|
|
1721
|
+
const image = PNG.sync.read(source);
|
|
1722
|
+
const clamped = clamp(box, {
|
|
1723
|
+
width: image.width,
|
|
1724
|
+
height: image.height
|
|
1725
|
+
});
|
|
1726
|
+
const cut = new PNG({
|
|
1727
|
+
width: clamped.width,
|
|
1728
|
+
height: clamped.height
|
|
1729
|
+
});
|
|
1730
|
+
PNG.bitblt(image, cut, clamped.x, clamped.y, clamped.width, clamped.height, 0, 0);
|
|
1731
|
+
return PNG.sync.write(cut);
|
|
1732
|
+
}
|
|
1733
|
+
function sizeOf(source) {
|
|
1734
|
+
const image = PNG.sync.read(source);
|
|
1735
|
+
return {
|
|
1736
|
+
width: image.width,
|
|
1737
|
+
height: image.height
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
/** Rounded outward, so a control's own edge is never the thing that gets cut off. */
|
|
1741
|
+
function toPixelBox(rect, scale) {
|
|
1742
|
+
const x = Math.floor(rect.x * scale);
|
|
1743
|
+
const y = Math.floor(rect.y * scale);
|
|
1744
|
+
return {
|
|
1745
|
+
x,
|
|
1746
|
+
y,
|
|
1747
|
+
width: Math.ceil((rect.x + rect.width) * scale) - x,
|
|
1748
|
+
height: Math.ceil((rect.y + rect.height) * scale) - y
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1751
|
+
/** Moves a box into the coordinates of a crop taken at `origin`. */
|
|
1752
|
+
function relativeTo(box, origin) {
|
|
1753
|
+
return {
|
|
1754
|
+
...box,
|
|
1755
|
+
x: box.x - origin.x,
|
|
1756
|
+
y: box.y - origin.y
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
function clamp(box, size) {
|
|
1760
|
+
const x = Math.min(Math.max(box.x, 0), Math.max(size.width - 1, 0));
|
|
1761
|
+
const y = Math.min(Math.max(box.y, 0), Math.max(size.height - 1, 0));
|
|
1762
|
+
return {
|
|
1763
|
+
x,
|
|
1764
|
+
y,
|
|
1765
|
+
width: Math.max(Math.min(box.width, size.width - x), 1),
|
|
1766
|
+
height: Math.max(Math.min(box.height, size.height - y), 1)
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
function paintBlack(image, box) {
|
|
1770
|
+
const region = clamp(box, {
|
|
1771
|
+
width: image.width,
|
|
1772
|
+
height: image.height
|
|
1773
|
+
});
|
|
1774
|
+
for (let row = region.y; row < region.y + region.height; row += 1) for (let column = region.x; column < region.x + region.width; column += 1) {
|
|
1775
|
+
const at = image.width * row + column << 2;
|
|
1776
|
+
image.data[at] = 0;
|
|
1777
|
+
image.data[at + 1] = 0;
|
|
1778
|
+
image.data[at + 2] = 0;
|
|
1779
|
+
image.data[at + 3] = 255;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
//#endregion
|
|
1783
|
+
//#region src/preflight.ts
|
|
1784
|
+
/**
|
|
1785
|
+
* Outside `core/` on purpose: this is the one function that needs a concrete
|
|
1786
|
+
* driver, and no module under `core/` may import `agent-device`.
|
|
1787
|
+
*/
|
|
1788
|
+
/**
|
|
1789
|
+
* A device that is not booted is reported as problems rather than thrown, so a
|
|
1790
|
+
* runner names every one of them at once. A malformed config still throws, the
|
|
1791
|
+
* way it does everywhere else. `driver` is the seam tests inject.
|
|
1792
|
+
*/
|
|
1793
|
+
async function preflight(options, driver) {
|
|
1794
|
+
const resolved = parseDeviceOptions(options);
|
|
1795
|
+
const lister = driver ?? createAgentDeviceDriver(createClient(), `${resolved.sessionPrefix}-preflight`, {
|
|
1796
|
+
platform: resolved.platform,
|
|
1797
|
+
name: null
|
|
1798
|
+
});
|
|
1799
|
+
let devices;
|
|
1800
|
+
try {
|
|
1801
|
+
devices = await lister.listDevices();
|
|
1802
|
+
} catch (error) {
|
|
1803
|
+
return {
|
|
1804
|
+
ok: false,
|
|
1805
|
+
problems: [unreachable(resolved.platform, error)]
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
const booted = devices.filter((device) => device.booted);
|
|
1809
|
+
const wanted = namesOf(resolved.device);
|
|
1810
|
+
if (wanted.length === 0) {
|
|
1811
|
+
const picked = booted[0];
|
|
1812
|
+
if (picked === void 0) return {
|
|
1813
|
+
ok: false,
|
|
1814
|
+
problems: [noneBooted(resolved.platform)]
|
|
1815
|
+
};
|
|
1816
|
+
return {
|
|
1817
|
+
ok: true,
|
|
1818
|
+
device: {
|
|
1819
|
+
name: picked.name,
|
|
1820
|
+
id: picked.id
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
const found = [];
|
|
1825
|
+
const problems = [];
|
|
1826
|
+
for (const name of wanted) {
|
|
1827
|
+
const match = booted.find((device) => device.name === name);
|
|
1828
|
+
if (match === void 0) problems.push(notBooted(resolved.platform, name, booted));
|
|
1829
|
+
else found.push(match);
|
|
1830
|
+
}
|
|
1831
|
+
const picked = found[0];
|
|
1832
|
+
if (problems.length > 0 || picked === void 0) return {
|
|
1833
|
+
ok: false,
|
|
1834
|
+
problems
|
|
1835
|
+
};
|
|
1836
|
+
return {
|
|
1837
|
+
ok: true,
|
|
1838
|
+
device: {
|
|
1839
|
+
name: picked.name,
|
|
1840
|
+
id: picked.id
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
/** The names this choice insists on. Empty means any booted device will do. */
|
|
1845
|
+
function namesOf(choice) {
|
|
1846
|
+
switch (choice.kind) {
|
|
1847
|
+
case "first-booted": return [];
|
|
1848
|
+
case "named": return [choice.name];
|
|
1849
|
+
case "pool": return choice.names;
|
|
1850
|
+
default: throw new Error(`unhandled device choice ${JSON.stringify(choice)}`);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
function unreachable(platform, error) {
|
|
1854
|
+
const failure = failureOf(error);
|
|
1855
|
+
return `Could not list ${platform} devices: ${failure === null ? error instanceof Error ? error.message : String(error) : describeFailure(failure)}. Check that the agent-device daemon is reachable.`;
|
|
1856
|
+
}
|
|
1857
|
+
function noneBooted(platform) {
|
|
1858
|
+
return `No ${platform} device is booted. Boot one with \`agent-device device boot --platform ${platform}\`.`;
|
|
1859
|
+
}
|
|
1860
|
+
function notBooted(platform, name, booted) {
|
|
1861
|
+
if (booted.length === 0) return `No booted ${platform} device is named '${name}', because no ${platform} device is booted at all. Boot '${name}'.`;
|
|
1862
|
+
return `No booted ${platform} device is named '${name}'. Booted right now: ${booted.map((device) => `'${device.name}'`).join(", ")}. Set use.deviceName to one of those or boot '${name}'.`;
|
|
1863
|
+
}
|
|
1864
|
+
//#endregion
|
|
1865
|
+
export { textMatch as A, describeCheck as C, parseDeviceOptions as D, deviceNameForSlot as E, describeQuery as O, resolve as S, TANGERE_DEFAULTS as T, sleep as _, sizeOf as a, parseScreen as b, createClient as c, createScrollSearch as d, directionToward as f, sessionName as g, openSession as h, relativeTo as i, TangereError as j, normalizeText as k, captureEvidence as l, probe as m, compareScreenshot as n, toPixelBox as o, formatFailure as p, cropScreenshot as r, createAgentDeviceDriver as s, preflight as t, createDevice as u, renderTitle as v, evaluate as w, renderScreen as x, silentSink as y };
|