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.
@@ -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 };