@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/matcher.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// SelectorPart -> predicate over a getTree node, plus the node-field readers
|
|
2
|
+
// every layer above (locator, expect, snapshot) shares.
|
|
3
|
+
//
|
|
4
|
+
// checked, selected, expanded, label, placeholder and options are null on
|
|
5
|
+
// every node the field does not apply to, and null on a host that predates
|
|
6
|
+
// them, so each reader falls back to what the older wire already carried (a
|
|
7
|
+
// boolean `value` is the checked state, `text` is the accessible name) and a
|
|
8
|
+
// selector behaves the same against either host.
|
|
9
|
+
import type { JsonNode } from "@nativedesktop/react/rpc";
|
|
10
|
+
import type { SelectorPart, TextSpec } from "./selectors.ts";
|
|
11
|
+
|
|
12
|
+
/** getTree's node, narrowed so `children` recurses as NdNode. */
|
|
13
|
+
export type NdNode = Omit<JsonNode, "children"> & {
|
|
14
|
+
children: NdNode[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function asNdNode(node: JsonNode): NdNode {
|
|
18
|
+
return node as NdNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function nodeText(node: NdNode): string {
|
|
22
|
+
return node.text ?? "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The concatenated text of a node and everything under it, which is what
|
|
26
|
+
* `filter({hasText})` matches against. */
|
|
27
|
+
export function subtreeText(node: NdNode): string {
|
|
28
|
+
let out = nodeText(node);
|
|
29
|
+
for (const child of node.children) {
|
|
30
|
+
const inner = subtreeText(child);
|
|
31
|
+
if (inner) out = out ? `${out} ${inner}` : inner;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Accessible name: the label the host sends, else the node's own text, else
|
|
37
|
+
* a string value (Badge and Kbd carry their content there). */
|
|
38
|
+
export function nodeName(node: NdNode): string {
|
|
39
|
+
if (typeof node.label === "string" && node.label) return node.label;
|
|
40
|
+
if (node.text) return node.text;
|
|
41
|
+
return typeof node.value === "string" ? node.value : "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function nodePlaceholder(node: NdNode): string {
|
|
45
|
+
return typeof node.placeholder === "string" ? node.placeholder : "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function nodeChecked(node: NdNode): boolean | undefined {
|
|
49
|
+
if (typeof node.checked === "boolean") return node.checked;
|
|
50
|
+
if (typeof node.value === "boolean") return node.value;
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The string rendering the host uses for waitFor's valueEquals, so one
|
|
55
|
+
* comparison fits a TextInput, a Slider and a Checkbox alike. */
|
|
56
|
+
export function renderValue(value: unknown): string {
|
|
57
|
+
if (value === null || value === undefined) return "";
|
|
58
|
+
return typeof value === "string" ? value : String(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function hasRealSize(node: NdNode): boolean {
|
|
62
|
+
const g = node.geometry;
|
|
63
|
+
return g != null && g.w > 0 && g.h > 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalize(s: string): string {
|
|
67
|
+
return s.replace(/\s+/g, " ").trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function matchText(spec: TextSpec, actual: string): boolean {
|
|
71
|
+
if (spec.regex) return spec.regex.test(actual);
|
|
72
|
+
const want = spec.value ?? "";
|
|
73
|
+
if (spec.exact) return normalize(actual) === normalize(want);
|
|
74
|
+
return normalize(actual).toLowerCase().includes(normalize(want).toLowerCase());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True when `part` holds for `node` itself. Structural parts (nth, has,
|
|
78
|
+
* has-not, and) are handled by the selection engine, not here. */
|
|
79
|
+
export function matchPart(node: NdNode, part: SelectorPart): boolean {
|
|
80
|
+
switch (part.kind) {
|
|
81
|
+
case "testid":
|
|
82
|
+
return node.testID === part.value;
|
|
83
|
+
case "type":
|
|
84
|
+
return node.type === part.value;
|
|
85
|
+
case "text":
|
|
86
|
+
return matchText(part, nodeText(node));
|
|
87
|
+
case "label":
|
|
88
|
+
return matchText(part, nodeName(node));
|
|
89
|
+
case "placeholder":
|
|
90
|
+
return matchText(part, nodePlaceholder(node) || renderValue(node.value));
|
|
91
|
+
case "has-text":
|
|
92
|
+
return matchText(part, subtreeText(node));
|
|
93
|
+
case "role": {
|
|
94
|
+
if ((node.role ?? "") !== part.role) return false;
|
|
95
|
+
if (part.name && !matchText(part.name, nodeName(node))) return false;
|
|
96
|
+
if (part.checked !== undefined && nodeChecked(node) !== part.checked) return false;
|
|
97
|
+
if (part.disabled !== undefined && node.enabled !== !part.disabled) return false;
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
default:
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function descendants(node: NdNode, out: NdNode[]): NdNode[] {
|
|
106
|
+
for (const child of node.children) {
|
|
107
|
+
out.push(child);
|
|
108
|
+
descendants(child, out);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function selfAndDescendants(node: NdNode): NdNode[] {
|
|
114
|
+
return descendants(node, [node]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type PositionalPart = Extract<SelectorPart, { kind: "nth" | "has" | "has-not" | "and" }>;
|
|
118
|
+
|
|
119
|
+
function isPositional(part: SelectorPart): part is PositionalPart {
|
|
120
|
+
return part.kind === "nth" || part.kind === "has" || part.kind === "has-not" || part.kind === "and";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function pickNth(nodes: NdNode[], index: number): NdNode[] {
|
|
124
|
+
const at = index < 0 ? nodes.length + index : index;
|
|
125
|
+
const node = nodes[at];
|
|
126
|
+
return node ? [node] : [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whether one node satisfies every positional-free part (used by has/and). */
|
|
130
|
+
function matchesAll(node: NdNode, parts: SelectorPart[]): boolean {
|
|
131
|
+
return parts.every((p) => (isPositional(p) ? true : matchPart(node, p)));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolves a parsed selector against a tree.
|
|
136
|
+
*
|
|
137
|
+
* An engine part descends: the first searches the root and everything under
|
|
138
|
+
* it, each later one searches strictly inside the previous part's matches.
|
|
139
|
+
* nth/has/has-not/and/has-text refine the current set without descending.
|
|
140
|
+
*/
|
|
141
|
+
export function selectNodes(root: NdNode, parts: SelectorPart[]): NdNode[] {
|
|
142
|
+
let current: NdNode[] = [root];
|
|
143
|
+
let descended = false;
|
|
144
|
+
for (const part of parts) {
|
|
145
|
+
if (part.kind === "has-text") {
|
|
146
|
+
current = current.filter((n) => matchPart(n, part));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (isPositional(part)) {
|
|
150
|
+
if (part.kind === "nth") {
|
|
151
|
+
current = pickNth(current, part.index);
|
|
152
|
+
} else if (part.kind === "and") {
|
|
153
|
+
current = current.filter((n) => matchesAll(n, part.parts));
|
|
154
|
+
} else {
|
|
155
|
+
const wantInner = part.kind === "has";
|
|
156
|
+
current = current.filter((n) => descendants(n, []).some((d) => matchesAll(d, part.parts)) === wantInner);
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const pool: NdNode[] = [];
|
|
161
|
+
const seen = new Set<NdNode>();
|
|
162
|
+
for (const scope of current) {
|
|
163
|
+
for (const node of descended ? descendants(scope, []) : selfAndDescendants(scope)) {
|
|
164
|
+
if (seen.has(node)) continue;
|
|
165
|
+
seen.add(node);
|
|
166
|
+
pool.push(node);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
current = pool.filter((n) => matchPart(n, part));
|
|
170
|
+
descended = true;
|
|
171
|
+
}
|
|
172
|
+
return descended ? current : [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Every node in the tree, in document order: the candidate pool an error
|
|
176
|
+
* message ranks. */
|
|
177
|
+
export function allNodes(root: NdNode): NdNode[] {
|
|
178
|
+
return selfAndDescendants(root);
|
|
179
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { formatSelector, intendedName, parseSelector } from "./selectors.ts";
|
|
3
|
+
|
|
4
|
+
test("parses every engine", () => {
|
|
5
|
+
expect(parseSelector("testid=save-btn")).toEqual([{ kind: "testid", value: "save-btn" }]);
|
|
6
|
+
expect(parseSelector("type=Button")).toEqual([{ kind: "type", value: "Button" }]);
|
|
7
|
+
expect(parseSelector("text=Save")).toEqual([{ kind: "text", value: "Save" }]);
|
|
8
|
+
expect(parseSelector('text="Save As"')).toEqual([{ kind: "text", value: "Save As", exact: true }]);
|
|
9
|
+
expect(parseSelector("text=/sa.e/i")).toEqual([{ kind: "text", regex: /sa.e/i }]);
|
|
10
|
+
expect(parseSelector("label=Volume")).toEqual([{ kind: "label", value: "Volume" }]);
|
|
11
|
+
expect(parseSelector("placeholder=Search")).toEqual([{ kind: "placeholder", value: "Search" }]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("parses a role with attributes", () => {
|
|
15
|
+
expect(parseSelector('role=button[name="Save"][exact]')).toEqual([
|
|
16
|
+
{ kind: "role", role: "button", name: { value: "Save", exact: true } },
|
|
17
|
+
]);
|
|
18
|
+
expect(parseSelector("role=checkbox[checked][disabled]")).toEqual([
|
|
19
|
+
{ kind: "role", role: "checkbox", checked: true, disabled: true },
|
|
20
|
+
]);
|
|
21
|
+
expect(parseSelector("role=checkbox[checked=false]")).toEqual([
|
|
22
|
+
{ kind: "role", role: "checkbox", checked: false },
|
|
23
|
+
]);
|
|
24
|
+
expect(parseSelector("role=button[name=/save/i]")).toEqual([
|
|
25
|
+
{ kind: "role", role: "button", name: { regex: /save/i } },
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("chains parts on >> and reads the positional shorthands", () => {
|
|
30
|
+
expect(parseSelector('role=button[name="Save"] >> nth=1')).toEqual([
|
|
31
|
+
{ kind: "role", role: "button", name: { value: "Save", exact: true } },
|
|
32
|
+
{ kind: "nth", index: 1 },
|
|
33
|
+
]);
|
|
34
|
+
expect(parseSelector("testid=list >> first")).toEqual([
|
|
35
|
+
{ kind: "testid", value: "list" },
|
|
36
|
+
{ kind: "nth", index: 0 },
|
|
37
|
+
]);
|
|
38
|
+
expect(parseSelector("testid=list >> last")).toEqual([
|
|
39
|
+
{ kind: "testid", value: "list" },
|
|
40
|
+
{ kind: "nth", index: -1 },
|
|
41
|
+
]);
|
|
42
|
+
expect(parseSelector("nth=-1")).toEqual([{ kind: "nth", index: -1 }]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a >> inside a quoted value or a regex is not a separator", () => {
|
|
46
|
+
expect(parseSelector('text=">> keep"')).toEqual([{ kind: "text", value: ">> keep", exact: true }]);
|
|
47
|
+
expect(parseSelector("text=/a>>b/")).toEqual([{ kind: "text", regex: /a>>b/ }]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("parses nested filter parts", () => {
|
|
51
|
+
expect(parseSelector("type=Row >> has=(role=button)")).toEqual([
|
|
52
|
+
{ kind: "type", value: "Row" },
|
|
53
|
+
{ kind: "has", parts: [{ kind: "role", role: "button" }] },
|
|
54
|
+
]);
|
|
55
|
+
expect(parseSelector("type=Row >> has-not=(testid=x) >> has-text=Ada")).toEqual([
|
|
56
|
+
{ kind: "type", value: "Row" },
|
|
57
|
+
{ kind: "has-not", parts: [{ kind: "testid", value: "x" }] },
|
|
58
|
+
{ kind: "has-text", value: "Ada" },
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("format round-trips through parse", () => {
|
|
63
|
+
const cases = [
|
|
64
|
+
"testid=save-btn",
|
|
65
|
+
'role=button[name="Save"]',
|
|
66
|
+
"role=button[name=Save]",
|
|
67
|
+
"role=checkbox[checked][disabled]",
|
|
68
|
+
"text=/sa.e/i",
|
|
69
|
+
"type=Table >> has-text=Ada >> nth=-1",
|
|
70
|
+
"type=Row >> has=(role=button >> nth=0)",
|
|
71
|
+
"label=Volume >> and=(type=Label)",
|
|
72
|
+
];
|
|
73
|
+
for (const selector of cases) {
|
|
74
|
+
const parsed = parseSelector(selector);
|
|
75
|
+
expect(formatSelector(parsed)).toBe(selector);
|
|
76
|
+
expect(parseSelector(formatSelector(parsed))).toEqual(parsed);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("rejects a malformed selector", () => {
|
|
81
|
+
expect(() => parseSelector("button")).toThrow(/no engine/);
|
|
82
|
+
expect(() => parseSelector("nope=1")).toThrow(/unknown engine/);
|
|
83
|
+
expect(() => parseSelector("role=button[bogus]")).toThrow(/unknown role attribute/);
|
|
84
|
+
expect(() => parseSelector("nth=x")).toThrow(/not an integer/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("intendedName picks the last naming part", () => {
|
|
88
|
+
expect(intendedName(parseSelector("testid=save-btn >> nth=1"))).toBe("save-btn");
|
|
89
|
+
expect(intendedName(parseSelector('role=button[name="Save"]'))).toBe("Save");
|
|
90
|
+
expect(intendedName(parseSelector("role=button"))).toBe("button");
|
|
91
|
+
expect(intendedName(parseSelector("testid=a >> text=Ada"))).toBe("Ada");
|
|
92
|
+
expect(intendedName([])).toBeUndefined();
|
|
93
|
+
});
|
package/src/selectors.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Playwright-shaped selector strings, parsed into the SelectorPart list the
|
|
2
|
+
// matcher and Locator work on. A selector is one or more parts joined by
|
|
3
|
+
// ">>"; each engine part descends into the previous part's subtree, while the
|
|
4
|
+
// positional and filter parts refine the current match set in place.
|
|
5
|
+
//
|
|
6
|
+
// testid=save-btn
|
|
7
|
+
// role=button[name="Save"][exact] >> nth=1
|
|
8
|
+
// type=Table >> has-text=Ada >> first
|
|
9
|
+
//
|
|
10
|
+
// Every part is representable as a string, so a Locator built through the
|
|
11
|
+
// getBy*/filter helpers can print itself back as a selector in an error.
|
|
12
|
+
|
|
13
|
+
export interface TextSpec {
|
|
14
|
+
/** Substring or exact literal; absent when `regex` is set. */
|
|
15
|
+
value?: string;
|
|
16
|
+
regex?: RegExp;
|
|
17
|
+
/** Equality after whitespace normalisation, rather than a case-insensitive substring. */
|
|
18
|
+
exact?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SelectorPart =
|
|
22
|
+
| { kind: "testid"; value: string }
|
|
23
|
+
| ({ kind: "role"; role: string; checked?: boolean; disabled?: boolean } & { name?: TextSpec })
|
|
24
|
+
| ({ kind: "text" | "label" | "placeholder" } & TextSpec)
|
|
25
|
+
| { kind: "type"; value: string }
|
|
26
|
+
| { kind: "nth"; index: number }
|
|
27
|
+
| ({ kind: "has-text" } & TextSpec)
|
|
28
|
+
| { kind: "has" | "has-not" | "and"; parts: SelectorPart[] };
|
|
29
|
+
|
|
30
|
+
const TEXT_ENGINES = new Set(["text", "label", "placeholder"]);
|
|
31
|
+
|
|
32
|
+
/** Splits on top-level ">>", ignoring separators inside quotes, brackets,
|
|
33
|
+
* parens and regex literals. */
|
|
34
|
+
function splitParts(selector: string): string[] {
|
|
35
|
+
const out: string[] = [];
|
|
36
|
+
let depth = 0;
|
|
37
|
+
let quote: string | null = null;
|
|
38
|
+
let inRegex = false;
|
|
39
|
+
let start = 0;
|
|
40
|
+
for (let i = 0; i < selector.length; i++) {
|
|
41
|
+
const c = selector[i]!;
|
|
42
|
+
if (quote) {
|
|
43
|
+
if (c === "\\") i++;
|
|
44
|
+
else if (c === quote) quote = null;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (inRegex) {
|
|
48
|
+
if (c === "\\") i++;
|
|
49
|
+
else if (c === "/") inRegex = false;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (c === '"' || c === "'") quote = c;
|
|
53
|
+
else if (c === "/" && selector[i - 1] === "=") inRegex = true;
|
|
54
|
+
else if (c === "[" || c === "(") depth++;
|
|
55
|
+
else if (c === "]" || c === ")") depth--;
|
|
56
|
+
else if (depth === 0 && c === ">" && selector[i + 1] === ">") {
|
|
57
|
+
out.push(selector.slice(start, i));
|
|
58
|
+
i++;
|
|
59
|
+
start = i + 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
out.push(selector.slice(start));
|
|
63
|
+
return out.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseRegex(body: string): RegExp | null {
|
|
67
|
+
if (!body.startsWith("/")) return null;
|
|
68
|
+
const end = body.lastIndexOf("/");
|
|
69
|
+
if (end <= 0) return null;
|
|
70
|
+
return new RegExp(body.slice(1, end), body.slice(end + 1));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseTextSpec(body: string): TextSpec {
|
|
74
|
+
const trimmed = body.trim();
|
|
75
|
+
const regex = parseRegex(trimmed);
|
|
76
|
+
if (regex) return { regex };
|
|
77
|
+
if (trimmed.length >= 2 && (trimmed[0] === '"' || trimmed[0] === "'") && trimmed.at(-1) === trimmed[0]) {
|
|
78
|
+
return { value: unquote(trimmed), exact: true };
|
|
79
|
+
}
|
|
80
|
+
return { value: trimmed };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function unquote(s: string): string {
|
|
84
|
+
const q = s[0]!;
|
|
85
|
+
let out = "";
|
|
86
|
+
for (let i = 1; i < s.length - 1; i++) {
|
|
87
|
+
const c = s[i]!;
|
|
88
|
+
if (c === "\\") {
|
|
89
|
+
const next = s[++i];
|
|
90
|
+
out += next === "n" ? "\n" : next === "t" ? "\t" : (next ?? "");
|
|
91
|
+
} else if (c !== q) {
|
|
92
|
+
out += c;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `[name="Save"]`, `[name=/sa/i]`, `[exact]`, `[checked]`, `[disabled]`. */
|
|
99
|
+
function parseRoleAttrs(part: SelectorPart & { kind: "role" }, attrs: string, selector: string): void {
|
|
100
|
+
const re = /\[([a-zA-Z-]+)(?:=([^\]]*))?\]/g;
|
|
101
|
+
let m: RegExpExecArray | null;
|
|
102
|
+
let consumed = 0;
|
|
103
|
+
while ((m = re.exec(attrs))) {
|
|
104
|
+
consumed = re.lastIndex;
|
|
105
|
+
const key = m[1]!;
|
|
106
|
+
const raw = m[2];
|
|
107
|
+
switch (key) {
|
|
108
|
+
case "name":
|
|
109
|
+
part.name = parseTextSpec(raw ?? "");
|
|
110
|
+
break;
|
|
111
|
+
case "exact":
|
|
112
|
+
part.name = { ...(part.name ?? {}), exact: raw !== "false" };
|
|
113
|
+
break;
|
|
114
|
+
case "checked":
|
|
115
|
+
part.checked = raw !== "false";
|
|
116
|
+
break;
|
|
117
|
+
case "disabled":
|
|
118
|
+
part.disabled = raw !== "false";
|
|
119
|
+
break;
|
|
120
|
+
default:
|
|
121
|
+
throw new Error(`selector "${selector}": unknown role attribute [${key}]`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (consumed !== attrs.length) throw new Error(`selector "${selector}": malformed role attributes "${attrs}"`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseNested(body: string, selector: string): SelectorPart[] {
|
|
128
|
+
const trimmed = body.trim();
|
|
129
|
+
const inner = trimmed.startsWith("(") && trimmed.endsWith(")") ? trimmed.slice(1, -1) : trimmed;
|
|
130
|
+
const parts = parseSelector(inner);
|
|
131
|
+
if (!parts.length) throw new Error(`selector "${selector}": empty nested selector`);
|
|
132
|
+
return parts;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function parseSelector(selector: string): SelectorPart[] {
|
|
136
|
+
const out: SelectorPart[] = [];
|
|
137
|
+
for (const raw of splitParts(selector)) {
|
|
138
|
+
if (raw === "first") {
|
|
139
|
+
out.push({ kind: "nth", index: 0 });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (raw === "last") {
|
|
143
|
+
out.push({ kind: "nth", index: -1 });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const eq = raw.indexOf("=");
|
|
147
|
+
if (eq < 0) throw new Error(`selector "${selector}": part "${raw}" has no engine (want engine=value)`);
|
|
148
|
+
const engine = raw.slice(0, eq).trim().toLowerCase();
|
|
149
|
+
const body = raw.slice(eq + 1).trim();
|
|
150
|
+
if (engine === "nth") {
|
|
151
|
+
const index = Number(body);
|
|
152
|
+
if (!Number.isInteger(index)) throw new Error(`selector "${selector}": nth=${body} is not an integer`);
|
|
153
|
+
out.push({ kind: "nth", index });
|
|
154
|
+
} else if (engine === "testid" || engine === "test-id") {
|
|
155
|
+
out.push({ kind: "testid", value: body.startsWith('"') || body.startsWith("'") ? unquote(body) : body });
|
|
156
|
+
} else if (engine === "type") {
|
|
157
|
+
out.push({ kind: "type", value: body });
|
|
158
|
+
} else if (engine === "role") {
|
|
159
|
+
const bracket = body.indexOf("[");
|
|
160
|
+
const part: SelectorPart & { kind: "role" } = {
|
|
161
|
+
kind: "role",
|
|
162
|
+
role: (bracket < 0 ? body : body.slice(0, bracket)).trim(),
|
|
163
|
+
};
|
|
164
|
+
if (bracket >= 0) parseRoleAttrs(part, body.slice(bracket), selector);
|
|
165
|
+
if (!part.role) throw new Error(`selector "${selector}": role= needs a role name`);
|
|
166
|
+
out.push(part);
|
|
167
|
+
} else if (TEXT_ENGINES.has(engine)) {
|
|
168
|
+
out.push({ kind: engine as "text" | "label" | "placeholder", ...parseTextSpec(body) });
|
|
169
|
+
} else if (engine === "has-text") {
|
|
170
|
+
out.push({ kind: "has-text", ...parseTextSpec(body) });
|
|
171
|
+
} else if (engine === "has" || engine === "has-not" || engine === "and") {
|
|
172
|
+
out.push({ kind: engine, parts: parseNested(body, selector) });
|
|
173
|
+
} else {
|
|
174
|
+
throw new Error(`selector "${selector}": unknown engine "${engine}"`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatTextSpec(spec: TextSpec): string {
|
|
181
|
+
if (spec.regex) return String(spec.regex);
|
|
182
|
+
if (spec.exact) return JSON.stringify(spec.value ?? "");
|
|
183
|
+
return spec.value ?? "";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function formatPart(part: SelectorPart): string {
|
|
187
|
+
switch (part.kind) {
|
|
188
|
+
case "testid":
|
|
189
|
+
return `testid=${part.value}`;
|
|
190
|
+
case "type":
|
|
191
|
+
return `type=${part.value}`;
|
|
192
|
+
case "nth":
|
|
193
|
+
return `nth=${part.index}`;
|
|
194
|
+
case "role": {
|
|
195
|
+
let out = `role=${part.role}`;
|
|
196
|
+
// A quoted name already means exact, so [exact] is only emitted for the
|
|
197
|
+
// degenerate [exact]-with-no-name form.
|
|
198
|
+
if (part.name?.regex || part.name?.value) out += `[name=${formatTextSpec(part.name)}]`;
|
|
199
|
+
else if (part.name?.exact) out += "[exact]";
|
|
200
|
+
if (part.checked !== undefined) out += part.checked ? "[checked]" : "[checked=false]";
|
|
201
|
+
if (part.disabled !== undefined) out += part.disabled ? "[disabled]" : "[disabled=false]";
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
case "has":
|
|
205
|
+
case "has-not":
|
|
206
|
+
case "and":
|
|
207
|
+
return `${part.kind}=(${formatSelector(part.parts)})`;
|
|
208
|
+
default:
|
|
209
|
+
return `${part.kind}=${formatTextSpec(part)}`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function formatSelector(parts: SelectorPart[]): string {
|
|
214
|
+
return parts.map(formatPart).join(" >> ");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The literal a candidate list ranks against: the name/id the last engine
|
|
218
|
+
* part was reaching for. */
|
|
219
|
+
export function intendedName(parts: SelectorPart[]): string | undefined {
|
|
220
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
221
|
+
const part = parts[i]!;
|
|
222
|
+
if (part.kind === "testid" || part.kind === "type") return part.value;
|
|
223
|
+
if (part.kind === "role") return part.name?.value ?? part.role;
|
|
224
|
+
if (part.kind === "text" || part.kind === "label" || part.kind === "placeholder" || part.kind === "has-text") {
|
|
225
|
+
return part.value ?? part.regex?.source;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Compact accessibility-tree rendering, shared by the MCP `snapshot` tool and
|
|
2
|
+
// anything that wants a tree in an error or a log.
|
|
3
|
+
//
|
|
4
|
+
// One line per node, indented by depth:
|
|
5
|
+
//
|
|
6
|
+
// - window "Notes" [ref=e1]
|
|
7
|
+
// - button "Save" [ref=e12] [testid=save-btn] [enabled]
|
|
8
|
+
//
|
|
9
|
+
// A node with no role, text or testID carries no information a caller can act
|
|
10
|
+
// on, so it collapses: its children move up to its own depth. That turns the
|
|
11
|
+
// layout boxes that make up most of a real tree into nothing at all.
|
|
12
|
+
import { hasRealSize, nodeChecked, nodeName, renderValue, type NdNode } from "./matcher.ts";
|
|
13
|
+
|
|
14
|
+
export interface SnapshotOptions {
|
|
15
|
+
/** Keep only nodes an agent can act on (a role, plus visible and enabled),
|
|
16
|
+
* and the ancestors that lead to them. */
|
|
17
|
+
interactiveOnly?: boolean;
|
|
18
|
+
/** Cap the emitted lines; the renderer notes how many it dropped. */
|
|
19
|
+
maxLines?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const NON_INTERACTIVE_ROLES = new Set(["window", "group", "label", "image", "toolbar", "list", "tree", "table"]);
|
|
23
|
+
|
|
24
|
+
function identifies(node: NdNode): boolean {
|
|
25
|
+
return Boolean(node.role || node.testID || nodeName(node));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function actionable(node: NdNode): boolean {
|
|
29
|
+
if (!node.role || NON_INTERACTIVE_ROLES.has(node.role)) return false;
|
|
30
|
+
return node.visible && node.enabled && hasRealSize(node);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function line(node: NdNode, depth: number): string {
|
|
34
|
+
const bits = [`- ${node.role ?? node.type.toLowerCase()}`];
|
|
35
|
+
const name = nodeName(node);
|
|
36
|
+
if (name) bits.push(JSON.stringify(name));
|
|
37
|
+
bits.push(`[ref=e${node.ref}]`);
|
|
38
|
+
if (node.testID) bits.push(`[testid=${node.testID}]`);
|
|
39
|
+
const checked = nodeChecked(node);
|
|
40
|
+
if (checked !== undefined) bits.push(`[checked=${checked}]`);
|
|
41
|
+
else {
|
|
42
|
+
const value = renderValue(node.value);
|
|
43
|
+
if (value && value !== name) bits.push(`[value=${JSON.stringify(value)}]`);
|
|
44
|
+
}
|
|
45
|
+
if (!node.visible) bits.push("[hidden]");
|
|
46
|
+
bits.push(node.enabled ? "[enabled]" : "[disabled]");
|
|
47
|
+
return `${" ".repeat(depth)}${bits.join(" ")}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True when the node or anything under it survives the interactive filter. */
|
|
51
|
+
function keeps(node: NdNode, interactiveOnly: boolean): boolean {
|
|
52
|
+
if (!interactiveOnly) return true;
|
|
53
|
+
if (actionable(node)) return true;
|
|
54
|
+
return node.children.some((c) => keeps(c, interactiveOnly));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderSnapshot(root: NdNode, opts: SnapshotOptions = {}): string {
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
const interactiveOnly = opts.interactiveOnly ?? false;
|
|
60
|
+
const walk = (node: NdNode, depth: number): void => {
|
|
61
|
+
if (!keeps(node, interactiveOnly)) return;
|
|
62
|
+
const show = identifies(node) && (!interactiveOnly || actionable(node) || node.children.length > 0);
|
|
63
|
+
if (show) out.push(line(node, depth));
|
|
64
|
+
for (const child of node.children) walk(child, show ? depth + 1 : depth);
|
|
65
|
+
};
|
|
66
|
+
walk(root, 0);
|
|
67
|
+
const max = opts.maxLines;
|
|
68
|
+
if (max !== undefined && out.length > max) {
|
|
69
|
+
return [...out.slice(0, max), `... ${out.length - max} more nodes (raise maxLines or scope to a window)`].join("\n");
|
|
70
|
+
}
|
|
71
|
+
return out.join("\n");
|
|
72
|
+
}
|
package/src/socket.ts
CHANGED
|
@@ -98,9 +98,15 @@ export class AutomationClient {
|
|
|
98
98
|
else pending.resolve(msg.result);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// A method whose params are all optional (getTree) may be called with none
|
|
102
|
+
// at all, and then sends no params member rather than an empty object.
|
|
101
103
|
call<M extends RpcMethodName>(
|
|
102
104
|
method: M,
|
|
103
|
-
...params: RpcParams<M> extends undefined
|
|
105
|
+
...params: RpcParams<M> extends undefined
|
|
106
|
+
? []
|
|
107
|
+
: Record<string, never> extends RpcParams<M>
|
|
108
|
+
? [RpcParams<M>?]
|
|
109
|
+
: [RpcParams<M>]
|
|
104
110
|
): Promise<RpcResult<M>> {
|
|
105
111
|
const id = this.nextId++;
|
|
106
112
|
const json = new TextEncoder().encode(JSON.stringify({ jsonrpc: "2.0", id, method, params: params[0] }));
|