@gtkx/testing 0.3.4 → 0.4.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 CHANGED
@@ -41,7 +41,14 @@ Edit your code and see changes instantly—no restart needed.
41
41
  ### Example
42
42
 
43
43
  ```tsx
44
- import { render, ApplicationWindow, Box, Button, Label, quit } from "@gtkx/react";
44
+ import {
45
+ render,
46
+ ApplicationWindow,
47
+ Box,
48
+ Button,
49
+ Label,
50
+ quit,
51
+ } from "@gtkx/react";
45
52
  import { Orientation } from "@gtkx/ffi/gtk";
46
53
  import { useState } from "react";
47
54
 
@@ -74,7 +81,7 @@ const primary = css`
74
81
  color: white;
75
82
  `;
76
83
 
77
- <Button label="Click me" cssClasses={[primary]} />
84
+ <Button label="Click me" cssClasses={[primary]} />;
78
85
  ```
79
86
 
80
87
  GTK also provides built-in classes like `suggested-action`, `destructive-action`, `card`, and `heading`.
@@ -90,7 +97,9 @@ afterEach(() => cleanup());
90
97
  test("increments count", async () => {
91
98
  await render(<App />);
92
99
 
93
- const button = await screen.findByRole(AccessibleRole.BUTTON, { name: "Increment" });
100
+ const button = await screen.findByRole(AccessibleRole.BUTTON, {
101
+ name: "Increment",
102
+ });
94
103
  await userEvent.click(button);
95
104
 
96
105
  await screen.findByText("Count: 1");
@@ -103,10 +112,10 @@ User events: `click`, `dblClick`, `type`, `clear`, `tab`, `selectOptions`
103
112
 
104
113
  ## Examples
105
114
 
106
- | Example | Description |
107
- | ------- | ----------- |
108
- | [gtk4-demo](examples/gtk4-demo) | Widget showcase |
109
- | [todo](examples/todo) | Todo app with tests |
115
+ | Example | Description |
116
+ | ------------------------------- | ------------------- |
117
+ | [gtk4-demo](examples/gtk4-demo) | Widget showcase |
118
+ | [todo](examples/todo) | Todo app with tests |
110
119
 
111
120
  ```bash
112
121
  cd examples/gtk4-demo && pnpm dev
@@ -114,15 +123,15 @@ cd examples/gtk4-demo && pnpm dev
114
123
 
115
124
  ## Packages
116
125
 
117
- | Package | Description |
118
- | ------- | ----------- |
119
- | [@gtkx/cli](packages/cli) | CLI with HMR dev server |
120
- | [@gtkx/react](packages/react) | React reconciler and JSX components |
121
- | [@gtkx/ffi](packages/ffi) | TypeScript bindings for GTK4/GLib/GIO |
122
- | [@gtkx/native](packages/native) | Rust native module (libffi bridge) |
123
- | [@gtkx/css](packages/css) | CSS-in-JS styling |
124
- | [@gtkx/testing](packages/testing) | Testing utilities |
125
- | [@gtkx/gir](packages/gir) | GObject Introspection parser |
126
+ | Package | Description |
127
+ | --------------------------------- | ------------------------------------- |
128
+ | [@gtkx/cli](packages/cli) | CLI with HMR dev server |
129
+ | [@gtkx/react](packages/react) | React reconciler and JSX components |
130
+ | [@gtkx/ffi](packages/ffi) | TypeScript bindings for GTK4/GLib/GIO |
131
+ | [@gtkx/native](packages/native) | Rust native module (libffi bridge) |
132
+ | [@gtkx/css](packages/css) | CSS-in-JS styling |
133
+ | [@gtkx/testing](packages/testing) | Testing utilities |
134
+ | [@gtkx/gir](packages/gir) | GObject Introspection parser |
126
135
 
127
136
  ## Requirements
128
137
 
@@ -136,7 +145,6 @@ We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
136
145
 
137
146
  - [Report a bug](https://github.com/eugeniodepalo/gtkx/issues/new?template=bug_report.md)
138
147
  - [Request a feature](https://github.com/eugeniodepalo/gtkx/issues/new?template=feature_request.md)
139
- - [Read the Code of Conduct](CODE_OF_CONDUCT.md)
140
148
 
141
149
  ## License
142
150
 
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { fireEvent } from "./fire-event.js";
2
2
  export { findAllByLabelText, findAllByRole, findAllByTestId, findAllByText, findByLabelText, findByRole, findByTestId, findByText, } from "./queries.js";
3
3
  export { cleanup, render, teardown } from "./render.js";
4
4
  export { screen } from "./screen.js";
5
- export type { BoundQueries, ByRoleOptions, RenderOptions, RenderResult, TextMatchOptions, WaitForOptions, } from "./types.js";
5
+ export type { BoundQueries, ByRoleOptions, NormalizerOptions, RenderOptions, RenderResult, TextMatch, TextMatchFunction, TextMatchOptions, WaitForOptions, } from "./types.js";
6
6
  export type { TabOptions } from "./user-event.js";
7
7
  export { userEvent } from "./user-event.js";
8
8
  export { waitFor, waitForElementToBeRemoved } from "./wait-for.js";
package/dist/queries.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type * as Gtk from "@gtkx/ffi/gtk";
2
2
  import { AccessibleRole } from "@gtkx/ffi/gtk";
3
- import type { ByRoleOptions, TextMatchOptions } from "./types.js";
3
+ import type { ByRoleOptions, TextMatch, TextMatchOptions } from "./types.js";
4
4
  type Container = Gtk.Application | Gtk.Widget;
5
5
  /**
6
6
  * Waits for and finds a single widget matching the specified accessible role.
@@ -21,49 +21,49 @@ export declare const findAllByRole: (container: Container, role: AccessibleRole,
21
21
  /**
22
22
  * Waits for and finds a single widget matching the specified label text.
23
23
  * @param container - The container to search within
24
- * @param text - The text or pattern to match
24
+ * @param text - The text, pattern, or matcher function
25
25
  * @param options - Text matching options (exact, normalizer, timeout)
26
26
  * @returns Promise resolving to the matching widget
27
27
  */
28
- export declare const findByLabelText: (container: Container, text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget>;
28
+ export declare const findByLabelText: (container: Container, text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
29
29
  /**
30
30
  * Waits for and finds all widgets matching the specified label text.
31
31
  * @param container - The container to search within
32
- * @param text - The text or pattern to match
32
+ * @param text - The text, pattern, or matcher function
33
33
  * @param options - Text matching options (exact, normalizer, timeout)
34
34
  * @returns Promise resolving to array of matching widgets
35
35
  */
36
- export declare const findAllByLabelText: (container: Container, text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
36
+ export declare const findAllByLabelText: (container: Container, text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
37
37
  /**
38
38
  * Waits for and finds a single widget matching the specified text content.
39
39
  * @param container - The container to search within
40
- * @param text - The text or pattern to match
40
+ * @param text - The text, pattern, or matcher function
41
41
  * @param options - Text matching options (exact, normalizer, timeout)
42
42
  * @returns Promise resolving to the matching widget
43
43
  */
44
- export declare const findByText: (container: Container, text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget>;
44
+ export declare const findByText: (container: Container, text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
45
45
  /**
46
46
  * Waits for and finds all widgets matching the specified text content.
47
47
  * @param container - The container to search within
48
- * @param text - The text or pattern to match
48
+ * @param text - The text, pattern, or matcher function
49
49
  * @param options - Text matching options (exact, normalizer, timeout)
50
50
  * @returns Promise resolving to array of matching widgets
51
51
  */
52
- export declare const findAllByText: (container: Container, text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
52
+ export declare const findAllByText: (container: Container, text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
53
53
  /**
54
54
  * Waits for and finds a single widget matching the specified test ID.
55
55
  * @param container - The container to search within
56
- * @param testId - The test ID or pattern to match
56
+ * @param testId - The test ID, pattern, or matcher function
57
57
  * @param options - Text matching options (exact, normalizer, timeout)
58
58
  * @returns Promise resolving to the matching widget
59
59
  */
60
- export declare const findByTestId: (container: Container, testId: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget>;
60
+ export declare const findByTestId: (container: Container, testId: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
61
61
  /**
62
62
  * Waits for and finds all widgets matching the specified test ID.
63
63
  * @param container - The container to search within
64
- * @param testId - The test ID or pattern to match
64
+ * @param testId - The test ID, pattern, or matcher function
65
65
  * @param options - Text matching options (exact, normalizer, timeout)
66
66
  * @returns Promise resolving to array of matching widgets
67
67
  */
68
- export declare const findAllByTestId: (container: Container, testId: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
68
+ export declare const findAllByTestId: (container: Container, testId: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
69
69
  export {};
package/dist/queries.js CHANGED
@@ -1,22 +1,42 @@
1
1
  import { getInterface } from "@gtkx/ffi";
2
- import { Accessible, AccessibleRole, Button, CheckButton, Editable, Expander, Frame, Label, StackPage, Switch, ToggleButton, Window, } from "@gtkx/ffi/gtk";
2
+ import { Accessible, AccessibleRole, Button, CheckButton, Editable, Expander, Frame, Label, MenuButton, StackPage, Switch, ToggleButton, Window, } from "@gtkx/ffi/gtk";
3
3
  import { findAll } from "./traversal.js";
4
4
  import { waitFor } from "./wait-for.js";
5
- const DEFAULT_NORMALIZER = (text) => text.trim().replace(/\s+/g, " ");
5
+ const buildNormalizer = (options) => {
6
+ if (options?.normalizer) {
7
+ return options.normalizer;
8
+ }
9
+ const trim = options?.trim ?? true;
10
+ const collapseWhitespace = options?.collapseWhitespace ?? true;
11
+ return (text) => {
12
+ let result = text;
13
+ if (trim) {
14
+ result = result.trim();
15
+ }
16
+ if (collapseWhitespace) {
17
+ result = result.replace(/\s+/g, " ");
18
+ }
19
+ return result;
20
+ };
21
+ };
6
22
  const normalizeText = (text, options) => {
7
- const normalizer = options?.normalizer ?? DEFAULT_NORMALIZER;
23
+ const normalizer = buildNormalizer(options);
8
24
  return normalizer(text);
9
25
  };
10
- const matchText = (actual, expected, options) => {
26
+ const matchText = (actual, expected, widget, options) => {
11
27
  if (actual === null)
12
28
  return false;
13
29
  const normalizedActual = normalizeText(actual, options);
14
- const exact = options?.exact ?? true;
15
- if (typeof expected === "string") {
16
- const normalizedExpected = normalizeText(expected, options);
17
- return exact ? normalizedActual === normalizedExpected : normalizedActual.includes(normalizedExpected);
30
+ if (typeof expected === "function") {
31
+ return expected(normalizedActual, widget);
32
+ }
33
+ if (expected instanceof RegExp) {
34
+ expected.lastIndex = 0;
35
+ return expected.test(normalizedActual);
18
36
  }
19
- return expected.test(normalizedActual);
37
+ const normalizedExpected = normalizeText(expected, options);
38
+ const exact = options?.exact ?? true;
39
+ return exact ? normalizedActual === normalizedExpected : normalizedActual.includes(normalizedExpected);
20
40
  };
21
41
  const ROLES_WITH_INTERNAL_LABELS = new Set([
22
42
  AccessibleRole.BUTTON,
@@ -31,54 +51,68 @@ const ROLES_WITH_INTERNAL_LABELS = new Set([
31
51
  ]);
32
52
  const isInternalLabel = (widget) => {
33
53
  const accessible = getInterface(widget, Accessible);
34
- if (accessible.getAccessibleRole() !== AccessibleRole.LABEL)
54
+ if (!accessible || accessible.getAccessibleRole() !== AccessibleRole.LABEL)
35
55
  return false;
36
56
  const parent = widget.getParent();
37
57
  if (!parent)
38
58
  return false;
39
- const parentRole = getInterface(parent, Accessible).getAccessibleRole();
40
- return ROLES_WITH_INTERNAL_LABELS.has(parentRole);
59
+ const parentAccessible = getInterface(parent, Accessible);
60
+ if (!parentAccessible)
61
+ return false;
62
+ return ROLES_WITH_INTERNAL_LABELS.has(parentAccessible.getAccessibleRole());
63
+ };
64
+ const collectChildLabels = (widget) => {
65
+ const labels = [];
66
+ let child = widget.getFirstChild();
67
+ while (child) {
68
+ const childAccessible = getInterface(child, Accessible);
69
+ if (childAccessible?.getAccessibleRole() === AccessibleRole.LABEL) {
70
+ const labelText = getInterface(child, Label)?.getLabel();
71
+ if (labelText)
72
+ labels.push(labelText);
73
+ }
74
+ labels.push(...collectChildLabels(child));
75
+ child = child.getNextSibling();
76
+ }
77
+ return labels;
41
78
  };
42
79
  const getWidgetText = (widget) => {
43
80
  if (isInternalLabel(widget))
44
81
  return null;
45
- const role = getInterface(widget, Accessible).getAccessibleRole();
82
+ const role = getInterface(widget, Accessible)?.getAccessibleRole();
83
+ if (role === undefined)
84
+ return null;
46
85
  switch (role) {
47
86
  case AccessibleRole.BUTTON:
48
87
  case AccessibleRole.LINK:
49
- case AccessibleRole.TAB:
50
- return getInterface(widget, Button).getLabel();
88
+ case AccessibleRole.TAB: {
89
+ const directLabel = getInterface(widget, Button)?.getLabel() ??
90
+ getInterface(widget, MenuButton)?.getLabel() ??
91
+ getInterface(widget, Expander)?.getLabel();
92
+ if (directLabel)
93
+ return directLabel;
94
+ const childLabels = collectChildLabels(widget);
95
+ return childLabels.length > 0 ? childLabels.join(" ") : null;
96
+ }
51
97
  case AccessibleRole.TOGGLE_BUTTON:
52
- return getInterface(widget, ToggleButton).getLabel();
98
+ return getInterface(widget, ToggleButton)?.getLabel() ?? null;
53
99
  case AccessibleRole.CHECKBOX:
54
100
  case AccessibleRole.RADIO:
55
- return getInterface(widget, CheckButton).getLabel();
101
+ return getInterface(widget, CheckButton)?.getLabel() ?? null;
56
102
  case AccessibleRole.LABEL:
57
- return getInterface(widget, Label).getLabel();
103
+ return getInterface(widget, Label)?.getLabel() ?? null;
58
104
  case AccessibleRole.TEXT_BOX:
59
105
  case AccessibleRole.SEARCH_BOX:
60
106
  case AccessibleRole.SPIN_BUTTON:
61
- return getInterface(widget, Editable).getText();
107
+ return getInterface(widget, Editable)?.getText() ?? null;
62
108
  case AccessibleRole.GROUP:
63
- try {
64
- return getInterface(widget, Frame).getLabel();
65
- }
66
- catch {
67
- return null;
68
- }
109
+ return getInterface(widget, Frame)?.getLabel() ?? null;
69
110
  case AccessibleRole.WINDOW:
70
111
  case AccessibleRole.DIALOG:
71
112
  case AccessibleRole.ALERT_DIALOG:
72
- return getInterface(widget, Window).getTitle();
113
+ return getInterface(widget, Window)?.getTitle() ?? null;
73
114
  case AccessibleRole.TAB_PANEL:
74
- try {
75
- return getInterface(widget, StackPage).getTitle();
76
- }
77
- catch {
78
- return null;
79
- }
80
- case AccessibleRole.SWITCH:
81
- return null;
115
+ return getInterface(widget, StackPage)?.getTitle() ?? null;
82
116
  default:
83
117
  return null;
84
118
  }
@@ -87,26 +121,32 @@ const getWidgetTestId = (widget) => {
87
121
  return widget.getName();
88
122
  };
89
123
  const getWidgetCheckedState = (widget) => {
90
- const role = getInterface(widget, Accessible).getAccessibleRole();
124
+ const accessible = getInterface(widget, Accessible);
125
+ if (!accessible)
126
+ return undefined;
127
+ const role = accessible.getAccessibleRole();
91
128
  switch (role) {
92
129
  case AccessibleRole.CHECKBOX:
93
130
  case AccessibleRole.RADIO:
94
- return getInterface(widget, CheckButton).getActive();
131
+ return getInterface(widget, CheckButton)?.getActive();
95
132
  case AccessibleRole.TOGGLE_BUTTON:
96
- return getInterface(widget, ToggleButton).getActive();
133
+ return getInterface(widget, ToggleButton)?.getActive();
97
134
  case AccessibleRole.SWITCH:
98
- return getInterface(widget, Switch).getActive();
135
+ return getInterface(widget, Switch)?.getActive();
99
136
  default:
100
137
  return undefined;
101
138
  }
102
139
  };
103
140
  const getWidgetExpandedState = (widget) => {
104
- const role = getInterface(widget, Accessible).getAccessibleRole();
141
+ const accessible = getInterface(widget, Accessible);
142
+ if (!accessible)
143
+ return undefined;
144
+ const role = accessible.getAccessibleRole();
105
145
  if (role === AccessibleRole.BUTTON) {
106
146
  const parent = widget.getParent();
107
147
  if (!parent)
108
148
  return undefined;
109
- return getInterface(parent, Expander).getExpanded();
149
+ return getInterface(parent, Expander)?.getExpanded();
110
150
  }
111
151
  return undefined;
112
152
  };
@@ -115,7 +155,7 @@ const matchByRoleOptions = (widget, options) => {
115
155
  return true;
116
156
  if (options.name !== undefined) {
117
157
  const text = getWidgetText(widget);
118
- if (!matchText(text, options.name, options))
158
+ if (!matchText(text, options.name, widget, options))
119
159
  return false;
120
160
  }
121
161
  if (options.checked !== undefined) {
@@ -149,7 +189,8 @@ const formatByRoleError = (role, options) => {
149
189
  };
150
190
  const getAllByRole = (container, role, options) => {
151
191
  const matches = findAll(container, (node) => {
152
- if (getInterface(node, Accessible).getAccessibleRole() !== role)
192
+ const accessible = getInterface(node, Accessible);
193
+ if (!accessible || accessible.getAccessibleRole() !== role)
153
194
  return false;
154
195
  return matchByRoleOptions(node, options);
155
196
  });
@@ -171,7 +212,7 @@ const getByRole = (container, role, options) => {
171
212
  const getAllByLabelText = (container, text, options) => {
172
213
  const matches = findAll(container, (node) => {
173
214
  const widgetText = getWidgetText(node);
174
- return matchText(widgetText, text, options);
215
+ return matchText(widgetText, text, node, options);
175
216
  });
176
217
  if (matches.length === 0) {
177
218
  throw new Error(`Unable to find any elements with label text "${text}"`);
@@ -191,7 +232,7 @@ const getByLabelText = (container, text, options) => {
191
232
  const getAllByText = (container, text, options) => {
192
233
  const matches = findAll(container, (node) => {
193
234
  const widgetText = getWidgetText(node);
194
- return matchText(widgetText, text, options);
235
+ return matchText(widgetText, text, node, options);
195
236
  });
196
237
  if (matches.length === 0) {
197
238
  throw new Error(`Unable to find any elements with text "${text}"`);
@@ -211,7 +252,7 @@ const getByText = (container, text, options) => {
211
252
  const getAllByTestId = (container, testId, options) => {
212
253
  const matches = findAll(container, (node) => {
213
254
  const widgetTestId = getWidgetTestId(node);
214
- return matchText(widgetTestId, testId, options);
255
+ return matchText(widgetTestId, testId, node, options);
215
256
  });
216
257
  if (matches.length === 0) {
217
258
  throw new Error(`Unable to find any elements with test id "${testId}"`);
@@ -235,7 +276,9 @@ const getByTestId = (container, testId, options) => {
235
276
  * @param options - Additional filtering options (name, checked, expanded)
236
277
  * @returns Promise resolving to the matching widget
237
278
  */
238
- export const findByRole = async (container, role, options) => waitFor(() => getByRole(container, role, options), { timeout: options?.timeout });
279
+ export const findByRole = async (container, role, options) => waitFor(() => getByRole(container, role, options), {
280
+ timeout: options?.timeout,
281
+ });
239
282
  /**
240
283
  * Waits for and finds all widgets matching the specified accessible role.
241
284
  * @param container - The container to search within
@@ -243,52 +286,66 @@ export const findByRole = async (container, role, options) => waitFor(() => getB
243
286
  * @param options - Additional filtering options (name, checked, expanded)
244
287
  * @returns Promise resolving to array of matching widgets
245
288
  */
246
- export const findAllByRole = async (container, role, options) => waitFor(() => getAllByRole(container, role, options), { timeout: options?.timeout });
289
+ export const findAllByRole = async (container, role, options) => waitFor(() => getAllByRole(container, role, options), {
290
+ timeout: options?.timeout,
291
+ });
247
292
  /**
248
293
  * Waits for and finds a single widget matching the specified label text.
249
294
  * @param container - The container to search within
250
- * @param text - The text or pattern to match
295
+ * @param text - The text, pattern, or matcher function
251
296
  * @param options - Text matching options (exact, normalizer, timeout)
252
297
  * @returns Promise resolving to the matching widget
253
298
  */
254
- export const findByLabelText = async (container, text, options) => waitFor(() => getByLabelText(container, text, options), { timeout: options?.timeout });
299
+ export const findByLabelText = async (container, text, options) => waitFor(() => getByLabelText(container, text, options), {
300
+ timeout: options?.timeout,
301
+ });
255
302
  /**
256
303
  * Waits for and finds all widgets matching the specified label text.
257
304
  * @param container - The container to search within
258
- * @param text - The text or pattern to match
305
+ * @param text - The text, pattern, or matcher function
259
306
  * @param options - Text matching options (exact, normalizer, timeout)
260
307
  * @returns Promise resolving to array of matching widgets
261
308
  */
262
- export const findAllByLabelText = async (container, text, options) => waitFor(() => getAllByLabelText(container, text, options), { timeout: options?.timeout });
309
+ export const findAllByLabelText = async (container, text, options) => waitFor(() => getAllByLabelText(container, text, options), {
310
+ timeout: options?.timeout,
311
+ });
263
312
  /**
264
313
  * Waits for and finds a single widget matching the specified text content.
265
314
  * @param container - The container to search within
266
- * @param text - The text or pattern to match
315
+ * @param text - The text, pattern, or matcher function
267
316
  * @param options - Text matching options (exact, normalizer, timeout)
268
317
  * @returns Promise resolving to the matching widget
269
318
  */
270
- export const findByText = async (container, text, options) => waitFor(() => getByText(container, text, options), { timeout: options?.timeout });
319
+ export const findByText = async (container, text, options) => waitFor(() => getByText(container, text, options), {
320
+ timeout: options?.timeout,
321
+ });
271
322
  /**
272
323
  * Waits for and finds all widgets matching the specified text content.
273
324
  * @param container - The container to search within
274
- * @param text - The text or pattern to match
325
+ * @param text - The text, pattern, or matcher function
275
326
  * @param options - Text matching options (exact, normalizer, timeout)
276
327
  * @returns Promise resolving to array of matching widgets
277
328
  */
278
- export const findAllByText = async (container, text, options) => waitFor(() => getAllByText(container, text, options), { timeout: options?.timeout });
329
+ export const findAllByText = async (container, text, options) => waitFor(() => getAllByText(container, text, options), {
330
+ timeout: options?.timeout,
331
+ });
279
332
  /**
280
333
  * Waits for and finds a single widget matching the specified test ID.
281
334
  * @param container - The container to search within
282
- * @param testId - The test ID or pattern to match
335
+ * @param testId - The test ID, pattern, or matcher function
283
336
  * @param options - Text matching options (exact, normalizer, timeout)
284
337
  * @returns Promise resolving to the matching widget
285
338
  */
286
- export const findByTestId = async (container, testId, options) => waitFor(() => getByTestId(container, testId, options), { timeout: options?.timeout });
339
+ export const findByTestId = async (container, testId, options) => waitFor(() => getByTestId(container, testId, options), {
340
+ timeout: options?.timeout,
341
+ });
287
342
  /**
288
343
  * Waits for and finds all widgets matching the specified test ID.
289
344
  * @param container - The container to search within
290
- * @param testId - The test ID or pattern to match
345
+ * @param testId - The test ID, pattern, or matcher function
291
346
  * @param options - Text matching options (exact, normalizer, timeout)
292
347
  * @returns Promise resolving to array of matching widgets
293
348
  */
294
- export const findAllByTestId = async (container, testId, options) => waitFor(() => getAllByTestId(container, testId, options), { timeout: options?.timeout });
349
+ export const findAllByTestId = async (container, testId, options) => waitFor(() => getAllByTestId(container, testId, options), {
350
+ timeout: options?.timeout,
351
+ });
package/dist/render.js CHANGED
@@ -11,15 +11,19 @@ let container = null;
11
11
  const getWidgetLabel = (widget) => {
12
12
  if (!hasLabel(widget))
13
13
  return null;
14
- const role = getInterface(widget, Gtk.Accessible).getAccessibleRole();
14
+ const accessible = getInterface(widget, Gtk.Accessible);
15
+ if (!accessible)
16
+ return null;
17
+ const role = accessible.getAccessibleRole();
15
18
  if (role === Gtk.AccessibleRole.LABEL) {
16
- return getInterface(widget, Gtk.Label).getLabel();
19
+ return getInterface(widget, Gtk.Label)?.getLabel() ?? null;
17
20
  }
18
- return getInterface(widget, Gtk.Button).getLabel();
21
+ return getInterface(widget, Gtk.Button)?.getLabel() ?? null;
19
22
  };
20
23
  const printWidgetTree = (root, indent = 0) => {
21
24
  const prefix = " ".repeat(indent);
22
- const role = Gtk.AccessibleRole[getInterface(root, Gtk.Accessible).getAccessibleRole()] ?? "UNKNOWN";
25
+ const accessibleRole = getInterface(root, Gtk.Accessible)?.getAccessibleRole();
26
+ const role = accessibleRole !== undefined ? (Gtk.AccessibleRole[accessibleRole] ?? "UNKNOWN") : "UNKNOWN";
23
27
  const labelText = getWidgetLabel(root);
24
28
  const label = labelText ? ` label="${labelText}"` : "";
25
29
  let result = `${prefix}<${root.constructor.name} role=${role}${label}>\n`;
package/dist/screen.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type * as Gtk from "@gtkx/ffi/gtk";
2
2
  import type { AccessibleRole } from "@gtkx/ffi/gtk";
3
- import type { ByRoleOptions, TextMatchOptions } from "./types.js";
3
+ import type { ByRoleOptions, TextMatch, TextMatchOptions } from "./types.js";
4
4
  /**
5
5
  * Sets the root application for screen queries. Called internally by render().
6
6
  * @param root - The GTK application to use as query root, or null to clear
@@ -13,12 +13,12 @@ export declare const setScreenRoot: (root: Gtk.Application | null) => void;
13
13
  */
14
14
  export declare const screen: {
15
15
  findByRole: (role: AccessibleRole, options?: ByRoleOptions) => Promise<Gtk.Widget>;
16
- findByLabelText: (text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget>;
17
- findByText: (text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget>;
18
- findByTestId: (testId: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget>;
16
+ findByLabelText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
17
+ findByText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
18
+ findByTestId: (testId: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
19
19
  findAllByRole: (role: AccessibleRole, options?: ByRoleOptions) => Promise<Gtk.Widget[]>;
20
- findAllByLabelText: (text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
21
- findAllByText: (text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
22
- findAllByTestId: (testId: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
20
+ findAllByLabelText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
21
+ findAllByText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
22
+ findAllByTestId: (testId: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
23
23
  debug: () => void;
24
24
  };
package/dist/types.d.ts CHANGED
@@ -1,14 +1,40 @@
1
1
  import type * as Gtk from "@gtkx/ffi/gtk";
2
2
  import type { AccessibleRole } from "@gtkx/ffi/gtk";
3
3
  import type { ComponentType, ReactNode } from "react";
4
+ /**
5
+ * A function that receives the text content and widget, returning true for a match.
6
+ * Matches React Testing Library's function matcher signature.
7
+ */
8
+ export type TextMatchFunction = (content: string, widget: Gtk.Widget) => boolean;
9
+ /**
10
+ * Flexible text matching: string for exact/partial, RegExp for patterns, or function for custom logic.
11
+ * Matches React Testing Library's TextMatch type.
12
+ */
13
+ export type TextMatch = string | RegExp | TextMatchFunction;
14
+ /**
15
+ * Options for normalizing text before comparison.
16
+ */
17
+ export interface NormalizerOptions {
18
+ /** Whether to trim whitespace from text. Defaults to true. */
19
+ trim?: boolean;
20
+ /** Whether to collapse multiple whitespace into single spaces. Defaults to true. */
21
+ collapseWhitespace?: boolean;
22
+ }
4
23
  /**
5
24
  * Options for text matching in queries.
6
25
  */
7
26
  export interface TextMatchOptions {
8
27
  /** Whether to match the entire string exactly. Defaults to true. */
9
28
  exact?: boolean;
10
- /** Custom function to normalize text before comparison. */
29
+ /**
30
+ * Custom function to normalize text before comparison.
31
+ * Cannot be used with trim/collapseWhitespace options.
32
+ */
11
33
  normalizer?: (text: string) => string;
34
+ /** Whether to trim whitespace from text. Defaults to true. */
35
+ trim?: boolean;
36
+ /** Whether to collapse multiple whitespace into single spaces. Defaults to true. */
37
+ collapseWhitespace?: boolean;
12
38
  /** Maximum time in milliseconds to wait for a match. */
13
39
  timeout?: number;
14
40
  }
@@ -16,8 +42,8 @@ export interface TextMatchOptions {
16
42
  * Options for querying elements by their accessible role.
17
43
  */
18
44
  export interface ByRoleOptions extends TextMatchOptions {
19
- /** Filter by the element's accessible name. */
20
- name?: string | RegExp;
45
+ /** Filter by the element's accessible name. Supports string, RegExp, or function matcher. */
46
+ name?: TextMatch;
21
47
  /** Filter checkboxes/switches by checked state. */
22
48
  checked?: boolean;
23
49
  /** Filter toggle buttons by pressed state. */
@@ -62,19 +88,19 @@ export interface BoundQueries {
62
88
  /** Find a single element by its accessible role. */
63
89
  findByRole: (role: AccessibleRole, options?: ByRoleOptions) => Promise<Gtk.Widget>;
64
90
  /** Find a single element by its associated label text. */
65
- findByLabelText: (text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget>;
91
+ findByLabelText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
66
92
  /** Find a single element by its text content. */
67
- findByText: (text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget>;
93
+ findByText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
68
94
  /** Find a single element by its test ID. */
69
- findByTestId: (testId: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget>;
95
+ findByTestId: (testId: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
70
96
  /** Find all elements matching an accessible role. */
71
97
  findAllByRole: (role: AccessibleRole, options?: ByRoleOptions) => Promise<Gtk.Widget[]>;
72
98
  /** Find all elements with matching label text. */
73
- findAllByLabelText: (text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
99
+ findAllByLabelText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
74
100
  /** Find all elements with matching text content. */
75
- findAllByText: (text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
101
+ findAllByText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
76
102
  /** Find all elements with matching test ID. */
77
- findAllByTestId: (testId: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
103
+ findAllByTestId: (testId: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
78
104
  }
79
105
  /**
80
106
  * The result returned by the render function. Includes query methods
@@ -3,18 +3,29 @@ import { Accessible, AccessibleRole, DirectionType, Editable, Widget, } from "@g
3
3
  import { fireEvent } from "./fire-event.js";
4
4
  import { tick } from "./timing.js";
5
5
  import { isEditable } from "./widget.js";
6
- const TOGGLEABLE_ROLES = new Set([AccessibleRole.CHECKBOX, AccessibleRole.RADIO, AccessibleRole.TOGGLE_BUTTON]);
6
+ const TOGGLEABLE_ROLES = new Set([
7
+ AccessibleRole.CHECKBOX,
8
+ AccessibleRole.RADIO,
9
+ AccessibleRole.TOGGLE_BUTTON,
10
+ AccessibleRole.SWITCH,
11
+ ]);
7
12
  const isToggleable = (widget) => {
8
- const role = getInterface(widget, Accessible).getAccessibleRole();
9
- return TOGGLEABLE_ROLES.has(role);
13
+ const accessible = getInterface(widget, Accessible);
14
+ if (!accessible)
15
+ return false;
16
+ return TOGGLEABLE_ROLES.has(accessible.getAccessibleRole());
10
17
  };
11
18
  const click = async (element) => {
12
19
  if (isToggleable(element)) {
13
- const role = getInterface(element, Accessible).getAccessibleRole();
20
+ const role = getInterface(element, Accessible)?.getAccessibleRole();
14
21
  if (role === AccessibleRole.CHECKBOX || role === AccessibleRole.RADIO) {
15
22
  const checkButton = element;
16
23
  checkButton.setActive(!checkButton.getActive());
17
24
  }
25
+ else if (role === AccessibleRole.SWITCH) {
26
+ const switchWidget = element;
27
+ switchWidget.setActive(!switchWidget.getActive());
28
+ }
18
29
  else {
19
30
  const toggleButton = element;
20
31
  toggleButton.setActive(!toggleButton.getActive());
@@ -42,7 +53,7 @@ const tab = async (element, options) => {
42
53
  const direction = options?.shift ? DirectionType.TAB_BACKWARD : DirectionType.TAB_FORWARD;
43
54
  const root = element.getRoot();
44
55
  if (root) {
45
- getInterface(root, Widget).childFocus(direction);
56
+ getInterface(root, Widget)?.childFocus(direction);
46
57
  }
47
58
  await tick();
48
59
  };
@@ -51,6 +62,8 @@ const type = async (element, text) => {
51
62
  throw new Error("Cannot type into element: element is not editable (TEXT_BOX, SEARCH_BOX, or SPIN_BUTTON)");
52
63
  }
53
64
  const editable = getInterface(element, Editable);
65
+ if (!editable)
66
+ return;
54
67
  const currentText = editable.getText();
55
68
  editable.setText(currentText + text);
56
69
  await tick();
@@ -59,19 +72,21 @@ const clear = async (element) => {
59
72
  if (!isEditable(element)) {
60
73
  throw new Error("Cannot clear element: element is not editable (TEXT_BOX, SEARCH_BOX, or SPIN_BUTTON)");
61
74
  }
62
- getInterface(element, Editable).setText("");
75
+ getInterface(element, Editable)?.setText("");
63
76
  await tick();
64
77
  };
65
78
  const SELECTABLE_ROLES = new Set([AccessibleRole.COMBO_BOX, AccessibleRole.LIST]);
66
79
  const isSelectable = (widget) => {
67
- const role = getInterface(widget, Accessible).getAccessibleRole();
68
- return SELECTABLE_ROLES.has(role);
80
+ const accessible = getInterface(widget, Accessible);
81
+ if (!accessible)
82
+ return false;
83
+ return SELECTABLE_ROLES.has(accessible.getAccessibleRole());
69
84
  };
70
85
  const selectOptions = async (element, values) => {
71
86
  if (!isSelectable(element)) {
72
87
  throw new Error("Cannot select options: element is not a selectable widget (COMBO_BOX or LIST)");
73
88
  }
74
- const role = getInterface(element, Accessible).getAccessibleRole();
89
+ const role = getInterface(element, Accessible)?.getAccessibleRole();
75
90
  const valueArray = Array.isArray(values) ? values : [values];
76
91
  if (role === AccessibleRole.COMBO_BOX) {
77
92
  if (valueArray.length > 1) {
@@ -98,13 +113,14 @@ const selectOptions = async (element, values) => {
98
113
  const row = listBox.getRowAtIndex(value);
99
114
  if (row) {
100
115
  listBox.selectRow(row);
116
+ row.activate();
101
117
  }
102
118
  }
103
119
  }
104
120
  await tick();
105
121
  };
106
122
  const deselectOptions = async (element, values) => {
107
- const role = getInterface(element, Accessible).getAccessibleRole();
123
+ const role = getInterface(element, Accessible)?.getAccessibleRole();
108
124
  if (role !== AccessibleRole.LIST) {
109
125
  throw new Error("Cannot deselect options: only ListBox supports deselection");
110
126
  }
package/dist/widget.js CHANGED
@@ -5,8 +5,10 @@ const EDITABLE_ROLES = new Set([AccessibleRole.TEXT_BOX, AccessibleRole.SEARCH_B
5
5
  * Checks if a widget has an editable accessible role (text box, search box, or spin button).
6
6
  */
7
7
  export const isEditable = (widget) => {
8
- const role = getInterface(widget, Accessible).getAccessibleRole();
9
- return EDITABLE_ROLES.has(role);
8
+ const accessible = getInterface(widget, Accessible);
9
+ if (!accessible)
10
+ return false;
11
+ return EDITABLE_ROLES.has(accessible.getAccessibleRole());
10
12
  };
11
13
  const LABEL_ROLES = new Set([
12
14
  AccessibleRole.BUTTON,
@@ -22,6 +24,8 @@ const LABEL_ROLES = new Set([
22
24
  * Checks if a widget has an accessible role that supports labels.
23
25
  */
24
26
  export const hasLabel = (widget) => {
25
- const role = getInterface(widget, Accessible).getAccessibleRole();
26
- return LABEL_ROLES.has(role);
27
+ const accessible = getInterface(widget, Accessible);
28
+ if (!accessible)
29
+ return false;
30
+ return LABEL_ROLES.has(accessible.getAccessibleRole());
27
31
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtkx/testing",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Testing utilities for GTKX applications",
5
5
  "keywords": [
6
6
  "gtk",
@@ -32,9 +32,9 @@
32
32
  "dist"
33
33
  ],
34
34
  "dependencies": {
35
- "@gtkx/react": "0.3.4",
36
- "@gtkx/ffi": "0.3.4",
37
- "@gtkx/native": "0.3.4"
35
+ "@gtkx/ffi": "0.4.0",
36
+ "@gtkx/native": "0.4.0",
37
+ "@gtkx/react": "0.4.0"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsc -b && cp ../../README.md .",