@nativedesktop/test 0.4.1 → 0.4.3
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/package.json +3 -3
- package/src/attach.ts +157 -0
- package/src/errors.ts +121 -0
- package/src/expect.ts +274 -0
- package/src/fixtures/gestures-tree.json +291 -0
- package/src/index.ts +40 -2
- package/src/keyboard.ts +143 -0
- package/src/launch.ts +95 -1
- package/src/locator.test.ts +306 -0
- package/src/locator.ts +539 -0
- package/src/matcher.test.ts +83 -0
- package/src/matcher.ts +179 -0
- package/src/selectors.test.ts +93 -0
- package/src/selectors.ts +229 -0
- package/src/snapshot.ts +72 -0
- package/src/socket.ts +7 -1
package/src/locator.ts
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// Playwright-shaped locators over the automation socket.
|
|
2
|
+
//
|
|
3
|
+
// A Locator is lazy: it holds a client, an optional window scope, and a list
|
|
4
|
+
// of selector parts. Nothing is resolved until an action or a reader runs,
|
|
5
|
+
// and every one of those re-resolves from a fresh getTree, so a locator kept
|
|
6
|
+
// across a re-render still points at the right widget.
|
|
7
|
+
//
|
|
8
|
+
// Actionability before a single-target action, in order: resolve (poll every
|
|
9
|
+
// 100ms until the deadline), refuse more than one match outright, require the
|
|
10
|
+
// one match to be visible, enabled and to occupy real pixels, then require
|
|
11
|
+
// its rectangle to be identical on the next read so a mid-animation widget is
|
|
12
|
+
// never clicked. A dispatch that comes back -32001 (the ref went stale
|
|
13
|
+
// between resolve and dispatch) goes around the loop again.
|
|
14
|
+
import { AutomationRpcError } from "./socket.ts";
|
|
15
|
+
import { RPC_ERRORS, type GetTreeResult, type ScreenshotResult } from "@nativedesktop/react/rpc";
|
|
16
|
+
import {
|
|
17
|
+
allNodes,
|
|
18
|
+
asNdNode,
|
|
19
|
+
hasRealSize,
|
|
20
|
+
nodeChecked,
|
|
21
|
+
nodeName,
|
|
22
|
+
nodePlaceholder,
|
|
23
|
+
nodeText,
|
|
24
|
+
renderValue,
|
|
25
|
+
selectNodes,
|
|
26
|
+
type NdNode,
|
|
27
|
+
} from "./matcher.ts";
|
|
28
|
+
import { formatSelector, intendedName, parseSelector, type SelectorPart, type TextSpec } from "./selectors.ts";
|
|
29
|
+
import { LocatorError, rankCandidates, StrictModeError, TimeoutError } from "./errors.ts";
|
|
30
|
+
import { Keyboard, Mouse, toChord } from "./keyboard.ts";
|
|
31
|
+
|
|
32
|
+
const POLL_MS = 100;
|
|
33
|
+
|
|
34
|
+
/** What a Locator needs from a host connection. AppHandle implements it; a
|
|
35
|
+
* unit test can implement it over a canned tree. */
|
|
36
|
+
export interface LocatorClient {
|
|
37
|
+
tree(window?: number): Promise<GetTreeResult>;
|
|
38
|
+
callRpc(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
39
|
+
/** Default deadline for actions, readers and expect matchers. */
|
|
40
|
+
actionTimeout: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RoleOptions {
|
|
44
|
+
name?: string | RegExp;
|
|
45
|
+
exact?: boolean;
|
|
46
|
+
checked?: boolean;
|
|
47
|
+
disabled?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TextOptions {
|
|
51
|
+
exact?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ActionOptions {
|
|
55
|
+
timeout?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type WaitForState = "attached" | "detached" | "visible" | "hidden";
|
|
59
|
+
|
|
60
|
+
export interface BoundingBox {
|
|
61
|
+
x: number;
|
|
62
|
+
y: number;
|
|
63
|
+
width: number;
|
|
64
|
+
height: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sleep(ms: number): Promise<void> {
|
|
68
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function textSpec(text: string | RegExp, exact?: boolean): TextSpec {
|
|
72
|
+
return text instanceof RegExp ? { regex: text } : { value: text, exact: exact ?? false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isStaleRef(e: unknown): boolean {
|
|
76
|
+
return e instanceof AutomationRpcError && e.code === RPC_ERRORS.notActionable.code;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Calls a host method, turning "method not found" into a message that names
|
|
80
|
+
* the missing RPC instead of a bare -32601. */
|
|
81
|
+
export async function callHost(client: LocatorClient, method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
82
|
+
try {
|
|
83
|
+
return await client.callRpc(method, params);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (e instanceof AutomationRpcError && e.code === RPC_ERRORS.methodNotFound.code) {
|
|
86
|
+
throw new LocatorError(`host predates the "${method}" RPC`);
|
|
87
|
+
}
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function notActionable(node: NdNode): string {
|
|
93
|
+
if (!node.visible) return "not visible";
|
|
94
|
+
if (!node.enabled) return "disabled";
|
|
95
|
+
if (!hasRealSize(node)) {
|
|
96
|
+
const g = node.geometry;
|
|
97
|
+
return g ? `zero size (${g.w}x${g.h})` : "no geometry";
|
|
98
|
+
}
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sameRect(a: NdNode, b: NdNode): boolean {
|
|
103
|
+
const x = a.geometry;
|
|
104
|
+
const y = b.geometry;
|
|
105
|
+
if (!x || !y) return x === y;
|
|
106
|
+
return x.x === y.x && x.y === y.y && x.w === y.w && x.h === y.h;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface ResolveOptions {
|
|
110
|
+
action: string;
|
|
111
|
+
timeout?: number;
|
|
112
|
+
actionable?: boolean;
|
|
113
|
+
stable?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class Locator {
|
|
117
|
+
constructor(
|
|
118
|
+
readonly client: LocatorClient,
|
|
119
|
+
readonly parts: SelectorPart[],
|
|
120
|
+
readonly windowRef?: number,
|
|
121
|
+
) {}
|
|
122
|
+
|
|
123
|
+
get selector(): string {
|
|
124
|
+
return formatSelector(this.parts);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
toString(): string {
|
|
128
|
+
return this.selector;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private derive(extra: SelectorPart[]): Locator {
|
|
132
|
+
return new Locator(this.client, [...this.parts, ...extra], this.windowRef);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- chaining ------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
locator(selector: string | Locator): Locator {
|
|
138
|
+
return this.derive(typeof selector === "string" ? parseSelector(selector) : selector.parts);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getByTestId(testId: string): Locator {
|
|
142
|
+
return this.derive([{ kind: "testid", value: testId }]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
getByRole(role: string, opts: RoleOptions = {}): Locator {
|
|
146
|
+
return this.derive([roleSpec(role, opts)]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
getByText(text: string | RegExp, opts: TextOptions = {}): Locator {
|
|
150
|
+
return this.derive([{ kind: "text", ...textSpec(text, opts.exact) }]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
getByLabel(text: string | RegExp, opts: TextOptions = {}): Locator {
|
|
154
|
+
return this.derive([{ kind: "label", ...textSpec(text, opts.exact) }]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
getByPlaceholder(text: string | RegExp, opts: TextOptions = {}): Locator {
|
|
158
|
+
return this.derive([{ kind: "placeholder", ...textSpec(text, opts.exact) }]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
filter(opts: { hasText?: string | RegExp; has?: Locator; hasNot?: Locator }): Locator {
|
|
162
|
+
const extra: SelectorPart[] = [];
|
|
163
|
+
if (opts.hasText !== undefined) extra.push({ kind: "has-text", ...textSpec(opts.hasText) });
|
|
164
|
+
if (opts.has) extra.push({ kind: "has", parts: opts.has.parts });
|
|
165
|
+
if (opts.hasNot) extra.push({ kind: "has-not", parts: opts.hasNot.parts });
|
|
166
|
+
return this.derive(extra);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
and(other: Locator): Locator {
|
|
170
|
+
return this.derive([{ kind: "and", parts: other.parts }]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
first(): Locator {
|
|
174
|
+
return this.derive([{ kind: "nth", index: 0 }]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
last(): Locator {
|
|
178
|
+
return this.derive([{ kind: "nth", index: -1 }]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
nth(index: number): Locator {
|
|
182
|
+
return this.derive([{ kind: "nth", index }]);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- resolution ----------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/** One tree read, no waiting. The window scope going away reads as an
|
|
188
|
+
* empty match set, the same as a selector that matches nothing. */
|
|
189
|
+
async snapshot(): Promise<{ root: NdNode | null; matches: NdNode[] }> {
|
|
190
|
+
let tree: GetTreeResult;
|
|
191
|
+
try {
|
|
192
|
+
tree = await this.client.tree(this.windowRef);
|
|
193
|
+
} catch (e) {
|
|
194
|
+
if (e instanceof AutomationRpcError && e.code === RPC_ERRORS.windowGone.code) return { root: null, matches: [] };
|
|
195
|
+
throw e;
|
|
196
|
+
}
|
|
197
|
+
const root = asNdNode(tree.root);
|
|
198
|
+
return { root, matches: selectNodes(root, this.parts) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async windowLabel(root: NdNode | null): Promise<string> {
|
|
202
|
+
const ref = this.windowRef ?? root?.ref;
|
|
203
|
+
try {
|
|
204
|
+
const res = (await this.client.callRpc("windows")) as { windows: { ref: number; title: string | null }[] };
|
|
205
|
+
const info = res.windows.find((w) => w.ref === ref) ?? res.windows[0];
|
|
206
|
+
if (info?.title) return JSON.stringify(info.title);
|
|
207
|
+
} catch {
|
|
208
|
+
// The host is gone, or answered nothing useful; the root node still names it.
|
|
209
|
+
}
|
|
210
|
+
return root?.text ? JSON.stringify(root.text) : `#${ref ?? "?"}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private async stableFrame(node: NdNode): Promise<NdNode | null> {
|
|
214
|
+
await sleep(POLL_MS);
|
|
215
|
+
const { matches } = await this.snapshot();
|
|
216
|
+
const again = matches.find((m) => m.ref === node.ref);
|
|
217
|
+
return again && sameRect(node, again) ? again : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async resolveOne(opts: ResolveOptions): Promise<NdNode> {
|
|
221
|
+
const timeout = opts.timeout ?? this.client.actionTimeout;
|
|
222
|
+
const deadline = Date.now() + timeout;
|
|
223
|
+
let root: NdNode | null = null;
|
|
224
|
+
let count = 0;
|
|
225
|
+
let reason = "";
|
|
226
|
+
for (;;) {
|
|
227
|
+
const snap = await this.snapshot();
|
|
228
|
+
root = snap.root;
|
|
229
|
+
count = snap.matches.length;
|
|
230
|
+
if (count > 1) throw new StrictModeError(this.selector, snap.matches, await this.windowLabel(root));
|
|
231
|
+
const node = snap.matches[0];
|
|
232
|
+
if (node) {
|
|
233
|
+
if (!opts.actionable) return node;
|
|
234
|
+
reason = notActionable(node);
|
|
235
|
+
if (!reason) {
|
|
236
|
+
if (!opts.stable) return node;
|
|
237
|
+
const settled = await this.stableFrame(node);
|
|
238
|
+
if (settled) return settled;
|
|
239
|
+
reason = "geometry never settled";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (Date.now() >= deadline) break;
|
|
243
|
+
await sleep(POLL_MS);
|
|
244
|
+
}
|
|
245
|
+
const log = [`waiting for ${this.selector}`, `resolved ${count} element${count === 1 ? "" : "s"}`];
|
|
246
|
+
if (reason) log.push(`element is ${reason}`);
|
|
247
|
+
const candidates = root ? rankCandidates(allNodes(root), intendedName(this.parts)) : [];
|
|
248
|
+
throw new TimeoutError(`locator.${opts.action}`, timeout, log, candidates, await this.windowLabel(root));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Resolved node, waiting only for it to exist. */
|
|
252
|
+
node(opts: ActionOptions = {}): Promise<NdNode> {
|
|
253
|
+
return this.resolveOne({ action: "node", timeout: opts.timeout });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Resolved wire ref, for the coordinate RPCs a locator does not wrap. */
|
|
257
|
+
async ref(opts: ActionOptions = {}): Promise<number> {
|
|
258
|
+
return (await this.node(opts)).ref;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async dispatch<T>(action: string, run: (ref: number) => Promise<T>, opts: ActionOptions): Promise<T> {
|
|
262
|
+
const timeout = opts.timeout ?? this.client.actionTimeout;
|
|
263
|
+
const deadline = Date.now() + timeout;
|
|
264
|
+
for (;;) {
|
|
265
|
+
const node = await this.resolveOne({
|
|
266
|
+
action,
|
|
267
|
+
timeout: Math.max(POLL_MS, deadline - Date.now()),
|
|
268
|
+
actionable: true,
|
|
269
|
+
stable: true,
|
|
270
|
+
});
|
|
271
|
+
try {
|
|
272
|
+
return await run(node.ref);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
if (isStaleRef(e) && Date.now() < deadline) continue;
|
|
275
|
+
throw e;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- actions -------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/** `action` names a SourceTree row action, dispatched on the row the
|
|
283
|
+
* locator resolves to. */
|
|
284
|
+
async click(opts: ActionOptions & { action?: string } = {}): Promise<void> {
|
|
285
|
+
await this.dispatch("click", (ref) => this.client.callRpc("click", { ref, action: opts.action }), opts);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async dblclick(opts: ActionOptions = {}): Promise<void> {
|
|
289
|
+
await this.dispatch("dblclick", (ref) => this.client.callRpc("doubleClick", { ref }), opts);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async rightClick(opts: ActionOptions = {}): Promise<void> {
|
|
293
|
+
await this.dispatch("rightClick", (ref) => this.client.callRpc("rightClick", { ref }), opts);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async hover(opts: ActionOptions = {}): Promise<void> {
|
|
297
|
+
await this.dispatch("hover", (ref) => this.client.callRpc("hover", { ref }), opts);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Replaces the widget's value, the way a user finishing an edit would.
|
|
301
|
+
* Kind-dispatched host-side: string for text, number for a slider. */
|
|
302
|
+
async fill(value: string | number | boolean, opts: ActionOptions = {}): Promise<void> {
|
|
303
|
+
await this.dispatch("fill", (ref) => this.client.callRpc("setValue", { ref, value }), opts);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Appends text without clearing what is there, the `type` RPC's semantics. */
|
|
307
|
+
async type(text: string, opts: ActionOptions = {}): Promise<void> {
|
|
308
|
+
await this.dispatch("type", (ref) => this.client.callRpc("type", { ref, text }), opts);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
pressSequentially(text: string, opts: ActionOptions = {}): Promise<void> {
|
|
312
|
+
return this.type(text, opts);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Focuses the widget, then sends one chord in Playwright key names. */
|
|
316
|
+
async press(key: string, opts: ActionOptions = {}): Promise<void> {
|
|
317
|
+
await this.focus(opts);
|
|
318
|
+
await this.client.callRpc("keys", { keys: toChord(key), window: this.windowRef });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
check(opts: ActionOptions = {}): Promise<void> {
|
|
322
|
+
return this.setChecked(true, opts);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
uncheck(opts: ActionOptions = {}): Promise<void> {
|
|
326
|
+
return this.setChecked(false, opts);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async setChecked(checked: boolean, opts: ActionOptions = {}): Promise<void> {
|
|
330
|
+
const node = await this.resolveOne({ action: "setChecked", timeout: opts.timeout, actionable: true, stable: true });
|
|
331
|
+
if (nodeChecked(node) === checked) return;
|
|
332
|
+
await this.dispatch("setChecked", (ref) => this.client.callRpc("setValue", { ref, value: checked }), opts);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** An index selects that row. A string is looked up in the node's
|
|
336
|
+
* `options` first, so a drive can name the option instead of counting to
|
|
337
|
+
* it, and is passed through when the host sends no options (Select and
|
|
338
|
+
* ComboBox both accept a literal). */
|
|
339
|
+
async selectOption(value: string | number, opts: ActionOptions = {}): Promise<void> {
|
|
340
|
+
let resolved: string | number = value;
|
|
341
|
+
if (typeof value === "string") {
|
|
342
|
+
const node = await this.resolveOne({ action: "selectOption", timeout: opts.timeout });
|
|
343
|
+
const index = node.options?.indexOf(value) ?? -1;
|
|
344
|
+
if (index >= 0) resolved = index;
|
|
345
|
+
}
|
|
346
|
+
await this.dispatch("selectOption", (ref) => this.client.callRpc("setValue", { ref, value: resolved }), opts);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async focus(opts: ActionOptions = {}): Promise<void> {
|
|
350
|
+
await this.dispatch("focus", (ref) => callHost(this.client, "focus", { ref }), opts);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** There is no blur RPC: focus moves to the enclosing window instead, which
|
|
354
|
+
* is what resigns a widget's first-responder status. */
|
|
355
|
+
async blur(opts: ActionOptions = {}): Promise<void> {
|
|
356
|
+
const node = await this.resolveOne({ action: "blur", timeout: opts.timeout });
|
|
357
|
+
const { root } = await this.snapshot();
|
|
358
|
+
const target = this.windowRef ?? root?.ref;
|
|
359
|
+
if (target === undefined) throw new LocatorError(`blur: no window to move focus to (from ref ${node.ref})`);
|
|
360
|
+
await callHost(this.client, "focus", { ref: target });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async scrollIntoViewIfNeeded(opts: ActionOptions = {}): Promise<void> {
|
|
364
|
+
const node = await this.resolveOne({ action: "scrollIntoViewIfNeeded", timeout: opts.timeout });
|
|
365
|
+
await callHost(this.client, "scrollIntoView", { ref: node.ref });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async dragTo(
|
|
369
|
+
target: Locator,
|
|
370
|
+
opts: ActionOptions & { steps?: number; durationMs?: number } = {},
|
|
371
|
+
): Promise<void> {
|
|
372
|
+
const toRef = await target.resolveOne({ action: "dragTo", timeout: opts.timeout, actionable: true, stable: true });
|
|
373
|
+
await this.dispatch(
|
|
374
|
+
"dragTo",
|
|
375
|
+
(ref) =>
|
|
376
|
+
this.client.callRpc("drag", {
|
|
377
|
+
fromRef: ref,
|
|
378
|
+
toRef: toRef.ref,
|
|
379
|
+
steps: opts.steps,
|
|
380
|
+
durationMs: opts.durationMs,
|
|
381
|
+
window: this.windowRef,
|
|
382
|
+
}),
|
|
383
|
+
opts,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Renders this node's own widget to a PNG. `path` is optional: without
|
|
388
|
+
* one the host writes beside the automation socket and answers where. The
|
|
389
|
+
* result's width/height are the node's pixels, not the window's. */
|
|
390
|
+
async screenshot(path?: string, opts: ActionOptions = {}): Promise<ScreenshotResult> {
|
|
391
|
+
const node = await this.resolveOne({ action: "screenshot", timeout: opts.timeout });
|
|
392
|
+
return (await callHost(this.client, "snapshotNode", { ref: node.ref, path })) as ScreenshotResult;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// --- readers -------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
async boundingBox(opts: ActionOptions = {}): Promise<BoundingBox | null> {
|
|
398
|
+
const g = (await this.node(opts)).geometry;
|
|
399
|
+
return g ? { x: g.x, y: g.y, width: g.w, height: g.h } : null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async textContent(opts: ActionOptions = {}): Promise<string | null> {
|
|
403
|
+
return (await this.node(opts)).text;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async inputValue(opts: ActionOptions = {}): Promise<string> {
|
|
407
|
+
return renderValue((await this.node(opts)).value);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Node fields by name: testID, role, type, text, label, placeholder,
|
|
411
|
+
* value. Anything else answers null rather than throwing, matching the
|
|
412
|
+
* "attribute is absent" reading. */
|
|
413
|
+
async getAttribute(name: string, opts: ActionOptions = {}): Promise<string | null> {
|
|
414
|
+
const node = await this.node(opts);
|
|
415
|
+
switch (name) {
|
|
416
|
+
case "testId":
|
|
417
|
+
case "testID":
|
|
418
|
+
return node.testID;
|
|
419
|
+
case "role":
|
|
420
|
+
return node.role;
|
|
421
|
+
case "type":
|
|
422
|
+
return node.type;
|
|
423
|
+
case "text":
|
|
424
|
+
return node.text;
|
|
425
|
+
case "label":
|
|
426
|
+
return nodeName(node) || null;
|
|
427
|
+
case "placeholder":
|
|
428
|
+
return nodePlaceholder(node) || null;
|
|
429
|
+
case "value":
|
|
430
|
+
return node.value === null || node.value === undefined ? null : renderValue(node.value);
|
|
431
|
+
default:
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async isVisible(): Promise<boolean> {
|
|
437
|
+
const { matches } = await this.snapshot();
|
|
438
|
+
return matches.length > 0 && matches[0]!.visible;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async isEnabled(opts: ActionOptions = {}): Promise<boolean> {
|
|
442
|
+
return (await this.node(opts)).enabled;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async isChecked(opts: ActionOptions = {}): Promise<boolean> {
|
|
446
|
+
return nodeChecked(await this.node(opts)) === true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async isFocused(opts: ActionOptions = {}): Promise<boolean> {
|
|
450
|
+
return (await this.node(opts)).focused;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async count(): Promise<number> {
|
|
454
|
+
return (await this.snapshot()).matches.length;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async all(): Promise<Locator[]> {
|
|
458
|
+
const n = await this.count();
|
|
459
|
+
return Array.from({ length: n }, (_, i) => this.nth(i));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async allTextContents(): Promise<string[]> {
|
|
463
|
+
return (await this.snapshot()).matches.map(nodeText);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async waitFor(opts: ActionOptions & { state?: WaitForState } = {}): Promise<void> {
|
|
467
|
+
const state = opts.state ?? "visible";
|
|
468
|
+
const timeout = opts.timeout ?? this.client.actionTimeout;
|
|
469
|
+
const deadline = Date.now() + timeout;
|
|
470
|
+
let root: NdNode | null = null;
|
|
471
|
+
let count = 0;
|
|
472
|
+
for (;;) {
|
|
473
|
+
const snap = await this.snapshot();
|
|
474
|
+
root = snap.root;
|
|
475
|
+
count = snap.matches.length;
|
|
476
|
+
const visible = snap.matches.filter((m) => m.visible).length;
|
|
477
|
+
const ok =
|
|
478
|
+
state === "attached"
|
|
479
|
+
? count > 0
|
|
480
|
+
: state === "detached"
|
|
481
|
+
? count === 0
|
|
482
|
+
: state === "visible"
|
|
483
|
+
? visible > 0
|
|
484
|
+
: visible === 0;
|
|
485
|
+
if (ok) return;
|
|
486
|
+
if (Date.now() >= deadline) break;
|
|
487
|
+
await sleep(POLL_MS);
|
|
488
|
+
}
|
|
489
|
+
const log = [`waiting for ${this.selector} to be ${state}`, `resolved ${count} element${count === 1 ? "" : "s"}`];
|
|
490
|
+
const candidates = root ? rankCandidates(allNodes(root), intendedName(this.parts)) : [];
|
|
491
|
+
throw new TimeoutError("locator.waitFor", timeout, log, candidates, await this.windowLabel(root));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function roleSpec(role: string, opts: RoleOptions): SelectorPart {
|
|
496
|
+
const part: SelectorPart = { kind: "role", role };
|
|
497
|
+
if (opts.name !== undefined) part.name = textSpec(opts.name, opts.exact);
|
|
498
|
+
if (opts.checked !== undefined) part.checked = opts.checked;
|
|
499
|
+
if (opts.disabled !== undefined) part.disabled = opts.disabled;
|
|
500
|
+
return part;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** The getBy* surface, bound to a client and optionally to one window. */
|
|
504
|
+
export class LocatorFactory {
|
|
505
|
+
readonly keyboard: Keyboard;
|
|
506
|
+
readonly mouse: Mouse;
|
|
507
|
+
|
|
508
|
+
constructor(
|
|
509
|
+
readonly client: LocatorClient,
|
|
510
|
+
readonly windowRef?: number,
|
|
511
|
+
) {
|
|
512
|
+
this.keyboard = new Keyboard(client, windowRef);
|
|
513
|
+
this.mouse = new Mouse(client, windowRef);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
locator(selector: string): Locator {
|
|
517
|
+
return new Locator(this.client, parseSelector(selector), this.windowRef);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
getByTestId(testId: string): Locator {
|
|
521
|
+
return new Locator(this.client, [{ kind: "testid", value: testId }], this.windowRef);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
getByRole(role: string, opts: RoleOptions = {}): Locator {
|
|
525
|
+
return new Locator(this.client, [roleSpec(role, opts)], this.windowRef);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
getByText(text: string | RegExp, opts: TextOptions = {}): Locator {
|
|
529
|
+
return new Locator(this.client, [{ kind: "text", ...textSpec(text, opts.exact) }], this.windowRef);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
getByLabel(text: string | RegExp, opts: TextOptions = {}): Locator {
|
|
533
|
+
return new Locator(this.client, [{ kind: "label", ...textSpec(text, opts.exact) }], this.windowRef);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
getByPlaceholder(text: string | RegExp, opts: TextOptions = {}): Locator {
|
|
537
|
+
return new Locator(this.client, [{ kind: "placeholder", ...textSpec(text, opts.exact) }], this.windowRef);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Selector engine against a real getTree capture. fixtures/gestures-tree.json
|
|
2
|
+
// came off examples/gestures on the AppKit host, so the field shapes (a
|
|
3
|
+
// Slider's numeric value, a Checkbox's boolean, a Label whose value repeats
|
|
4
|
+
// its text) are the host's, not a hand-written idea of them.
|
|
5
|
+
import { expect, test } from "bun:test";
|
|
6
|
+
import type { GetTreeResult } from "@nativedesktop/react/rpc";
|
|
7
|
+
import fixture from "./fixtures/gestures-tree.json" with { type: "json" };
|
|
8
|
+
import { asNdNode, nodeChecked, nodeName, renderValue, selectNodes, subtreeText, type NdNode } from "./matcher.ts";
|
|
9
|
+
import { parseSelector } from "./selectors.ts";
|
|
10
|
+
|
|
11
|
+
const root: NdNode = asNdNode((fixture as GetTreeResult).root);
|
|
12
|
+
|
|
13
|
+
function ids(selector: string): (string | null)[] {
|
|
14
|
+
return selectNodes(root, parseSelector(selector)).map((n) => n.testID);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("testid and type select one node", () => {
|
|
18
|
+
expect(ids("testid=volume-slider")).toEqual(["volume-slider"]);
|
|
19
|
+
expect(ids("type=Checkbox")).toEqual(["agree-check"]);
|
|
20
|
+
expect(ids("testid=nope")).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("role selects by the schema-declared automation role", () => {
|
|
24
|
+
expect(ids("role=slider")).toEqual(["volume-slider"]);
|
|
25
|
+
expect(ids("role=window")).toEqual([null]);
|
|
26
|
+
expect(ids("role=label")).toEqual(["volume-label", "agree-label", "echo-label", "activated-label", "hover-target"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("role name matches the accessible name, exactly when quoted", () => {
|
|
30
|
+
expect(ids('role=label[name="Agreed: no"]')).toEqual(["agree-label"]);
|
|
31
|
+
expect(ids("role=label[name=agreed]")).toEqual(["agree-label"]);
|
|
32
|
+
expect(ids('role=label[name="agreed"]')).toEqual([]);
|
|
33
|
+
expect(ids("role=label[name=/^Volume/]")).toEqual(["volume-label"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("checked falls back to a boolean value when the host sends no checked field", () => {
|
|
37
|
+
expect(ids("role=checkbox[checked=false]")).toEqual(["agree-check"]);
|
|
38
|
+
expect(ids("role=checkbox[checked]")).toEqual([]);
|
|
39
|
+
expect(nodeChecked(selectNodes(root, parseSelector("testid=agree-check"))[0]!)).toBe(false);
|
|
40
|
+
expect(nodeChecked(selectNodes(root, parseSelector("testid=volume-slider"))[0]!)).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("disabled reads the enabled field", () => {
|
|
44
|
+
expect(ids("role=label[disabled]")).toEqual([]);
|
|
45
|
+
expect(ids("role=label[disabled=false]").length).toBe(5);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("text is a case-insensitive substring unless quoted", () => {
|
|
49
|
+
expect(ids("text=volume")).toEqual(["volume-label"]);
|
|
50
|
+
expect(ids('text="Volume: 20"')).toEqual(["volume-label"]);
|
|
51
|
+
expect(ids('text="volume: 20"')).toEqual([]);
|
|
52
|
+
expect(ids("text=/right-click/")).toEqual(["hover-target"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("nth, first and last cut the match set", () => {
|
|
56
|
+
expect(ids("role=label >> nth=0")).toEqual(["volume-label"]);
|
|
57
|
+
expect(ids("role=label >> last")).toEqual(["hover-target"]);
|
|
58
|
+
expect(ids("role=label >> nth=-2")).toEqual(["activated-label"]);
|
|
59
|
+
expect(ids("role=label >> nth=99")).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("chaining descends into the previous match", () => {
|
|
63
|
+
expect(ids("role=group >> role=slider")).toEqual(["volume-slider"]);
|
|
64
|
+
expect(ids("role=slider >> role=label")).toEqual([]);
|
|
65
|
+
expect(ids("type=Box >> role=label >> first")).toEqual(["volume-label"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("filter parts refine without descending", () => {
|
|
69
|
+
expect(ids("type=Box >> has=(testid=volume-slider)")).toEqual([null]);
|
|
70
|
+
expect(ids("type=Box >> has=(testid=nope)")).toEqual([]);
|
|
71
|
+
expect(ids("type=Box >> has-not=(testid=nope)")).toEqual([null]);
|
|
72
|
+
expect(ids("role=label >> has-text=Echo")).toEqual(["echo-label"]);
|
|
73
|
+
expect(ids("role=label >> and=(type=Label) >> first")).toEqual(["volume-label"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("node readers", () => {
|
|
77
|
+
const slider = selectNodes(root, parseSelector("testid=volume-slider"))[0]!;
|
|
78
|
+
expect(renderValue(slider.value)).toBe("20");
|
|
79
|
+
expect(nodeName(slider)).toBe("");
|
|
80
|
+
expect(nodeName(selectNodes(root, parseSelector("testid=agree-check"))[0]!)).toBe("I agree");
|
|
81
|
+
expect(subtreeText(root)).toContain("Volume: 20");
|
|
82
|
+
expect(subtreeText(root)).toContain("Hover / right-click target");
|
|
83
|
+
});
|