touchpress 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/{preflight-Czhj9QFM.d.mts → preflight-CqFFdaAv.d.mts} +104 -1
- package/dist/{preflight-CWeJYtS7.mjs → preflight-sFz236o3.mjs} +253 -15
- package/package.json +1 -1
package/dist/core/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as DeviceSelection, A as ResolvedOptions, B as renderTitle, C as DeviceSession, Ct as parseScreen, D as sessionName, E as openSession, F as ActionRecord, G as formatFailure, H as ProbeOptions, I as ActionSink, J as Binding, K as probe, L as EvidenceFile, M as TouchpressOptions, N as deviceNameForSlot, O as DeviceChoice, P as parseDeviceOptions, Q as DeviceInfo, R as StepOptions, S as createDevice, St as ScreenNode, T as SessionState, Tt as resolve, U as ProbeResult, V as silentSink, W as ProbeTarget, X as DeviceDriver, Y as CaptureOptions, Z as DeviceFailure, _ as Keyboard, _t as Platform, a as ExpectedValue, at as CheckName, b as TextOptions, bt as Resolution, c as ScrollSearch, ct as evaluate, d as directionToward, dt as Role, et as OpenRequest, f as ActionOptions, ft as TextMatch, g as FilterOptions, gt as PinnedRef, h as FillOptions, ht as textMatch, i as ErrorInfo, it as Check, j as TOUCHPRESS_DEFAULTS, k as ReadyQuery, l as ScrollTrail, lt as Filter, m as Device, mt as normalizeText, n as PreflightReport, nt as Settled, o as TouchpressError, ot as Verdict, p as BackOptions, pt as describeQuery, q as BackMode, r as preflight, rt as Tree, s as ScrollDevice, st as describeCheck, t as PreflightDevice, tt as ScrollDirection, u as createScrollSearch, ut as Query, v as Locator, vt as RawSnapshot, w as OpenSessionInput, wt as renderScreen, x as TypeOptions, xt as Screen, y as RoleOptions, yt as Rect, z as Typed } from "../preflight-CqFFdaAv.mjs";
|
|
2
2
|
//#region src/core/evidence.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* Never throws. A capture that fails records a note and returns, because masking
|
|
@@ -70,4 +70,4 @@ declare function toPixelBox(rect: {
|
|
|
70
70
|
/** Moves a box into the coordinates of a crop taken at `origin`. */
|
|
71
71
|
declare function relativeTo(box: PixelBox, origin: PixelBox): PixelBox;
|
|
72
72
|
//#endregion
|
|
73
|
-
export { type ActionOptions, type ActionRecord, type ActionSink, type Binding, type CaptureOptions, type Check, type CheckName, type CompareOptions, type Comparison, type Device, type DeviceChoice, type DeviceDriver, type DeviceFailure, type DeviceInfo, type DeviceSelection, type DeviceSession, type ErrorInfo, type EvidenceFile, type ExpectedValue, type FillOptions, type Filter, type FilterOptions, type Locator, type OpenRequest, type OpenSessionInput, type PinnedRef, type PixelBox, type Platform, type PreflightDevice, type PreflightReport, type ProbeOptions, type ProbeResult, type ProbeTarget, type Query, type RawSnapshot, type ReadyQuery, type Rect, type Resolution, type ResolvedOptions, type Role, type RoleOptions, type Screen, type ScreenNode, type ScrollDevice, type ScrollDirection, type ScrollSearch, type ScrollTrail, type SessionState, type Settled, type Size, type StepOptions, TOUCHPRESS_DEFAULTS, type TextMatch, type TextOptions, TouchpressError, type TouchpressOptions, type Tree, type Typed, type Verdict, captureEvidence, compareScreenshot, createDevice, createScrollSearch, cropScreenshot, describeCheck, describeQuery, deviceNameForSlot, directionToward, evaluate, formatFailure, normalizeText, openSession, parseDeviceOptions, parseScreen, preflight, probe, relativeTo, renderScreen, renderTitle, resolve, sessionName, silentSink, sizeOf, textMatch, toPixelBox };
|
|
73
|
+
export { type ActionOptions, type ActionRecord, type ActionSink, type BackMode, type BackOptions, type Binding, type CaptureOptions, type Check, type CheckName, type CompareOptions, type Comparison, type Device, type DeviceChoice, type DeviceDriver, type DeviceFailure, type DeviceInfo, type DeviceSelection, type DeviceSession, type ErrorInfo, type EvidenceFile, type ExpectedValue, type FillOptions, type Filter, type FilterOptions, type Keyboard, type Locator, type OpenRequest, type OpenSessionInput, type PinnedRef, type PixelBox, type Platform, type PreflightDevice, type PreflightReport, type ProbeOptions, type ProbeResult, type ProbeTarget, type Query, type RawSnapshot, type ReadyQuery, type Rect, type Resolution, type ResolvedOptions, type Role, type RoleOptions, type Screen, type ScreenNode, type ScrollDevice, type ScrollDirection, type ScrollSearch, type ScrollTrail, type SessionState, type Settled, type Size, type StepOptions, TOUCHPRESS_DEFAULTS, type TextMatch, type TextOptions, TouchpressError, type TouchpressOptions, type Tree, type TypeOptions, type Typed, type Verdict, captureEvidence, compareScreenshot, createDevice, createScrollSearch, cropScreenshot, describeCheck, describeQuery, deviceNameForSlot, directionToward, evaluate, formatFailure, normalizeText, openSession, parseDeviceOptions, parseScreen, preflight, probe, relativeTo, renderScreen, renderTitle, resolve, sessionName, silentSink, sizeOf, textMatch, toPixelBox };
|
package/dist/core/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as normalizeText, C as parseScreen, D as deviceNameForSlot, E as TOUCHPRESS_DEFAULTS, M as TouchpressError, O as parseDeviceOptions, S as silentSink, T as resolve, a as sizeOf, d as createScrollSearch, f as directionToward, g as evaluate, h as describeCheck, i as relativeTo, j as textMatch, k as describeQuery, l as captureEvidence, m as probe, n as compareScreenshot, o as toPixelBox, p as formatFailure, r as cropScreenshot, t as preflight, u as createDevice, v as openSession, w as renderScreen, x as renderTitle, y as sessionName } from "../preflight-
|
|
1
|
+
import { A as normalizeText, C as parseScreen, D as deviceNameForSlot, E as TOUCHPRESS_DEFAULTS, M as TouchpressError, O as parseDeviceOptions, S as silentSink, T as resolve, a as sizeOf, d as createScrollSearch, f as directionToward, g as evaluate, h as describeCheck, i as relativeTo, j as textMatch, k as describeQuery, l as captureEvidence, m as probe, n as compareScreenshot, o as toPixelBox, p as formatFailure, r as cropScreenshot, t as preflight, u as createDevice, v as openSession, w as renderScreen, x as renderTitle, y as sessionName } from "../preflight-sFz236o3.mjs";
|
|
2
2
|
export { TOUCHPRESS_DEFAULTS, TouchpressError, captureEvidence, compareScreenshot, createDevice, createScrollSearch, cropScreenshot, describeCheck, describeQuery, deviceNameForSlot, directionToward, evaluate, formatFailure, normalizeText, openSession, parseDeviceOptions, parseScreen, preflight, probe, relativeTo, renderScreen, renderTitle, resolve, sessionName, silentSink, sizeOf, textMatch, toPixelBox };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C as DeviceSession, M as TouchpressOptions$1, St as ScreenNode, _ as Keyboard, _t as Platform, a as ExpectedValue, dt as Role, ft as TextMatch, g as FilterOptions, i as ErrorInfo, k as ReadyQuery, lt as Filter, m as Device$1, n as PreflightReport, o as TouchpressError, p as BackOptions, r as preflight, t as PreflightDevice, ut as Query, v as Locator, x as TypeOptions, xt as Screen, yt as Rect } from "./preflight-CqFFdaAv.mjs";
|
|
2
2
|
import { ExpectMatcherState } from "@playwright/test";
|
|
3
3
|
//#region src/ai/options.d.ts
|
|
4
4
|
/**
|
|
@@ -143,4 +143,4 @@ declare const expect: import("@playwright/test").Expect<{
|
|
|
143
143
|
type TouchpressOptions = TouchpressOptions$1 & AiOptions;
|
|
144
144
|
type Device = Device$1 & AiDevice;
|
|
145
145
|
//#endregion
|
|
146
|
-
export { type ActOptions, type AiDevice, type AiModel, type AiOptions, Device, type ErrorInfo, type ExpectedValue, type ExtractOptions, type ExtractSchema, type Filter, type FilterOptions, type Locator, type Platform, type PreflightDevice, type PreflightReport, type Query, type ReadyQuery, type Rect, type Role, type Screen, type ScreenNode, type ScreenshotOptions, type TextMatch, TouchpressError, TouchpressOptions, expect, preflight, setupTest, test };
|
|
146
|
+
export { type ActOptions, type AiDevice, type AiModel, type AiOptions, type BackOptions, Device, type ErrorInfo, type ExpectedValue, type ExtractOptions, type ExtractSchema, type Filter, type FilterOptions, type Keyboard, type Locator, type Platform, type PreflightDevice, type PreflightReport, type Query, type ReadyQuery, type Rect, type Role, type Screen, type ScreenNode, type ScreenshotOptions, type TextMatch, TouchpressError, TouchpressOptions, type TypeOptions, expect, preflight, setupTest, test };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as parseScreen, E as TOUCHPRESS_DEFAULTS, M as TouchpressError, O as parseDeviceOptions, S as silentSink, T as resolve, _ as createQueue, a as sizeOf, b as sleep, c as createClient, i as relativeTo, j as textMatch, l as captureEvidence, n as compareScreenshot, o as toPixelBox, r as cropScreenshot, s as createAgentDeviceDriver, t as preflight, u as createDevice, v as openSession, w as renderScreen, x as renderTitle } from "./preflight-
|
|
1
|
+
import { C as parseScreen, E as TOUCHPRESS_DEFAULTS, M as TouchpressError, O as parseDeviceOptions, S as silentSink, T as resolve, _ as createQueue, a as sizeOf, b as sleep, c as createClient, i as relativeTo, j as textMatch, l as captureEvidence, n as compareScreenshot, o as toPixelBox, r as cropScreenshot, s as createAgentDeviceDriver, t as preflight, u as createDevice, v as openSession, w as renderScreen, x as renderTitle } from "./preflight-sFz236o3.mjs";
|
|
2
2
|
import { expect as expect$1, test as test$1 } from "@playwright/test";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { dirname } from "node:path";
|
|
@@ -233,6 +233,11 @@ declare function describeCheck(check: Check): string;
|
|
|
233
233
|
//#endregion
|
|
234
234
|
//#region src/core/driver.d.ts
|
|
235
235
|
type ScrollDirection = 'up' | 'down' | 'left' | 'right';
|
|
236
|
+
/**
|
|
237
|
+
* `in-app` is the navigation control the app draws. `system` is the platform
|
|
238
|
+
* gesture or key, which iOS does not have.
|
|
239
|
+
*/
|
|
240
|
+
type BackMode = 'in-app' | 'system';
|
|
236
241
|
/**
|
|
237
242
|
* A session binds on its first command, so this rides on `open` and on every
|
|
238
243
|
* command after it. A call sent without selection lands on whichever device the
|
|
@@ -262,6 +267,8 @@ type Binding = {
|
|
|
262
267
|
readonly deviceLabel: string;
|
|
263
268
|
readonly appId: string;
|
|
264
269
|
readonly stateDir: string | null;
|
|
270
|
+
/** What lets a driver reach the device with a tool of its own. Null when the open response named none. */
|
|
271
|
+
readonly udid: string | null;
|
|
265
272
|
};
|
|
266
273
|
type Settled = {
|
|
267
274
|
readonly settled: boolean;
|
|
@@ -291,6 +298,14 @@ type DeviceFailure = {
|
|
|
291
298
|
} | {
|
|
292
299
|
readonly kind: 'ambiguous';
|
|
293
300
|
readonly detail: string;
|
|
301
|
+
} |
|
|
302
|
+
/**
|
|
303
|
+
* The pinned node owns no touch point outside the interactive nodes drawn
|
|
304
|
+
* over it, so the driver refused rather than guess which one was meant.
|
|
305
|
+
*/
|
|
306
|
+
{
|
|
307
|
+
readonly kind: 'covered';
|
|
308
|
+
readonly detail: string;
|
|
294
309
|
} | {
|
|
295
310
|
readonly kind: 'timeout';
|
|
296
311
|
readonly detail: string;
|
|
@@ -334,6 +349,24 @@ type DeviceDriver = {
|
|
|
334
349
|
tap(ref: PinnedRef, options: SettleOptions): Promise<Settled>;
|
|
335
350
|
longPress(ref: PinnedRef, durationMs: number, options: SettleOptions): Promise<Settled>;
|
|
336
351
|
fill(ref: PinnedRef, text: string, options: SettleOptions): Promise<Settled>;
|
|
352
|
+
/** Takes no target, because the platform decides what goes back. */
|
|
353
|
+
back(mode: BackMode, options: SettleOptions): Promise<Settled>;
|
|
354
|
+
/**
|
|
355
|
+
* Types into whatever holds focus. The one mutation with no `PinnedRef`,
|
|
356
|
+
* because a field the app focused for itself may carry nothing to select on.
|
|
357
|
+
*/
|
|
358
|
+
type(text: string, options: SettleOptions): Promise<Settled>;
|
|
359
|
+
/**
|
|
360
|
+
* Discards the app's stored state. Whether that reaches every store the app
|
|
361
|
+
* writes to is the driver's problem, not the core's.
|
|
362
|
+
*/
|
|
363
|
+
clearAppState(app: string): Promise<void>;
|
|
364
|
+
/**
|
|
365
|
+
* Resets the device's keychain, which on an iOS simulator is shared by every
|
|
366
|
+
* app on it. Kept apart from `clearAppState` for that reason. A no-op on a
|
|
367
|
+
* platform whose app data clear already covers the app's secure store.
|
|
368
|
+
*/
|
|
369
|
+
resetKeychain(): Promise<void>;
|
|
337
370
|
/** Returns nothing because the driver's scroll response carries no settle observation. */
|
|
338
371
|
scroll(direction: ScrollDirection, options: SettleOptions): Promise<void>;
|
|
339
372
|
dismissDevOverlay(): Promise<void>;
|
|
@@ -434,6 +467,11 @@ type ActionRecord = {
|
|
|
434
467
|
} | {
|
|
435
468
|
readonly kind: 'typed';
|
|
436
469
|
readonly typed: Typed;
|
|
470
|
+
} |
|
|
471
|
+
/** Where an action landed after the driver refused the node the locator matched. */
|
|
472
|
+
{
|
|
473
|
+
readonly kind: 'retarget';
|
|
474
|
+
readonly target: string;
|
|
437
475
|
} | {
|
|
438
476
|
readonly kind: 'scroll';
|
|
439
477
|
readonly direction: ScrollDirection;
|
|
@@ -443,6 +481,14 @@ type ActionRecord = {
|
|
|
443
481
|
} | {
|
|
444
482
|
readonly kind: 'relaunch';
|
|
445
483
|
readonly app: string;
|
|
484
|
+
} | {
|
|
485
|
+
readonly kind: 'back';
|
|
486
|
+
readonly mode: BackMode;
|
|
487
|
+
} | {
|
|
488
|
+
readonly kind: 'clear-state';
|
|
489
|
+
readonly app: string;
|
|
490
|
+
} | {
|
|
491
|
+
readonly kind: 'clear-keychain';
|
|
446
492
|
} | {
|
|
447
493
|
readonly kind: 'dismiss-overlay';
|
|
448
494
|
} | {
|
|
@@ -622,6 +668,10 @@ type SessionDevice = {
|
|
|
622
668
|
tap(ref: PinnedRef, budgetMs: number): Promise<Settled>;
|
|
623
669
|
longPress(ref: PinnedRef, durationMs: number, budgetMs: number): Promise<Settled>;
|
|
624
670
|
fill(ref: PinnedRef, text: string, budgetMs: number): Promise<Settled>;
|
|
671
|
+
back(mode: BackMode, budgetMs: number): Promise<Settled>;
|
|
672
|
+
type(text: string, budgetMs: number): Promise<Settled>;
|
|
673
|
+
clearAppState(app: string): Promise<void>;
|
|
674
|
+
resetKeychain(): Promise<void>;
|
|
625
675
|
scroll(direction: ScrollDirection, budgetMs: number): Promise<void>;
|
|
626
676
|
};
|
|
627
677
|
type DeviceSession = {
|
|
@@ -636,6 +686,18 @@ type DeviceSession = {
|
|
|
636
686
|
screenshot(path: string): Promise<string>;
|
|
637
687
|
/** Relaunches the app and re-runs the ready gate. Reported as one step. */
|
|
638
688
|
relaunch(sink: ActionSink): Promise<void>;
|
|
689
|
+
/**
|
|
690
|
+
* Discards the app's stored state, then relaunches and re-runs the ready
|
|
691
|
+
* gate, because an app holding a cleared session in memory has not been
|
|
692
|
+
* cleared. Reported as one step with the relaunch nested under it.
|
|
693
|
+
*/
|
|
694
|
+
clearState(sink: ActionSink): Promise<void>;
|
|
695
|
+
/**
|
|
696
|
+
* Resets the simulator's keychain, which every app on it shares. Separate
|
|
697
|
+
* from `clearState` so an app-scoped clear never wipes another app's store.
|
|
698
|
+
* Reported as one step, with a note on Android where there is nothing to reset.
|
|
699
|
+
*/
|
|
700
|
+
clearKeychain(sink: ActionSink): Promise<void>;
|
|
639
701
|
dismissDevOverlay(): Promise<void>;
|
|
640
702
|
awaitReady(deadline: number): Promise<void>;
|
|
641
703
|
/** Idempotent. Never shuts the simulator down, and reaches `closed` even when the driver call fails. */
|
|
@@ -691,6 +753,25 @@ type FilterOptions = {
|
|
|
691
753
|
type FillOptions = ActionOptions & {
|
|
692
754
|
secret?: boolean;
|
|
693
755
|
};
|
|
756
|
+
type BackOptions = ActionOptions & {
|
|
757
|
+
mode?: BackMode;
|
|
758
|
+
};
|
|
759
|
+
/** `secret` reports a character count instead of the text, the way `fill`'s does. */
|
|
760
|
+
type TypeOptions = ActionOptions & {
|
|
761
|
+
secret?: boolean;
|
|
762
|
+
};
|
|
763
|
+
/**
|
|
764
|
+
* Playwright's `page.keyboard`, cut down to what a device keyboard can do with
|
|
765
|
+
* no target. Everything else touchpress offers needs a locator.
|
|
766
|
+
*/
|
|
767
|
+
type Keyboard = {
|
|
768
|
+
/**
|
|
769
|
+
* Types into whatever holds focus, for a field with nothing to select on,
|
|
770
|
+
* such as a one-time-code input the app focused for itself. Nothing is read
|
|
771
|
+
* back, because there is no target to read.
|
|
772
|
+
*/
|
|
773
|
+
type(text: string, options?: TypeOptions): Promise<void>;
|
|
774
|
+
};
|
|
694
775
|
type Device = {
|
|
695
776
|
/** Matches a node's accessibility name or its value. */
|
|
696
777
|
getByText(text: string | RegExp, options?: TextOptions): Locator;
|
|
@@ -699,6 +780,20 @@ type Device = {
|
|
|
699
780
|
getByTestId(testId: string): Locator;
|
|
700
781
|
locator(query: Query): Locator;
|
|
701
782
|
scroll(direction: ScrollDirection): Promise<void>;
|
|
783
|
+
/**
|
|
784
|
+
* Goes back. Defaults to the platform gesture on Android and to the app's own
|
|
785
|
+
* navigation control on iOS, which has no system back to press.
|
|
786
|
+
*/
|
|
787
|
+
goBack(options?: BackOptions): Promise<void>;
|
|
788
|
+
readonly keyboard: Keyboard;
|
|
789
|
+
/** Discards the app's stored state, then relaunches and waits for the ready gate again. */
|
|
790
|
+
clearState(): Promise<void>;
|
|
791
|
+
/**
|
|
792
|
+
* Resets the simulator's keychain, which is shared by every app on it, so
|
|
793
|
+
* it is not part of `clearState`. A no-op on Android, where clearing state
|
|
794
|
+
* already removes the app's keystore entries.
|
|
795
|
+
*/
|
|
796
|
+
clearKeychain(): Promise<void>;
|
|
702
797
|
/** Relaunches the app and waits for the ready gate again. */
|
|
703
798
|
relaunch(): Promise<void>;
|
|
704
799
|
/** Never automatic, because hiding the overlay would suppress a warning a test might want to see. */
|
|
@@ -833,6 +928,14 @@ type ErrorInfo = {
|
|
|
833
928
|
readonly attempts: number;
|
|
834
929
|
readonly timeoutMs: number;
|
|
835
930
|
readonly screen: string;
|
|
931
|
+
} | {
|
|
932
|
+
readonly kind: 'untappable';
|
|
933
|
+
readonly locator: string;
|
|
934
|
+
readonly screen: string;
|
|
935
|
+
} |
|
|
936
|
+
/** Carries no field, because the rejected text is the one thing this error may not hold. */
|
|
937
|
+
{
|
|
938
|
+
readonly kind: 'type-rejected';
|
|
836
939
|
} | {
|
|
837
940
|
readonly kind: 'driver';
|
|
838
941
|
readonly command: string;
|
|
@@ -882,4 +985,4 @@ type PreflightReport = {
|
|
|
882
985
|
*/
|
|
883
986
|
declare function preflight(options: Partial<TouchpressOptions>, driver?: DeviceDriver): Promise<PreflightReport>;
|
|
884
987
|
//#endregion
|
|
885
|
-
export {
|
|
988
|
+
export { DeviceSelection as $, ResolvedOptions as A, renderTitle as B, DeviceSession as C, parseScreen as Ct, sessionName as D, openSession as E, ActionRecord as F, formatFailure as G, ProbeOptions as H, ActionSink as I, Binding as J, probe as K, EvidenceFile as L, TouchpressOptions as M, deviceNameForSlot as N, DeviceChoice as O, parseDeviceOptions as P, DeviceInfo as Q, StepOptions as R, createDevice as S, ScreenNode as St, SessionState as T, resolve as Tt, ProbeResult as U, silentSink as V, ProbeTarget as W, DeviceDriver as X, CaptureOptions as Y, DeviceFailure as Z, Keyboard as _, Platform as _t, ExpectedValue as a, CheckName as at, TextOptions as b, Resolution as bt, ScrollSearch as c, evaluate as ct, directionToward as d, Role as dt, OpenRequest as et, ActionOptions as f, TextMatch as ft, FilterOptions as g, PinnedRef as gt, FillOptions as h, textMatch as ht, ErrorInfo as i, Check as it, TOUCHPRESS_DEFAULTS as j, ReadyQuery as k, ScrollTrail as l, Filter as lt, Device as m, normalizeText as mt, PreflightReport as n, Settled as nt, TouchpressError as o, Verdict as ot, BackOptions as p, describeQuery as pt, BackMode as q, preflight as r, Tree as rt, ScrollDevice as s, describeCheck as st, PreflightDevice as t, ScrollDirection as tt, createScrollSearch as u, Query as ut, Locator as v, RawSnapshot as vt, OpenSessionInput as w, renderScreen as wt, TypeOptions as x, Screen as xt, RoleOptions as y, Rect as yt, Typed as z };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
1
3
|
import { createAgentDeviceClient, normalizeAgentDeviceError } from "agent-device";
|
|
2
4
|
import pixelmatch from "pixelmatch";
|
|
3
5
|
import { PNG } from "pngjs";
|
|
@@ -58,6 +60,24 @@ function formatError(info) {
|
|
|
58
60
|
`Screen:`,
|
|
59
61
|
info.screen
|
|
60
62
|
].join("\n");
|
|
63
|
+
case "untappable": return [
|
|
64
|
+
`The matched node has no touch point of its own, and no enclosing control was found.`,
|
|
65
|
+
``,
|
|
66
|
+
`Locator: ${info.locator}`,
|
|
67
|
+
``,
|
|
68
|
+
`Name the control that receives the touch, with getByRole, or with`,
|
|
69
|
+
`locator({ role: 'button', where: (node) => ... }).`,
|
|
70
|
+
``,
|
|
71
|
+
`Screen:`,
|
|
72
|
+
info.screen
|
|
73
|
+
].join("\n");
|
|
74
|
+
case "type-rejected": return [
|
|
75
|
+
"The driver cannot type text whose first word looks like a node reference.",
|
|
76
|
+
"It reads a leading @ followed by a name with a digit in it, or by ref, node,",
|
|
77
|
+
"element or el, as a ref to resolve rather than as characters to send.",
|
|
78
|
+
"",
|
|
79
|
+
"Type it through the field instead, with fill on a locator for that field."
|
|
80
|
+
].join("\n");
|
|
61
81
|
case "driver": return `${info.command} failed: ${describeFailure(info.failure)}`;
|
|
62
82
|
case "ai-not-configured": return ["device.act and device.extract need a model.", "Set use.aiModel to a gateway model id, such as 'anthropic/claude-sonnet-5', or to a provider model instance."].join("\n");
|
|
63
83
|
case "ai-missing-peer": return ["device.act and device.extract need the optional peer dependency 'ai'.", "Install it with: pnpm add -D ai"].join("\n");
|
|
@@ -111,6 +131,7 @@ function describeFailure(failure) {
|
|
|
111
131
|
case "session-rebound": return `the session is already bound to ${failure.boundTo} (${failure.detail})`;
|
|
112
132
|
case "stale-ref": return `the screen changed before the action reached it (${failure.detail})`;
|
|
113
133
|
case "ambiguous": return `the driver matched more than one element (${failure.detail})`;
|
|
134
|
+
case "covered": return `the node owns no touch point of its own (${failure.detail})`;
|
|
114
135
|
case "timeout": return `the driver timed out (${failure.detail})`;
|
|
115
136
|
case "unknown": return `${failure.code}: ${failure.detail}${failure.logPath === null ? "" : `\nDiagnostics: ${failure.logPath}`}`;
|
|
116
137
|
default: throw new Error(`unhandled failure ${JSON.stringify(failure)}`);
|
|
@@ -600,6 +621,56 @@ function matchedText(node, query) {
|
|
|
600
621
|
function absorbAncestors(matched, query) {
|
|
601
622
|
return matched.filter((candidate) => !matched.some((other) => other !== candidate && isDescendant(other, candidate) && matchedText(other, query) === matchedText(candidate, query)));
|
|
602
623
|
}
|
|
624
|
+
/** `tab-bar` is left out because it is a container of buttons rather than a control that takes the touch. */
|
|
625
|
+
const INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
|
|
626
|
+
"button",
|
|
627
|
+
"link",
|
|
628
|
+
"switch",
|
|
629
|
+
"slider",
|
|
630
|
+
"text-field",
|
|
631
|
+
"secure-text-field",
|
|
632
|
+
"cell"
|
|
633
|
+
]);
|
|
634
|
+
/**
|
|
635
|
+
* The control a tap on `node` would really land on, or null when the screen
|
|
636
|
+
* offers none.
|
|
637
|
+
*
|
|
638
|
+
* A React Native pressable puts the accessible name on one node and the touch
|
|
639
|
+
* handler on another. On Android the two are siblings, so a locator that
|
|
640
|
+
* matched the label pins a ref that owns no touch point of its own. The button
|
|
641
|
+
* drawn under the label is the thing a finger hits, and its rect is what says
|
|
642
|
+
* so.
|
|
643
|
+
*
|
|
644
|
+
* A descendant wins over an enclosing node, because a control inside the
|
|
645
|
+
* matched node is what that node was labelling. Among several, the smallest
|
|
646
|
+
* wins, so a row of buttons inside one card does not hand back the card.
|
|
647
|
+
*/
|
|
648
|
+
function touchTargetFor(screen, node) {
|
|
649
|
+
const candidates = [];
|
|
650
|
+
for (const other of screen.nodes) {
|
|
651
|
+
if (other === node || !other.enabled || !INTERACTIVE_ROLES.has(other.role)) continue;
|
|
652
|
+
if (other.rect !== null) candidates.push({
|
|
653
|
+
node: other,
|
|
654
|
+
rect: other.rect
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
const inside = smallest(candidates.filter((candidate) => isDescendant(candidate.node, node)));
|
|
658
|
+
if (inside !== null) return inside;
|
|
659
|
+
const own = node.rect;
|
|
660
|
+
if (own === null) return null;
|
|
661
|
+
return smallest(candidates.filter((candidate) => encloses(candidate.rect, own)));
|
|
662
|
+
}
|
|
663
|
+
function encloses(outer, inner) {
|
|
664
|
+
return outer.x <= inner.x && outer.y <= inner.y && outer.x + outer.width >= inner.x + inner.width && outer.y + outer.height >= inner.y + inner.height;
|
|
665
|
+
}
|
|
666
|
+
function smallest(candidates) {
|
|
667
|
+
let best = null;
|
|
668
|
+
for (const candidate of candidates) if (best === null || areaOf(candidate.rect) < areaOf(best.rect)) best = candidate;
|
|
669
|
+
return best === null ? null : best.node;
|
|
670
|
+
}
|
|
671
|
+
function areaOf(rect) {
|
|
672
|
+
return rect.width * rect.height;
|
|
673
|
+
}
|
|
603
674
|
function isDescendant(node, ancestor) {
|
|
604
675
|
for (let walk = node.parent; walk !== null; walk = walk.parent) if (walk === ancestor) return true;
|
|
605
676
|
return false;
|
|
@@ -676,9 +747,13 @@ function renderTitle(record) {
|
|
|
676
747
|
case "long-press": return `longPress ${describeQuery(record.query)} for ${String(record.durationMs)}ms`;
|
|
677
748
|
case "fill": return `fill ${describeQuery(record.query)}`;
|
|
678
749
|
case "typed": return renderTyped(record.typed);
|
|
750
|
+
case "retarget": return `retarget to ${record.target}`;
|
|
679
751
|
case "scroll": return `scroll ${record.direction}`;
|
|
680
752
|
case "scroll-into-view": return `scrollIntoView ${describeQuery(record.query)}`;
|
|
681
753
|
case "relaunch": return `relaunch ${record.app}`;
|
|
754
|
+
case "back": return `back (${record.mode})`;
|
|
755
|
+
case "clear-state": return `clear state of ${record.app}`;
|
|
756
|
+
case "clear-keychain": return "clear keychain";
|
|
682
757
|
case "dismiss-overlay": return "dismiss the React Native dev overlay";
|
|
683
758
|
case "screenshot": return `screenshot ${record.path}`;
|
|
684
759
|
case "act": return `act "${truncate(record.instruction, PROMPT_LIMIT)}"`;
|
|
@@ -805,6 +880,10 @@ function createSession(driver, options, name, binding) {
|
|
|
805
880
|
tap: (ref, budgetMs) => driver.tap(ref, settle(budgetMs)),
|
|
806
881
|
longPress: (ref, durationMs, budgetMs) => driver.longPress(ref, durationMs, settle(budgetMs)),
|
|
807
882
|
fill: (ref, text, budgetMs) => driver.fill(ref, text, settle(budgetMs)),
|
|
883
|
+
back: (mode, budgetMs) => driver.back(mode, settle(budgetMs)),
|
|
884
|
+
type: (text, budgetMs) => driver.type(text, settle(budgetMs)),
|
|
885
|
+
clearAppState: (app) => driver.clearAppState(app),
|
|
886
|
+
resetKeychain: () => driver.resetKeychain(),
|
|
808
887
|
scroll: (direction, budgetMs) => driver.scroll(direction, settle(budgetMs))
|
|
809
888
|
};
|
|
810
889
|
function run(body) {
|
|
@@ -839,15 +918,8 @@ function createSession(driver, options, name, binding) {
|
|
|
839
918
|
screen: renderScreen(screen)
|
|
840
919
|
});
|
|
841
920
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
options,
|
|
845
|
-
state: () => state,
|
|
846
|
-
failure: () => state.phase === "broken" ? state.failure : null,
|
|
847
|
-
run,
|
|
848
|
-
screen: () => run((one) => one.capture()),
|
|
849
|
-
screenshot: (path) => queue.enqueue(() => driver.screenshot(path)),
|
|
850
|
-
relaunch: (sink) => sink.step(renderTitle({
|
|
921
|
+
function relaunch(sink) {
|
|
922
|
+
return sink.step(renderTitle({
|
|
851
923
|
kind: "relaunch",
|
|
852
924
|
app: options.app
|
|
853
925
|
}), async () => {
|
|
@@ -858,6 +930,27 @@ function createSession(driver, options, name, binding) {
|
|
|
858
930
|
}));
|
|
859
931
|
if (options.dismissDevOverlay) await run(() => driver.dismissDevOverlay());
|
|
860
932
|
await awaitReady(Date.now() + options.launchTimeout);
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
name,
|
|
937
|
+
options,
|
|
938
|
+
state: () => state,
|
|
939
|
+
failure: () => state.phase === "broken" ? state.failure : null,
|
|
940
|
+
run,
|
|
941
|
+
screen: () => run((one) => one.capture()),
|
|
942
|
+
screenshot: (path) => queue.enqueue(() => driver.screenshot(path)),
|
|
943
|
+
relaunch,
|
|
944
|
+
clearState: (sink) => sink.step(renderTitle({
|
|
945
|
+
kind: "clear-state",
|
|
946
|
+
app: options.app
|
|
947
|
+
}), async () => {
|
|
948
|
+
await run((one) => one.clearAppState(options.app));
|
|
949
|
+
await relaunch(sink);
|
|
950
|
+
}),
|
|
951
|
+
clearKeychain: (sink) => sink.step(renderTitle({ kind: "clear-keychain" }), async () => {
|
|
952
|
+
if (options.platform === "android") sink.note("keychain", "nothing to reset on Android, clearing state covers the keystore");
|
|
953
|
+
await run((one) => one.resetKeychain());
|
|
861
954
|
}),
|
|
862
955
|
dismissDevOverlay: () => run(() => driver.dismissDevOverlay()),
|
|
863
956
|
awaitReady,
|
|
@@ -1169,6 +1262,25 @@ function createDevice(session, sink) {
|
|
|
1169
1262
|
}), async () => {
|
|
1170
1263
|
await session.run((device) => device.scroll(direction, session.options.actionTimeout));
|
|
1171
1264
|
}),
|
|
1265
|
+
goBack: (options) => {
|
|
1266
|
+
const mode = options?.mode ?? (session.options.platform === "android" ? "system" : "in-app");
|
|
1267
|
+
return sink.step(renderTitle({
|
|
1268
|
+
kind: "back",
|
|
1269
|
+
mode
|
|
1270
|
+
}), async () => {
|
|
1271
|
+
const budget = options?.timeout ?? session.options.actionTimeout;
|
|
1272
|
+
await session.run((device) => device.back(mode, budget));
|
|
1273
|
+
});
|
|
1274
|
+
},
|
|
1275
|
+
keyboard: { type: (text, options) => sink.step(renderTitle({
|
|
1276
|
+
kind: "typed",
|
|
1277
|
+
typed: typedText(text, options)
|
|
1278
|
+
}), async () => {
|
|
1279
|
+
const budget = options?.timeout ?? session.options.actionTimeout;
|
|
1280
|
+
await session.run((device) => device.type(text, budget));
|
|
1281
|
+
}) },
|
|
1282
|
+
clearState: () => session.clearState(sink),
|
|
1283
|
+
clearKeychain: () => session.clearKeychain(sink),
|
|
1172
1284
|
relaunch: () => session.relaunch(sink),
|
|
1173
1285
|
dismissDevOverlay: () => sink.step(renderTitle({ kind: "dismiss-overlay" }), () => session.dismissDevOverlay()),
|
|
1174
1286
|
screen: () => session.screen(),
|
|
@@ -1297,10 +1409,14 @@ function perform(session, sink, record, options, dispatch, write) {
|
|
|
1297
1409
|
try {
|
|
1298
1410
|
settled = await dispatch(device, pin(screen, resolution.node), deadline - Date.now());
|
|
1299
1411
|
} catch (error) {
|
|
1300
|
-
|
|
1301
|
-
retriedStaleRef
|
|
1302
|
-
|
|
1303
|
-
|
|
1412
|
+
const failure = failureOf(error);
|
|
1413
|
+
if (failure?.kind === "stale-ref" && !retriedStaleRef) {
|
|
1414
|
+
retriedStaleRef = true;
|
|
1415
|
+
screen = await device.capture();
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
if (failure?.kind !== "covered" || record.kind === "fill") throw error;
|
|
1419
|
+
settled = await retarget(device, sink, dispatch, locator, screen, resolution.node, deadline - Date.now());
|
|
1304
1420
|
}
|
|
1305
1421
|
attempts += 1;
|
|
1306
1422
|
if (!settled.settled) sink.note("settle", `${renderTitle(record)} finished before the screen went quiet`);
|
|
@@ -1386,6 +1502,38 @@ function reportingScrolls(device, sink) {
|
|
|
1386
1502
|
}), () => device.scroll(direction, budgetMs))
|
|
1387
1503
|
};
|
|
1388
1504
|
}
|
|
1505
|
+
/**
|
|
1506
|
+
* A React Native pressable splits the accessible name off the touch handler, so
|
|
1507
|
+
* a locator naming what a user reads can pin a node the driver refuses. The
|
|
1508
|
+
* control covering that node is what the finger would have hit, so the action
|
|
1509
|
+
* goes there and the report names it, rather than asking the author to describe
|
|
1510
|
+
* a node they cannot see.
|
|
1511
|
+
*
|
|
1512
|
+
* One hop only. A second refusal means the retarget was the wrong reading of
|
|
1513
|
+
* the screen, and repeating it would only walk further from what was asked for.
|
|
1514
|
+
*/
|
|
1515
|
+
async function retarget(device, sink, dispatch, locator, screen, matched, budgetMs) {
|
|
1516
|
+
const target = touchTargetFor(screen, matched);
|
|
1517
|
+
if (target === null) throw untappable(locator, screen);
|
|
1518
|
+
return await sink.step(renderTitle({
|
|
1519
|
+
kind: "retarget",
|
|
1520
|
+
target: describeNode(target)
|
|
1521
|
+
}), async () => {
|
|
1522
|
+
try {
|
|
1523
|
+
return await dispatch(device, pin(screen, target), budgetMs);
|
|
1524
|
+
} catch (error) {
|
|
1525
|
+
if (failureOf(error)?.kind !== "covered") throw error;
|
|
1526
|
+
throw untappable(locator, screen);
|
|
1527
|
+
}
|
|
1528
|
+
}, { box: true });
|
|
1529
|
+
}
|
|
1530
|
+
function untappable(locator, screen) {
|
|
1531
|
+
return new TouchpressError({
|
|
1532
|
+
kind: "untappable",
|
|
1533
|
+
locator,
|
|
1534
|
+
screen: renderScreen(screen)
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1389
1537
|
function ambiguous(locator, nodes, screen) {
|
|
1390
1538
|
return new TouchpressError({
|
|
1391
1539
|
kind: "strict-mode",
|
|
@@ -1443,6 +1591,16 @@ function expectedOf(confirmation) {
|
|
|
1443
1591
|
default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
|
|
1444
1592
|
}
|
|
1445
1593
|
}
|
|
1594
|
+
/** A blind type has no node to ask about secrecy, so only the caller can say. */
|
|
1595
|
+
function typedText(text, options) {
|
|
1596
|
+
return options?.secret === true ? {
|
|
1597
|
+
kind: "hidden",
|
|
1598
|
+
length: text.length
|
|
1599
|
+
} : {
|
|
1600
|
+
kind: "text",
|
|
1601
|
+
value: text
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1446
1604
|
/** What the report says was typed, verbatim, which the normalized confirmation no longer holds. */
|
|
1447
1605
|
function typedOf(role, write) {
|
|
1448
1606
|
if (role === "secure-text-field" || write.secret) return {
|
|
@@ -1522,18 +1680,23 @@ function messageOf(error) {
|
|
|
1522
1680
|
function createClient() {
|
|
1523
1681
|
return createAgentDeviceClient({ responseLevel: "full" });
|
|
1524
1682
|
}
|
|
1683
|
+
const execFileAsync = promisify(execFile);
|
|
1684
|
+
const spawnCommand = async (file, args) => {
|
|
1685
|
+
await execFileAsync(file, [...args]);
|
|
1686
|
+
};
|
|
1525
1687
|
/**
|
|
1526
1688
|
* The only file that imports `agent-device`. Two jobs: translate domain requests
|
|
1527
1689
|
* into client calls carrying the session and device selection, and translate the
|
|
1528
1690
|
* driver's error codes into `DeviceFailure`. Snapshot parsing is not one of
|
|
1529
1691
|
* them, because role normalization and label rules live in `core/screen.ts`.
|
|
1530
1692
|
*/
|
|
1531
|
-
function createAgentDeviceDriver(client, session, selection) {
|
|
1693
|
+
function createAgentDeviceDriver(client, session, selection, runCommand = spawnCommand) {
|
|
1532
1694
|
const where = {
|
|
1533
1695
|
session,
|
|
1534
1696
|
platform: selection.platform,
|
|
1535
1697
|
...selection.name === null ? {} : { device: selection.name }
|
|
1536
1698
|
};
|
|
1699
|
+
let udid = null;
|
|
1537
1700
|
async function run(command, body) {
|
|
1538
1701
|
try {
|
|
1539
1702
|
return await body();
|
|
@@ -1560,12 +1723,14 @@ function createAgentDeviceDriver(client, session, selection) {
|
|
|
1560
1723
|
relaunch: request.relaunch,
|
|
1561
1724
|
...request.url === null ? {} : { url: request.url }
|
|
1562
1725
|
});
|
|
1726
|
+
udid = result.identifiers.udid ?? result.identifiers.deviceId ?? null;
|
|
1563
1727
|
return {
|
|
1564
1728
|
session: result.session,
|
|
1565
1729
|
platform: selection.platform,
|
|
1566
1730
|
deviceLabel: result.device?.name ?? result.identifiers.deviceName ?? selection.name ?? selection.platform,
|
|
1567
1731
|
appId: result.appBundleId ?? result.appId ?? request.app,
|
|
1568
|
-
stateDir: result.sessionStateDir ?? null
|
|
1732
|
+
stateDir: result.sessionStateDir ?? null,
|
|
1733
|
+
udid
|
|
1569
1734
|
};
|
|
1570
1735
|
}),
|
|
1571
1736
|
capture: (options) => run("snapshot", () => client.capture.snapshot({
|
|
@@ -1595,6 +1760,56 @@ function createAgentDeviceDriver(client, session, selection) {
|
|
|
1595
1760
|
text,
|
|
1596
1761
|
...settle(options)
|
|
1597
1762
|
}))),
|
|
1763
|
+
back: (mode, options) => run("back", async () => toSettled(await client.command.back({
|
|
1764
|
+
...where,
|
|
1765
|
+
mode,
|
|
1766
|
+
...settle(options)
|
|
1767
|
+
}))),
|
|
1768
|
+
/**
|
|
1769
|
+
* Scrubbed rather than run through `run`, because agent-device echoes the
|
|
1770
|
+
* text it rejected and a password has no business in a report.
|
|
1771
|
+
*/
|
|
1772
|
+
type: async (text, _options) => {
|
|
1773
|
+
if (looksLikeRef(text)) throw new TouchpressError({ kind: "type-rejected" });
|
|
1774
|
+
try {
|
|
1775
|
+
await client.interactions.type({
|
|
1776
|
+
...where,
|
|
1777
|
+
text
|
|
1778
|
+
});
|
|
1779
|
+
return {
|
|
1780
|
+
settled: false,
|
|
1781
|
+
waitedMs: 0
|
|
1782
|
+
};
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
throw new TouchpressError({
|
|
1785
|
+
kind: "driver",
|
|
1786
|
+
command: "type",
|
|
1787
|
+
failure: withoutText(classifyError(error), text)
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
},
|
|
1791
|
+
clearAppState: (app) => run("clearAppState", async () => {
|
|
1792
|
+
await client.settings.update({
|
|
1793
|
+
...where,
|
|
1794
|
+
setting: "clear-app-state",
|
|
1795
|
+
state: "clear",
|
|
1796
|
+
app
|
|
1797
|
+
});
|
|
1798
|
+
}),
|
|
1799
|
+
/**
|
|
1800
|
+
* agent-device has no keychain command, and the simulator keychain is where
|
|
1801
|
+
* clerk-ios and expo-secure-store keep a session. Lives in the driver
|
|
1802
|
+
* because it needs the udid and a process, neither of which the core has.
|
|
1803
|
+
*/
|
|
1804
|
+
resetKeychain: () => run("resetKeychain", async () => {
|
|
1805
|
+
if (selection.platform !== "ios") return;
|
|
1806
|
+
await runCommand("xcrun", [
|
|
1807
|
+
"simctl",
|
|
1808
|
+
"keychain",
|
|
1809
|
+
udid ?? "booted",
|
|
1810
|
+
"reset"
|
|
1811
|
+
]);
|
|
1812
|
+
}),
|
|
1598
1813
|
scroll: (direction, options) => run("scroll", async () => {
|
|
1599
1814
|
await client.interactions.scroll({
|
|
1600
1815
|
...where,
|
|
@@ -1635,6 +1850,25 @@ function readNormalized(error) {
|
|
|
1635
1850
|
return normalizeAgentDeviceError(error);
|
|
1636
1851
|
}
|
|
1637
1852
|
/**
|
|
1853
|
+
* agent-device 0.20.10's own check, copied rather than approximated so the two
|
|
1854
|
+
* agree on what it will refuse. `@e12` and `@ref-x` are refs, while `@word` and
|
|
1855
|
+
* `@ home` are text.
|
|
1856
|
+
*/
|
|
1857
|
+
function looksLikeRef(text) {
|
|
1858
|
+
const word = text.trim().split(/\s+/, 1)[0];
|
|
1859
|
+
if (word === void 0 || !word.startsWith("@") || word.length < 3) return false;
|
|
1860
|
+
const rest = word.slice(1);
|
|
1861
|
+
return /^[A-Za-z_-]*\d[\w-]*$/i.test(rest) || /^(?:ref|node|element|el)[\w-]*$/i.test(rest);
|
|
1862
|
+
}
|
|
1863
|
+
/** Every failure kind carries a `detail`, and the driver puts what it was given into it. */
|
|
1864
|
+
function withoutText(failure, text) {
|
|
1865
|
+
if (text === "") return failure;
|
|
1866
|
+
return {
|
|
1867
|
+
...failure,
|
|
1868
|
+
detail: failure.detail.split(text).join("<typed text>")
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1638
1872
|
* Timeouts and transport faults both arrive as `COMMAND_FAILED`, so the message
|
|
1639
1873
|
* and `details.reason` separate them. That matches against upstream text, and it
|
|
1640
1874
|
* is confined to this function for exactly that reason.
|
|
@@ -1681,6 +1915,10 @@ function classifyError(error) {
|
|
|
1681
1915
|
kind: "stale-ref",
|
|
1682
1916
|
detail: message
|
|
1683
1917
|
};
|
|
1918
|
+
if (details["reason"] === "covered_by_interactive_descendants") return {
|
|
1919
|
+
kind: "covered",
|
|
1920
|
+
detail: message
|
|
1921
|
+
};
|
|
1684
1922
|
if (/timed out|timeout/i.test(message)) return {
|
|
1685
1923
|
kind: "timeout",
|
|
1686
1924
|
detail: message
|