lynkeus-protocol 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/README.md +4 -0
- package/lib/index.d.ts +352 -0
- package/lib/index.js +2 -0
- package/package.json +35 -0
- package/src/__tests__/shape.test.ts +52 -0
- package/src/index.ts +191 -0
package/README.md
ADDED
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/** JSON-RPC-shaped messages over a WebSocket the app opens towards the driver; docs/PROTOCOL.md has the prose. */
|
|
2
|
+
export declare const PROTOCOL_VERSION = 1;
|
|
3
|
+
export type Frame = {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
w: number;
|
|
7
|
+
h: number;
|
|
8
|
+
};
|
|
9
|
+
export type ElementKind = 'button' | 'input' | 'text' | 'scroll' | 'switch' | 'image' | 'view';
|
|
10
|
+
/** Why an element is in the tree but not presented: structural, so a device and a host without layout agree. */
|
|
11
|
+
export type HiddenReason =
|
|
12
|
+
/** Hidden from assistive technology, so it is not presented to anyone. */
|
|
13
|
+
'a11y'
|
|
14
|
+
/** `pointerEvents: 'none'` on it or an ancestor: no touch can reach it. */
|
|
15
|
+
| 'inert'
|
|
16
|
+
/** Outside the subtree that declared itself modal. */
|
|
17
|
+
| 'behind-modal'
|
|
18
|
+
/** Outside the last full-window layer the app mounted at its root. */
|
|
19
|
+
| 'behind-overlay';
|
|
20
|
+
export type Element = {
|
|
21
|
+
/** Position in tree order: later elements draw on top of earlier ones. */
|
|
22
|
+
i: number;
|
|
23
|
+
kind: ElementKind;
|
|
24
|
+
frame: Frame;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
depth: number;
|
|
27
|
+
testId?: string;
|
|
28
|
+
/** Rendered text for `text` elements, or the label of a button/input. */
|
|
29
|
+
text?: string;
|
|
30
|
+
accessibilityLabel?: string;
|
|
31
|
+
/** Masked (`•`) when the field is a secure entry. */
|
|
32
|
+
value?: string;
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
focused?: boolean;
|
|
35
|
+
/** Index of the nearest listed ancestor, when there is one. */
|
|
36
|
+
parent?: number;
|
|
37
|
+
/** React native tag of the host view. */
|
|
38
|
+
tag?: number;
|
|
39
|
+
/** A touch at its centre would not reach it: something is on top. */
|
|
40
|
+
covered?: boolean;
|
|
41
|
+
/** Not presented at all, and why. A hidden element is not on screen. */
|
|
42
|
+
hidden?: HiddenReason;
|
|
43
|
+
/** Its rect lies outside the window: on screen only after scrolling. */
|
|
44
|
+
offscreen?: boolean;
|
|
45
|
+
};
|
|
46
|
+
export type Screen = {
|
|
47
|
+
route?: string;
|
|
48
|
+
params?: unknown;
|
|
49
|
+
/** Route names from the root navigator down to the focused screen. */
|
|
50
|
+
path: string[];
|
|
51
|
+
elements: Element[];
|
|
52
|
+
/** React commits since the agent started; a change means the UI re-rendered. */
|
|
53
|
+
commit: number;
|
|
54
|
+
/** Requests in flight, plus whatever the app's `busy` command reports. */
|
|
55
|
+
busy: boolean;
|
|
56
|
+
window: {
|
|
57
|
+
w: number;
|
|
58
|
+
h: number;
|
|
59
|
+
};
|
|
60
|
+
/** The layer on top, when the app is presenting one over its screen. */
|
|
61
|
+
presenting?: {
|
|
62
|
+
i: number;
|
|
63
|
+
modal: boolean;
|
|
64
|
+
testId?: string;
|
|
65
|
+
};
|
|
66
|
+
/** Milliseconds spent building this snapshot inside the app. */
|
|
67
|
+
costMs: number;
|
|
68
|
+
};
|
|
69
|
+
/** Where to look for a target: the element this names, then each container up from it, nearest first. */
|
|
70
|
+
export type Scope = {
|
|
71
|
+
testId: string;
|
|
72
|
+
} | {
|
|
73
|
+
text: string;
|
|
74
|
+
};
|
|
75
|
+
export type Target = {
|
|
76
|
+
testId: string;
|
|
77
|
+
nth?: number;
|
|
78
|
+
within?: Scope;
|
|
79
|
+
} | {
|
|
80
|
+
text: string;
|
|
81
|
+
nth?: number;
|
|
82
|
+
within?: Scope;
|
|
83
|
+
} | {
|
|
84
|
+
i: number;
|
|
85
|
+
} | {
|
|
86
|
+
x: number;
|
|
87
|
+
y: number;
|
|
88
|
+
};
|
|
89
|
+
export type PressOptions = {
|
|
90
|
+
/** Finger-down time; a long press is ~600. Default 50. */
|
|
91
|
+
holdMs?: number;
|
|
92
|
+
/** `js` calls the element's onPress directly: no touch, no gesture, no animation wait. Needs a testId and an enabled, uncovered control. */
|
|
93
|
+
mode?: 'native' | 'js';
|
|
94
|
+
};
|
|
95
|
+
export type SwipeParams = {
|
|
96
|
+
direction?: 'up' | 'down' | 'left' | 'right';
|
|
97
|
+
/** Swipe on an element (a slider, a sheet handle) from its centre rather than across the window. */
|
|
98
|
+
target?: Target;
|
|
99
|
+
/** How far to move, in points; default 60% of the window's extent that way. */
|
|
100
|
+
distance?: number;
|
|
101
|
+
from?: {
|
|
102
|
+
x: number;
|
|
103
|
+
y: number;
|
|
104
|
+
};
|
|
105
|
+
to?: {
|
|
106
|
+
x: number;
|
|
107
|
+
y: number;
|
|
108
|
+
};
|
|
109
|
+
durationMs?: number;
|
|
110
|
+
};
|
|
111
|
+
export type WaitForParams = {
|
|
112
|
+
route?: string;
|
|
113
|
+
target?: Target;
|
|
114
|
+
/** Satisfied by whichever of these shows up first. */
|
|
115
|
+
anyOf?: Target[];
|
|
116
|
+
gone?: boolean;
|
|
117
|
+
/** Swipe the nearest scrolling container towards the target until it is in the window. */
|
|
118
|
+
scroll?: boolean;
|
|
119
|
+
timeoutMs?: number;
|
|
120
|
+
};
|
|
121
|
+
export type TraceEvent = {
|
|
122
|
+
seq: number;
|
|
123
|
+
t: number;
|
|
124
|
+
} & ({
|
|
125
|
+
kind: 'route';
|
|
126
|
+
route?: string;
|
|
127
|
+
path: string[];
|
|
128
|
+
} | {
|
|
129
|
+
kind: 'request';
|
|
130
|
+
method: string;
|
|
131
|
+
url: string;
|
|
132
|
+
status?: number;
|
|
133
|
+
ms?: number;
|
|
134
|
+
error?: string;
|
|
135
|
+
} | {
|
|
136
|
+
kind: 'store';
|
|
137
|
+
store: string;
|
|
138
|
+
changed: Record<string, {
|
|
139
|
+
from: unknown;
|
|
140
|
+
to: unknown;
|
|
141
|
+
}>;
|
|
142
|
+
}
|
|
143
|
+
/** Something the app told its analytics, through `qa.event`. */
|
|
144
|
+
| {
|
|
145
|
+
kind: 'event';
|
|
146
|
+
name: string;
|
|
147
|
+
props?: Record<string, unknown>;
|
|
148
|
+
route?: string;
|
|
149
|
+
} | {
|
|
150
|
+
kind: 'command';
|
|
151
|
+
method: string;
|
|
152
|
+
ms: number;
|
|
153
|
+
error?: string;
|
|
154
|
+
}
|
|
155
|
+
/** The host broke a runaway render loop: `renders` updates in `windowMs`. */
|
|
156
|
+
| {
|
|
157
|
+
kind: 'stall';
|
|
158
|
+
renders: number;
|
|
159
|
+
windowMs: number;
|
|
160
|
+
component?: string;
|
|
161
|
+
}
|
|
162
|
+
/** A finger on the screen (device builds mounted with `auto()`), resolved to the element under it. */
|
|
163
|
+
| {
|
|
164
|
+
kind: 'touch';
|
|
165
|
+
x: number;
|
|
166
|
+
y: number;
|
|
167
|
+
testId?: string;
|
|
168
|
+
text?: string;
|
|
169
|
+
route?: string;
|
|
170
|
+
}
|
|
171
|
+
/** Something threw where nothing caught it, and the screen may be gone. */
|
|
172
|
+
| {
|
|
173
|
+
kind: 'crash';
|
|
174
|
+
message: string;
|
|
175
|
+
stack?: string;
|
|
176
|
+
route?: string;
|
|
177
|
+
});
|
|
178
|
+
export type RequestRecord = {
|
|
179
|
+
id: number;
|
|
180
|
+
method: string;
|
|
181
|
+
url: string;
|
|
182
|
+
status?: number;
|
|
183
|
+
startedAt: number;
|
|
184
|
+
ms?: number;
|
|
185
|
+
error?: string;
|
|
186
|
+
};
|
|
187
|
+
/** Every method the core answers. Apps add their own through `qa.register`. */
|
|
188
|
+
export type CoreMethods = {
|
|
189
|
+
ping: {
|
|
190
|
+
params: undefined;
|
|
191
|
+
result: {
|
|
192
|
+
pong: true;
|
|
193
|
+
native: boolean;
|
|
194
|
+
commands: string[];
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
screen: {
|
|
198
|
+
params: undefined;
|
|
199
|
+
result: Screen;
|
|
200
|
+
};
|
|
201
|
+
find: {
|
|
202
|
+
params: Target;
|
|
203
|
+
result: Element | null;
|
|
204
|
+
};
|
|
205
|
+
press: {
|
|
206
|
+
params: Target & PressOptions;
|
|
207
|
+
result: {
|
|
208
|
+
x: number;
|
|
209
|
+
y: number;
|
|
210
|
+
element?: Element;
|
|
211
|
+
mode: 'native' | 'js';
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
type: {
|
|
215
|
+
params: {
|
|
216
|
+
text: string;
|
|
217
|
+
target?: Target;
|
|
218
|
+
clear?: boolean;
|
|
219
|
+
paste?: boolean;
|
|
220
|
+
submit?: boolean;
|
|
221
|
+
};
|
|
222
|
+
result: {
|
|
223
|
+
typed: string;
|
|
224
|
+
element?: Element;
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
swipe: {
|
|
228
|
+
params: SwipeParams;
|
|
229
|
+
result: {
|
|
230
|
+
from: {
|
|
231
|
+
x: number;
|
|
232
|
+
y: number;
|
|
233
|
+
};
|
|
234
|
+
to: {
|
|
235
|
+
x: number;
|
|
236
|
+
y: number;
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
navigate: {
|
|
241
|
+
params: {
|
|
242
|
+
name: string;
|
|
243
|
+
params?: object;
|
|
244
|
+
};
|
|
245
|
+
result: {
|
|
246
|
+
route: string;
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
back: {
|
|
250
|
+
params: undefined;
|
|
251
|
+
result: {
|
|
252
|
+
route?: string;
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
waitFor: {
|
|
256
|
+
params: WaitForParams;
|
|
257
|
+
result: {
|
|
258
|
+
route?: string;
|
|
259
|
+
matched?: Target;
|
|
260
|
+
waitedMs: number;
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
idle: {
|
|
264
|
+
/** `after`: a commit count from before the awaited change; quiet only counts once the app has committed past it. */
|
|
265
|
+
params: {
|
|
266
|
+
quietMs?: number;
|
|
267
|
+
timeoutMs?: number;
|
|
268
|
+
after?: number;
|
|
269
|
+
} | undefined;
|
|
270
|
+
result: {
|
|
271
|
+
commit: number;
|
|
272
|
+
idle: boolean;
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
deepLink: {
|
|
276
|
+
params: {
|
|
277
|
+
url: string;
|
|
278
|
+
};
|
|
279
|
+
result: {
|
|
280
|
+
opened: string;
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
requests: {
|
|
284
|
+
params: {
|
|
285
|
+
since?: number;
|
|
286
|
+
} | undefined;
|
|
287
|
+
result: {
|
|
288
|
+
inFlight: number;
|
|
289
|
+
requests: RequestRecord[];
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
trace: {
|
|
293
|
+
params: {
|
|
294
|
+
since?: number;
|
|
295
|
+
} | undefined;
|
|
296
|
+
result: {
|
|
297
|
+
last: number;
|
|
298
|
+
events: TraceEvent[];
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
dismissKeyboard: {
|
|
302
|
+
params: undefined;
|
|
303
|
+
result: boolean;
|
|
304
|
+
};
|
|
305
|
+
/** Several calls in one round trip, in order; stops at the first error. */
|
|
306
|
+
batch: {
|
|
307
|
+
params: {
|
|
308
|
+
calls: {
|
|
309
|
+
method: string;
|
|
310
|
+
params?: unknown;
|
|
311
|
+
}[];
|
|
312
|
+
};
|
|
313
|
+
result: {
|
|
314
|
+
results: unknown[];
|
|
315
|
+
};
|
|
316
|
+
};
|
|
317
|
+
};
|
|
318
|
+
export type Request = {
|
|
319
|
+
id: number;
|
|
320
|
+
method: string;
|
|
321
|
+
params?: unknown;
|
|
322
|
+
};
|
|
323
|
+
export type Response = {
|
|
324
|
+
id: number;
|
|
325
|
+
result: unknown;
|
|
326
|
+
} | {
|
|
327
|
+
id: number;
|
|
328
|
+
error: {
|
|
329
|
+
message: string;
|
|
330
|
+
code?: string;
|
|
331
|
+
};
|
|
332
|
+
};
|
|
333
|
+
export type HelloEvent = {
|
|
334
|
+
event: 'hello';
|
|
335
|
+
protocolVersion: number;
|
|
336
|
+
platform: 'ios' | 'android' | string;
|
|
337
|
+
native: boolean;
|
|
338
|
+
commands: string[];
|
|
339
|
+
route?: string;
|
|
340
|
+
app?: Record<string, unknown>;
|
|
341
|
+
};
|
|
342
|
+
export type NavigationEvent = {
|
|
343
|
+
event: 'navigation';
|
|
344
|
+
route?: string;
|
|
345
|
+
};
|
|
346
|
+
/** The driver's first message. `session` is random per driver process, so the app can tell a reconnect (whose resent requests it may have answered) from a new driver reusing request ids. */
|
|
347
|
+
export type AuthEvent = {
|
|
348
|
+
event: 'auth';
|
|
349
|
+
session: string;
|
|
350
|
+
token?: string;
|
|
351
|
+
};
|
|
352
|
+
export type AgentEvent = HelloEvent | NavigationEvent;
|
package/lib/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lynkeus-protocol",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Wire contract between an app running lynkeus-agent and whatever drives it.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
21
|
+
"test": "node --import tsx --test src/__tests__/*.test.ts"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/jonamischener/lynkeus.git",
|
|
26
|
+
"directory": "packages/protocol"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.10.2",
|
|
31
|
+
"tsx": "^4.19.2",
|
|
32
|
+
"typescript": "^6.0.3"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/jonamischener/lynkeus#readme"
|
|
35
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import { PROTOCOL_VERSION } from '../index.js';
|
|
9
|
+
|
|
10
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const source = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
|
|
12
|
+
const frozenFile = path.join(here, '..', '..', 'shape.json');
|
|
13
|
+
const agentProtocol = fs.readFileSync(path.join(here, '..', '..', '..', 'agent', 'src', 'protocol.ts'), 'utf8');
|
|
14
|
+
|
|
15
|
+
const SHAPES = ['ElementKind', 'HiddenReason', 'Frame', 'Element', 'Screen', 'Scope', 'Target'];
|
|
16
|
+
|
|
17
|
+
const declaration = (name: string): string => {
|
|
18
|
+
const start = source.search(new RegExp(`export type ${name}\\b`));
|
|
19
|
+
assert.notEqual(start, -1, `no export type ${name} in the protocol`);
|
|
20
|
+
let depth = 0;
|
|
21
|
+
for (let i = start; i < source.length; i++) {
|
|
22
|
+
const c = source[i];
|
|
23
|
+
if (c === '{' || c === '(') depth++;
|
|
24
|
+
else if (c === '}' || c === ')') depth--;
|
|
25
|
+
else if (c === ';' && depth === 0) return source.slice(start, i + 1);
|
|
26
|
+
}
|
|
27
|
+
return source.slice(start);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const normalized = (text: string) =>
|
|
31
|
+
text
|
|
32
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
33
|
+
.replace(/\/\/.*$/gm, '')
|
|
34
|
+
.replace(/\s+/g, ' ')
|
|
35
|
+
.trim();
|
|
36
|
+
|
|
37
|
+
const hash = createHash('sha1')
|
|
38
|
+
.update(SHAPES.map((n) => normalized(declaration(n))).join('\n'))
|
|
39
|
+
.digest('hex');
|
|
40
|
+
const agentVersion = Number(/PROTOCOL_VERSION = (\d+)/.exec(agentProtocol)?.[1]);
|
|
41
|
+
|
|
42
|
+
test('the protocol shapes change only on purpose', () => {
|
|
43
|
+
if (process.env.UPDATE_PROTOCOL_SHAPE === '1') fs.writeFileSync(frozenFile, `${JSON.stringify({ version: PROTOCOL_VERSION, hash }, null, 2)}\n`);
|
|
44
|
+
const frozen = JSON.parse(fs.readFileSync(frozenFile, 'utf8')) as { version: number; hash: string };
|
|
45
|
+
assert.equal(
|
|
46
|
+
hash,
|
|
47
|
+
frozen.hash,
|
|
48
|
+
`${SHAPES.join(', ')} changed. A change a driver of version ${frozen.version} would misread needs PROTOCOL_VERSION bumped; an optional field it can ignore does not. Either way, record the decision with UPDATE_PROTOCOL_SHAPE=1.`,
|
|
49
|
+
);
|
|
50
|
+
assert.equal(PROTOCOL_VERSION, frozen.version, 'PROTOCOL_VERSION moved without recording the shape it belongs to (UPDATE_PROTOCOL_SHAPE=1)');
|
|
51
|
+
assert.equal(agentVersion, PROTOCOL_VERSION, "lynkeus-agent's own PROTOCOL_VERSION must equal this one");
|
|
52
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/** JSON-RPC-shaped messages over a WebSocket the app opens towards the driver; docs/PROTOCOL.md has the prose. */
|
|
2
|
+
export const PROTOCOL_VERSION = 1;
|
|
3
|
+
|
|
4
|
+
export type Frame = { x: number; y: number; w: number; h: number };
|
|
5
|
+
|
|
6
|
+
export type ElementKind = 'button' | 'input' | 'text' | 'scroll' | 'switch' | 'image' | 'view';
|
|
7
|
+
|
|
8
|
+
/** Why an element is in the tree but not presented: structural, so a device and a host without layout agree. */
|
|
9
|
+
export type HiddenReason =
|
|
10
|
+
/** Hidden from assistive technology, so it is not presented to anyone. */
|
|
11
|
+
| 'a11y'
|
|
12
|
+
/** `pointerEvents: 'none'` on it or an ancestor: no touch can reach it. */
|
|
13
|
+
| 'inert'
|
|
14
|
+
/** Outside the subtree that declared itself modal. */
|
|
15
|
+
| 'behind-modal'
|
|
16
|
+
/** Outside the last full-window layer the app mounted at its root. */
|
|
17
|
+
| 'behind-overlay';
|
|
18
|
+
|
|
19
|
+
export type Element = {
|
|
20
|
+
/** Position in tree order: later elements draw on top of earlier ones. */
|
|
21
|
+
i: number;
|
|
22
|
+
kind: ElementKind;
|
|
23
|
+
frame: Frame;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
depth: number;
|
|
26
|
+
testId?: string;
|
|
27
|
+
/** Rendered text for `text` elements, or the label of a button/input. */
|
|
28
|
+
text?: string;
|
|
29
|
+
accessibilityLabel?: string;
|
|
30
|
+
/** Masked (`•`) when the field is a secure entry. */
|
|
31
|
+
value?: string;
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
focused?: boolean;
|
|
34
|
+
/** Index of the nearest listed ancestor, when there is one. */
|
|
35
|
+
parent?: number;
|
|
36
|
+
/** React native tag of the host view. */
|
|
37
|
+
tag?: number;
|
|
38
|
+
/** A touch at its centre would not reach it: something is on top. */
|
|
39
|
+
covered?: boolean;
|
|
40
|
+
/** Not presented at all, and why. A hidden element is not on screen. */
|
|
41
|
+
hidden?: HiddenReason;
|
|
42
|
+
/** Its rect lies outside the window: on screen only after scrolling. */
|
|
43
|
+
offscreen?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type Screen = {
|
|
47
|
+
route?: string;
|
|
48
|
+
params?: unknown;
|
|
49
|
+
/** Route names from the root navigator down to the focused screen. */
|
|
50
|
+
path: string[];
|
|
51
|
+
elements: Element[];
|
|
52
|
+
/** React commits since the agent started; a change means the UI re-rendered. */
|
|
53
|
+
commit: number;
|
|
54
|
+
/** Requests in flight, plus whatever the app's `busy` command reports. */
|
|
55
|
+
busy: boolean;
|
|
56
|
+
window: { w: number; h: number };
|
|
57
|
+
/** The layer on top, when the app is presenting one over its screen. */
|
|
58
|
+
presenting?: { i: number; modal: boolean; testId?: string };
|
|
59
|
+
/** Milliseconds spent building this snapshot inside the app. */
|
|
60
|
+
costMs: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Where to look for a target: the element this names, then each container up from it, nearest first. */
|
|
64
|
+
export type Scope = { testId: string } | { text: string };
|
|
65
|
+
export type Target =
|
|
66
|
+
| { testId: string; nth?: number; within?: Scope }
|
|
67
|
+
| { text: string; nth?: number; within?: Scope }
|
|
68
|
+
| { i: number }
|
|
69
|
+
| { x: number; y: number };
|
|
70
|
+
|
|
71
|
+
export type PressOptions = {
|
|
72
|
+
/** Finger-down time; a long press is ~600. Default 50. */
|
|
73
|
+
holdMs?: number;
|
|
74
|
+
/** `js` calls the element's onPress directly: no touch, no gesture, no animation wait. Needs a testId and an enabled, uncovered control. */
|
|
75
|
+
mode?: 'native' | 'js';
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type SwipeParams = {
|
|
79
|
+
direction?: 'up' | 'down' | 'left' | 'right';
|
|
80
|
+
/** Swipe on an element (a slider, a sheet handle) from its centre rather than across the window. */
|
|
81
|
+
target?: Target;
|
|
82
|
+
/** How far to move, in points; default 60% of the window's extent that way. */
|
|
83
|
+
distance?: number;
|
|
84
|
+
from?: { x: number; y: number };
|
|
85
|
+
to?: { x: number; y: number };
|
|
86
|
+
durationMs?: number;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type WaitForParams = {
|
|
90
|
+
route?: string;
|
|
91
|
+
target?: Target;
|
|
92
|
+
/** Satisfied by whichever of these shows up first. */
|
|
93
|
+
anyOf?: Target[];
|
|
94
|
+
gone?: boolean;
|
|
95
|
+
/** Swipe the nearest scrolling container towards the target until it is in the window. */
|
|
96
|
+
scroll?: boolean;
|
|
97
|
+
timeoutMs?: number;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type TraceEvent = { seq: number; t: number } & (
|
|
101
|
+
| { kind: 'route'; route?: string; path: string[] }
|
|
102
|
+
| { kind: 'request'; method: string; url: string; status?: number; ms?: number; error?: string }
|
|
103
|
+
| { kind: 'store'; store: string; changed: Record<string, { from: unknown; to: unknown }> }
|
|
104
|
+
/** Something the app told its analytics, through `qa.event`. */
|
|
105
|
+
| { kind: 'event'; name: string; props?: Record<string, unknown>; route?: string }
|
|
106
|
+
| { kind: 'command'; method: string; ms: number; error?: string }
|
|
107
|
+
/** The host broke a runaway render loop: `renders` updates in `windowMs`. */
|
|
108
|
+
| { kind: 'stall'; renders: number; windowMs: number; component?: string }
|
|
109
|
+
/** A finger on the screen (device builds mounted with `auto()`), resolved to the element under it. */
|
|
110
|
+
| { kind: 'touch'; x: number; y: number; testId?: string; text?: string; route?: string }
|
|
111
|
+
/** Something threw where nothing caught it, and the screen may be gone. */
|
|
112
|
+
| { kind: 'crash'; message: string; stack?: string; route?: string }
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
export type RequestRecord = {
|
|
116
|
+
id: number;
|
|
117
|
+
method: string;
|
|
118
|
+
url: string;
|
|
119
|
+
status?: number;
|
|
120
|
+
startedAt: number;
|
|
121
|
+
ms?: number;
|
|
122
|
+
error?: string;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Every method the core answers. Apps add their own through `qa.register`. */
|
|
126
|
+
export type CoreMethods = {
|
|
127
|
+
ping: {
|
|
128
|
+
params: undefined;
|
|
129
|
+
result: { pong: true; native: boolean; commands: string[] };
|
|
130
|
+
};
|
|
131
|
+
screen: { params: undefined; result: Screen };
|
|
132
|
+
find: { params: Target; result: Element | null };
|
|
133
|
+
press: {
|
|
134
|
+
params: Target & PressOptions;
|
|
135
|
+
result: { x: number; y: number; element?: Element; mode: 'native' | 'js' };
|
|
136
|
+
};
|
|
137
|
+
type: {
|
|
138
|
+
params: { text: string; target?: Target; clear?: boolean; paste?: boolean; submit?: boolean };
|
|
139
|
+
result: { typed: string; element?: Element };
|
|
140
|
+
};
|
|
141
|
+
swipe: {
|
|
142
|
+
params: SwipeParams;
|
|
143
|
+
result: { from: { x: number; y: number }; to: { x: number; y: number } };
|
|
144
|
+
};
|
|
145
|
+
navigate: {
|
|
146
|
+
params: { name: string; params?: object };
|
|
147
|
+
result: { route: string };
|
|
148
|
+
};
|
|
149
|
+
back: { params: undefined; result: { route?: string } };
|
|
150
|
+
waitFor: {
|
|
151
|
+
params: WaitForParams;
|
|
152
|
+
result: { route?: string; matched?: Target; waitedMs: number };
|
|
153
|
+
};
|
|
154
|
+
idle: {
|
|
155
|
+
/** `after`: a commit count from before the awaited change; quiet only counts once the app has committed past it. */
|
|
156
|
+
params: { quietMs?: number; timeoutMs?: number; after?: number } | undefined;
|
|
157
|
+
result: { commit: number; idle: boolean };
|
|
158
|
+
};
|
|
159
|
+
deepLink: { params: { url: string }; result: { opened: string } };
|
|
160
|
+
requests: {
|
|
161
|
+
params: { since?: number } | undefined;
|
|
162
|
+
result: { inFlight: number; requests: RequestRecord[] };
|
|
163
|
+
};
|
|
164
|
+
trace: {
|
|
165
|
+
params: { since?: number } | undefined;
|
|
166
|
+
result: { last: number; events: TraceEvent[] };
|
|
167
|
+
};
|
|
168
|
+
dismissKeyboard: { params: undefined; result: boolean };
|
|
169
|
+
/** Several calls in one round trip, in order; stops at the first error. */
|
|
170
|
+
batch: {
|
|
171
|
+
params: { calls: { method: string; params?: unknown }[] };
|
|
172
|
+
result: { results: unknown[] };
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export type Request = { id: number; method: string; params?: unknown };
|
|
177
|
+
export type Response = { id: number; result: unknown } | { id: number; error: { message: string; code?: string } };
|
|
178
|
+
|
|
179
|
+
export type HelloEvent = {
|
|
180
|
+
event: 'hello';
|
|
181
|
+
protocolVersion: number;
|
|
182
|
+
platform: 'ios' | 'android' | string;
|
|
183
|
+
native: boolean;
|
|
184
|
+
commands: string[];
|
|
185
|
+
route?: string;
|
|
186
|
+
app?: Record<string, unknown>;
|
|
187
|
+
};
|
|
188
|
+
export type NavigationEvent = { event: 'navigation'; route?: string };
|
|
189
|
+
/** The driver's first message. `session` is random per driver process, so the app can tell a reconnect (whose resent requests it may have answered) from a new driver reusing request ids. */
|
|
190
|
+
export type AuthEvent = { event: 'auth'; session: string; token?: string };
|
|
191
|
+
export type AgentEvent = HelloEvent | NavigationEvent;
|