@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.
@@ -0,0 +1,306 @@
1
+ // Locator, expect and snapshot against a fake client, so the actionability
2
+ // loop, the strict-mode refusal, the stale-ref retry and the polling matchers
3
+ // are covered without a host process.
4
+ import { expect as bunExpect, test } from "bun:test";
5
+ import type { GetTreeResult, JsonNode } from "@nativedesktop/react/rpc";
6
+ import fixture from "./fixtures/gestures-tree.json" with { type: "json" };
7
+ import { AutomationRpcError } from "./socket.ts";
8
+ import { Locator, LocatorError, LocatorFactory, StrictModeError, TimeoutError, type LocatorClient } from "./index.ts";
9
+ import { expect } from "./expect.ts";
10
+ import { renderSnapshot } from "./snapshot.ts";
11
+ import { asNdNode } from "./matcher.ts";
12
+ import { toChord } from "./keyboard.ts";
13
+
14
+ interface NodeSpec {
15
+ ref: number;
16
+ type: string;
17
+ role?: string;
18
+ testID?: string;
19
+ text?: string;
20
+ value?: unknown;
21
+ visible?: boolean;
22
+ enabled?: boolean;
23
+ focused?: boolean;
24
+ checked?: boolean;
25
+ selected?: boolean;
26
+ expanded?: boolean;
27
+ placeholder?: string;
28
+ label?: string;
29
+ options?: string[];
30
+ w?: number;
31
+ h?: number;
32
+ children?: NodeSpec[];
33
+ }
34
+
35
+ function node(spec: NodeSpec): JsonNode {
36
+ return {
37
+ ref: spec.ref,
38
+ type: spec.type,
39
+ testID: spec.testID ?? null,
40
+ text: spec.text ?? null,
41
+ visible: spec.visible ?? true,
42
+ geometry: { x: 0, y: 0, w: spec.w ?? 100, h: spec.h ?? 20 },
43
+ children: (spec.children ?? []).map(node),
44
+ itemCount: null,
45
+ rows: null,
46
+ role: spec.role ?? null,
47
+ enabled: spec.enabled ?? true,
48
+ focused: spec.focused ?? false,
49
+ value: spec.value ?? null,
50
+ checked: spec.checked ?? null,
51
+ selected: spec.selected ?? null,
52
+ expanded: spec.expanded ?? null,
53
+ placeholder: spec.placeholder ?? null,
54
+ label: spec.label ?? null,
55
+ options: spec.options ?? null,
56
+ };
57
+ }
58
+
59
+ function tree(root: NodeSpec): GetTreeResult {
60
+ return { coordinateSpace: "logical-window-topleft", root: node(root) };
61
+ }
62
+
63
+ class FakeClient implements LocatorClient {
64
+ actionTimeout = 600;
65
+ readonly calls: { method: string; params?: Record<string, unknown> }[] = [];
66
+ reads = 0;
67
+ /** Rejections queued per method, consumed one per call. */
68
+ readonly failures = new Map<string, Error[]>();
69
+
70
+ constructor(private readonly snapshots: (read: number) => GetTreeResult) {}
71
+
72
+ tree(): Promise<GetTreeResult> {
73
+ return Promise.resolve(this.snapshots(this.reads++));
74
+ }
75
+
76
+ callRpc(method: string, params?: Record<string, unknown>): Promise<unknown> {
77
+ this.calls.push({ method, params });
78
+ const queued = this.failures.get(method)?.shift();
79
+ if (queued) return Promise.reject(queued);
80
+ if (method === "windows") return Promise.resolve({ windows: [{ ref: 1, title: "Notes" }] });
81
+ return Promise.resolve({ ref: params?.ref, dispatched: true });
82
+ }
83
+
84
+ fail(method: string, error: Error): void {
85
+ const list = this.failures.get(method) ?? [];
86
+ list.push(error);
87
+ this.failures.set(method, list);
88
+ }
89
+ }
90
+
91
+ const twoButtons = tree({
92
+ ref: 1,
93
+ type: "Window",
94
+ role: "window",
95
+ text: "Notes",
96
+ children: [
97
+ { ref: 2, type: "Button", role: "button", testID: "save", text: "Save" },
98
+ { ref: 3, type: "Button", role: "button", testID: "save-as", text: "Save As" },
99
+ { ref: 4, type: "Checkbox", role: "checkbox", testID: "agree", text: "I agree", value: false },
100
+ ],
101
+ });
102
+
103
+ /** The error a promise rejects with, so a test can assert on its text. */
104
+ async function rejection(promise: Promise<unknown>): Promise<Error> {
105
+ try {
106
+ await promise;
107
+ } catch (e) {
108
+ return e as Error;
109
+ }
110
+ throw new Error("expected a rejection");
111
+ }
112
+
113
+ function factory(snapshots: (read: number) => GetTreeResult): { app: FakeClient; page: LocatorFactory } {
114
+ const app = new FakeClient(snapshots);
115
+ return { app, page: new LocatorFactory(app) };
116
+ }
117
+
118
+ test("click resolves by testID and dispatches on the ref", async () => {
119
+ const { app, page } = factory(() => twoButtons);
120
+ await page.getByTestId("save").click();
121
+ bunExpect(app.calls).toEqual([{ method: "click", params: { ref: 2, action: undefined } }]);
122
+ });
123
+
124
+ test("two matches for a single-target action fail immediately", async () => {
125
+ const { app, page } = factory(() => twoButtons);
126
+ const started = Date.now();
127
+ const err = await rejection(page.getByRole("button").click());
128
+ bunExpect(err).toBeInstanceOf(StrictModeError);
129
+ bunExpect(err.message).toContain("resolved to 2 elements");
130
+ bunExpect(err.message).toContain(".first()");
131
+ bunExpect(Date.now() - started).toBeLessThan(app.actionTimeout);
132
+ });
133
+
134
+ test("first/nth/filter narrow a strict-mode violation", async () => {
135
+ const { app, page } = factory(() => twoButtons);
136
+ await page.getByRole("button").first().click();
137
+ await page.getByRole("button").nth(1).click();
138
+ await page.getByRole("button").filter({ hasText: "Save As" }).click();
139
+ bunExpect(app.calls.map((c) => c.params?.ref)).toEqual([2, 3, 3]);
140
+ });
141
+
142
+ test("a timeout names the call, the log and the nearest candidates", async () => {
143
+ const { page } = factory(() => asFixture());
144
+ const err = await rejection(page.getByTestId("volume-slidr").click({ timeout: 250 }));
145
+ bunExpect(err).toBeInstanceOf(TimeoutError);
146
+ bunExpect(err.message).toContain("locator.click: Timeout 250ms exceeded.");
147
+ bunExpect(err.message).toContain("waiting for testid=volume-slidr");
148
+ bunExpect(err.message).toContain("resolved 0 elements");
149
+ bunExpect(err.message).toContain('Nearest candidates in window "Notes"');
150
+ bunExpect(err.message).toContain("volume-slider");
151
+ });
152
+
153
+ test("an unactionable match reports why it was skipped", async () => {
154
+ const { page } = factory(() =>
155
+ tree({ ref: 1, type: "Window", role: "window", children: [{ ref: 2, type: "Button", testID: "save", visible: false }] }),
156
+ );
157
+ const err = await rejection(page.getByTestId("save").click({ timeout: 250 }));
158
+ bunExpect(err.message).toContain("element is not visible");
159
+ });
160
+
161
+ test("a zero-size widget is never clicked", async () => {
162
+ const { page } = factory(() =>
163
+ tree({ ref: 1, type: "Window", role: "window", children: [{ ref: 2, type: "Button", testID: "save", w: 0, h: 0 }] }),
164
+ );
165
+ const err = await rejection(page.getByTestId("save").click({ timeout: 250 }));
166
+ bunExpect(err.message).toContain("element is zero size (0x0)");
167
+ });
168
+
169
+ test("a moving widget settles before it is clicked", async () => {
170
+ // The button slides for the first four reads, then holds still.
171
+ const client = new FakeClient((read) => {
172
+ const t = tree({ ref: 1, type: "Window", role: "window", children: [{ ref: 2, type: "Button", testID: "save" }] });
173
+ t.root.children[0]!.geometry!.x = read < 4 ? read : 4;
174
+ return t;
175
+ });
176
+ await new Locator(client, [{ kind: "testid", value: "save" }]).click();
177
+ bunExpect(client.calls.map((c) => c.method)).toEqual(["click"]);
178
+ bunExpect(client.reads).toBeGreaterThan(4);
179
+ });
180
+
181
+ test("a stale ref sends the action back through resolve", async () => {
182
+ const { app, page } = factory(() => twoButtons);
183
+ app.fail("click", new AutomationRpcError(-32001, "not actionable"));
184
+ await page.getByTestId("save").click();
185
+ bunExpect(app.calls.filter((c) => c.method === "click").length).toBe(2);
186
+ });
187
+
188
+ test("an RPC the host does not have names the method", async () => {
189
+ const { app, page } = factory(() => twoButtons);
190
+ app.fail("focus", new AutomationRpcError(-32601, "method not found"));
191
+ const err = await rejection(page.getByTestId("save").focus());
192
+ bunExpect(err).toBeInstanceOf(LocatorError);
193
+ bunExpect(err.message).toBe('host predates the "focus" RPC');
194
+ });
195
+
196
+ test("fill, check and setChecked ride setValue, and check no-ops when already set", async () => {
197
+ const { app, page } = factory(() => twoButtons);
198
+ await page.getByTestId("agree").check();
199
+ await page.getByTestId("agree").uncheck();
200
+ bunExpect(app.calls.filter((c) => c.method === "setValue")).toEqual([
201
+ { method: "setValue", params: { ref: 4, value: true } },
202
+ ]);
203
+ });
204
+
205
+ test("readers answer from one tree read", async () => {
206
+ const { page } = factory(() => twoButtons);
207
+ bunExpect(await page.getByRole("button").count()).toBe(2);
208
+ bunExpect(await page.getByRole("button").allTextContents()).toEqual(["Save", "Save As"]);
209
+ bunExpect(await page.getByTestId("save").textContent()).toBe("Save");
210
+ bunExpect(await page.getByTestId("agree").inputValue()).toBe("false");
211
+ bunExpect(await page.getByTestId("agree").isChecked()).toBe(false);
212
+ bunExpect(await page.getByTestId("save").boundingBox()).toEqual({ x: 0, y: 0, width: 100, height: 20 });
213
+ bunExpect(await page.getByTestId("nope").isVisible()).toBe(false);
214
+ bunExpect(await page.getByTestId("save").getAttribute("role")).toBe("button");
215
+ });
216
+
217
+ test("expect polls until the tree catches up", async () => {
218
+ const { page } = factory((read) =>
219
+ tree({
220
+ ref: 1,
221
+ type: "Window",
222
+ role: "window",
223
+ children: [{ ref: 2, type: "Label", role: "label", testID: "count", text: read < 3 ? "2" : "3" }],
224
+ }),
225
+ );
226
+ await expect(page.getByTestId("count")).toHaveText("3");
227
+ await expect(page.getByTestId("count")).toContainText("3");
228
+ });
229
+
230
+ test("expect.not waits for the widget to go away", async () => {
231
+ const { page } = factory((read) =>
232
+ tree({
233
+ ref: 1,
234
+ type: "Window",
235
+ role: "window",
236
+ children: read < 3 ? [{ ref: 2, type: "Label", role: "label", testID: "toast", text: "Saved" }] : [],
237
+ }),
238
+ );
239
+ await expect(page.getByTestId("toast")).not.toBeVisible();
240
+ await expect(page.getByTestId("toast")).toHaveCount(0);
241
+ });
242
+
243
+ test("a failed matcher reports expected, received and the call log", async () => {
244
+ const { page } = factory(() => twoButtons);
245
+ const err = await rejection(expect(page.getByTestId("save")).toHaveText("Saved", { timeout: 200 }));
246
+ bunExpect(err).toBeInstanceOf(TimeoutError);
247
+ bunExpect(err.message).toContain("expect(testid=save).toHaveText: Timeout 200ms exceeded.");
248
+ bunExpect(err.message).toContain('Expected: "Saved"');
249
+ bunExpect(err.message).toContain('Received: "Save"');
250
+ bunExpect(err.message).toContain("resolved 1 element");
251
+ });
252
+
253
+ test("expect over a plain value", () => {
254
+ expect(3).toBe(3);
255
+ expect({ a: [1] }).toEqual({ a: [1] });
256
+ expect("ND_OK marker").toContain("ND_OK");
257
+ expect(5).not.toBe(4);
258
+ bunExpect(() => expect(4).toBe(5)).toThrow(/Expected: 5/);
259
+ });
260
+
261
+ test("Playwright key names become host chords", () => {
262
+ bunExpect(toChord("Meta+A")).toBe("cmd+shift+a");
263
+ bunExpect(toChord("Meta+a")).toBe("cmd+a");
264
+ bunExpect(toChord("Control+Shift+n")).toBe("ctrl+shift+n");
265
+ bunExpect(toChord("ArrowDown")).toBe("down");
266
+ bunExpect(toChord("Escape")).toBe("escape");
267
+ bunExpect(toChord("Enter")).toBe("enter");
268
+ bunExpect(toChord("PageDown")).toBe("pagedown");
269
+ bunExpect(toChord("F7")).toBe("f7");
270
+ bunExpect(() => toChord("Shift+")).toThrow(/no key left/);
271
+ });
272
+
273
+ test("snapshot renders one line per identified node and collapses the rest", () => {
274
+ const out = renderSnapshot(
275
+ asNdNode(
276
+ tree({
277
+ ref: 1,
278
+ type: "Window",
279
+ role: "window",
280
+ text: "Notes",
281
+ children: [
282
+ { ref: 5, type: "Box", children: [{ ref: 12, type: "Button", role: "button", testID: "save-btn", text: "Save" }] },
283
+ ],
284
+ }).root,
285
+ ),
286
+ );
287
+ bunExpect(out.split("\n")).toEqual([
288
+ '- window "Notes" [ref=e1] [enabled]',
289
+ ' - button "Save" [ref=e12] [testid=save-btn] [enabled]',
290
+ ]);
291
+ });
292
+
293
+ test("snapshot of a real capture keeps the interactive widgets", () => {
294
+ const out = renderSnapshot(asFixtureRoot(), { interactiveOnly: true });
295
+ bunExpect(out).toContain("[testid=volume-slider]");
296
+ bunExpect(out).toContain("[testid=name-input]");
297
+ bunExpect(out).not.toContain("[testid=volume-label]");
298
+ });
299
+
300
+ function asFixture(): GetTreeResult {
301
+ return fixture as GetTreeResult;
302
+ }
303
+
304
+ function asFixtureRoot() {
305
+ return asNdNode(asFixture().root);
306
+ }