@gtkx/testing 0.1.48 → 0.1.50

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
@@ -159,7 +159,9 @@ Query types: `ByRole`, `ByText`, `ByLabelText`, `ByTestId`
159
159
  - `userEvent.activate(element)` - Activate element (e.g., press Enter in input)
160
160
  - `userEvent.type(element, text)` - Type text into input
161
161
  - `userEvent.clear(element)` - Clear input text
162
- - `userEvent.setup()` - Create reusable instance
162
+ - `userEvent.tab(element, options?)` - Simulate Tab navigation
163
+ - `userEvent.selectOptions(element, values)` - Select options in ComboBox/ListBox
164
+ - `userEvent.deselectOptions(element, values)` - Deselect options in ListBox
163
165
 
164
166
  **Low-level Events**:
165
167
  - `fireEvent(element, signalName, ...args)` - Emit any GTK signal with optional arguments
@@ -170,14 +172,6 @@ Query types: `ByRole`, `ByText`, `ByLabelText`, `ByTestId`
170
172
 
171
173
  ## Examples
172
174
 
173
- ### Counter
174
-
175
- A minimal counter app demonstrating state management:
176
-
177
- ```bash
178
- turbo start --filter=counter-example
179
- ```
180
-
181
175
  ### GTK4 Demo
182
176
 
183
177
  A comprehensive showcase of GTK4 widgets and features:
@@ -186,12 +180,13 @@ A comprehensive showcase of GTK4 widgets and features:
186
180
  turbo start --filter=gtk4-demo
187
181
  ```
188
182
 
189
- ### List Example
183
+ ### Todo App
190
184
 
191
- Comprehensive showcase of ListView, GridView, and ColumnView with sorting:
185
+ A todo app demonstrating `@gtkx/testing` with realistic component tests:
192
186
 
193
187
  ```bash
194
- turbo start --filter=list-example
188
+ turbo start --filter=todo
189
+ turbo test --filter=todo
195
190
  ```
196
191
 
197
192
  ## Packages
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { findAllByLabelText, findAllByRole, findAllByTestId, findAllByText, find
3
3
  export { cleanup, render, teardown } from "./render.js";
4
4
  export { screen } from "./screen.js";
5
5
  export type { BoundQueries, ByRoleOptions, RenderOptions, RenderResult, TextMatchOptions, WaitForOptions, } from "./types.js";
6
- export type { UserEventInstance, UserEventOptions } from "./user-event.js";
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";
9
9
  export { within } from "./within.js";
package/dist/queries.d.ts CHANGED
@@ -22,7 +22,7 @@ export declare const findAllByRole: (container: Container, role: AccessibleRole,
22
22
  * Waits for and finds a single widget matching the specified label text.
23
23
  * @param container - The container to search within
24
24
  * @param text - The text or pattern to match
25
- * @param options - Text matching options (exact, normalizer)
25
+ * @param options - Text matching options (exact, normalizer, timeout)
26
26
  * @returns Promise resolving to the matching widget
27
27
  */
28
28
  export declare const findByLabelText: (container: Container, text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget>;
@@ -30,7 +30,7 @@ export declare const findByLabelText: (container: Container, text: string | RegE
30
30
  * Waits for and finds all widgets matching the specified label text.
31
31
  * @param container - The container to search within
32
32
  * @param text - The text or pattern to match
33
- * @param options - Text matching options (exact, normalizer)
33
+ * @param options - Text matching options (exact, normalizer, timeout)
34
34
  * @returns Promise resolving to array of matching widgets
35
35
  */
36
36
  export declare const findAllByLabelText: (container: Container, text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
@@ -38,7 +38,7 @@ export declare const findAllByLabelText: (container: Container, text: string | R
38
38
  * Waits for and finds a single widget matching the specified text content.
39
39
  * @param container - The container to search within
40
40
  * @param text - The text or pattern to match
41
- * @param options - Text matching options (exact, normalizer)
41
+ * @param options - Text matching options (exact, normalizer, timeout)
42
42
  * @returns Promise resolving to the matching widget
43
43
  */
44
44
  export declare const findByText: (container: Container, text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget>;
@@ -46,7 +46,7 @@ export declare const findByText: (container: Container, text: string | RegExp, o
46
46
  * Waits for and finds all widgets matching the specified text content.
47
47
  * @param container - The container to search within
48
48
  * @param text - The text or pattern to match
49
- * @param options - Text matching options (exact, normalizer)
49
+ * @param options - Text matching options (exact, normalizer, timeout)
50
50
  * @returns Promise resolving to array of matching widgets
51
51
  */
52
52
  export declare const findAllByText: (container: Container, text: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
@@ -54,7 +54,7 @@ export declare const findAllByText: (container: Container, text: string | RegExp
54
54
  * Waits for and finds a single widget matching the specified test ID.
55
55
  * @param container - The container to search within
56
56
  * @param testId - The test ID or pattern to match
57
- * @param options - Text matching options (exact, normalizer)
57
+ * @param options - Text matching options (exact, normalizer, timeout)
58
58
  * @returns Promise resolving to the matching widget
59
59
  */
60
60
  export declare const findByTestId: (container: Container, testId: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget>;
@@ -62,7 +62,7 @@ export declare const findByTestId: (container: Container, testId: string | RegEx
62
62
  * Waits for and finds all widgets matching the specified test ID.
63
63
  * @param container - The container to search within
64
64
  * @param testId - The test ID or pattern to match
65
- * @param options - Text matching options (exact, normalizer)
65
+ * @param options - Text matching options (exact, normalizer, timeout)
66
66
  * @returns Promise resolving to array of matching widgets
67
67
  */
68
68
  export declare const findAllByTestId: (container: Container, testId: string | RegExp, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
package/dist/queries.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { getObject } from "@gtkx/ffi";
2
- import { AccessibleRole, Button, CheckButton, Expander, Label, ToggleButton } from "@gtkx/ffi/gtk";
2
+ import { AccessibleRole, Button, CheckButton, Editable, Expander, Frame, Label, StackPage, Switch, ToggleButton, Window, } from "@gtkx/ffi/gtk";
3
3
  import { findAll } from "./traversal.js";
4
4
  import { waitFor } from "./wait-for.js";
5
+ import { asAccessible } from "./widget.js";
5
6
  const DEFAULT_NORMALIZER = (text) => text.trim().replace(/\s+/g, " ");
6
7
  const normalizeText = (text, options) => {
7
8
  const normalizer = options?.normalizer ?? DEFAULT_NORMALIZER;
@@ -23,43 +24,70 @@ const asLabel = (widget) => getObject(widget.ptr, Label);
23
24
  const asCheckButton = (widget) => getObject(widget.ptr, CheckButton);
24
25
  const asToggleButton = (widget) => getObject(widget.ptr, ToggleButton);
25
26
  const asExpander = (widget) => getObject(widget.ptr, Expander);
27
+ const asFrame = (widget) => getObject(widget.ptr, Frame);
28
+ const asWindow = (widget) => getObject(widget.ptr, Window);
29
+ const asStackPage = (widget) => getObject(widget.ptr, StackPage);
30
+ const ROLES_WITH_INTERNAL_LABELS = new Set([
31
+ AccessibleRole.BUTTON,
32
+ AccessibleRole.TOGGLE_BUTTON,
33
+ AccessibleRole.CHECKBOX,
34
+ AccessibleRole.RADIO,
35
+ AccessibleRole.MENU_ITEM,
36
+ AccessibleRole.MENU_ITEM_CHECKBOX,
37
+ AccessibleRole.MENU_ITEM_RADIO,
38
+ AccessibleRole.TAB,
39
+ AccessibleRole.LINK,
40
+ ]);
26
41
  const isInternalLabel = (widget) => {
27
- const accessible = widget;
42
+ const accessible = asAccessible(widget);
28
43
  if (accessible.getAccessibleRole() !== AccessibleRole.LABEL)
29
44
  return false;
30
45
  const parent = widget.getParent();
31
46
  if (!parent)
32
47
  return false;
33
- const parentAccessible = parent;
34
- const parentRole = parentAccessible.getAccessibleRole();
35
- return (parentRole === AccessibleRole.BUTTON ||
36
- parentRole === AccessibleRole.TOGGLE_BUTTON ||
37
- parentRole === AccessibleRole.CHECKBOX ||
38
- parentRole === AccessibleRole.RADIO ||
39
- parentRole === AccessibleRole.MENU_ITEM ||
40
- parentRole === AccessibleRole.MENU_ITEM_CHECKBOX ||
41
- parentRole === AccessibleRole.MENU_ITEM_RADIO);
48
+ const parentRole = asAccessible(parent).getAccessibleRole();
49
+ return ROLES_WITH_INTERNAL_LABELS.has(parentRole);
42
50
  };
43
51
  const getWidgetText = (widget) => {
44
52
  if (isInternalLabel(widget))
45
53
  return null;
46
- const accessible = widget;
47
- const role = accessible.getAccessibleRole();
54
+ const role = asAccessible(widget).getAccessibleRole();
48
55
  switch (role) {
49
56
  case AccessibleRole.BUTTON:
57
+ case AccessibleRole.LINK:
58
+ case AccessibleRole.TAB:
59
+ return asButton(widget).getLabel();
50
60
  case AccessibleRole.TOGGLE_BUTTON:
61
+ return asToggleButton(widget).getLabel();
51
62
  case AccessibleRole.CHECKBOX:
52
63
  case AccessibleRole.RADIO:
53
- case AccessibleRole.MENU_ITEM:
54
- case AccessibleRole.MENU_ITEM_CHECKBOX:
55
- case AccessibleRole.MENU_ITEM_RADIO:
56
- return asButton(widget).getLabel();
64
+ return asCheckButton(widget).getLabel();
57
65
  case AccessibleRole.LABEL:
58
66
  return asLabel(widget).getLabel();
59
67
  case AccessibleRole.TEXT_BOX:
60
68
  case AccessibleRole.SEARCH_BOX:
61
69
  case AccessibleRole.SPIN_BUTTON:
62
- return widget.getText();
70
+ return getObject(widget.ptr, Editable).getText();
71
+ case AccessibleRole.GROUP:
72
+ try {
73
+ return asFrame(widget).getLabel();
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ case AccessibleRole.WINDOW:
79
+ case AccessibleRole.DIALOG:
80
+ case AccessibleRole.ALERT_DIALOG:
81
+ return asWindow(widget).getTitle();
82
+ case AccessibleRole.TAB_PANEL:
83
+ try {
84
+ return asStackPage(widget).getTitle();
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ case AccessibleRole.SWITCH:
90
+ return null;
63
91
  default:
64
92
  return null;
65
93
  }
@@ -67,20 +95,23 @@ const getWidgetText = (widget) => {
67
95
  const getWidgetTestId = (widget) => {
68
96
  return widget.getName();
69
97
  };
98
+ const asSwitch = (widget) => getObject(widget.ptr, Switch);
70
99
  const getWidgetCheckedState = (widget) => {
71
- const accessible = widget;
72
- const role = accessible.getAccessibleRole();
73
- if (role === AccessibleRole.CHECKBOX || role === AccessibleRole.RADIO) {
74
- return asCheckButton(widget).getActive();
75
- }
76
- if (role === AccessibleRole.TOGGLE_BUTTON) {
77
- return asToggleButton(widget).getActive();
100
+ const role = asAccessible(widget).getAccessibleRole();
101
+ switch (role) {
102
+ case AccessibleRole.CHECKBOX:
103
+ case AccessibleRole.RADIO:
104
+ return asCheckButton(widget).getActive();
105
+ case AccessibleRole.TOGGLE_BUTTON:
106
+ return asToggleButton(widget).getActive();
107
+ case AccessibleRole.SWITCH:
108
+ return asSwitch(widget).getActive();
109
+ default:
110
+ return undefined;
78
111
  }
79
- return undefined;
80
112
  };
81
113
  const getWidgetExpandedState = (widget) => {
82
- const accessible = widget;
83
- const role = accessible.getAccessibleRole();
114
+ const role = asAccessible(widget).getAccessibleRole();
84
115
  if (role === AccessibleRole.BUTTON) {
85
116
  const parent = widget.getParent();
86
117
  if (!parent)
@@ -128,8 +159,7 @@ const formatByRoleError = (role, options) => {
128
159
  };
129
160
  const getAllByRole = (container, role, options) => {
130
161
  const matches = findAll(container, (node) => {
131
- const accessible = node;
132
- if (accessible.getAccessibleRole() !== role)
162
+ if (asAccessible(node).getAccessibleRole() !== role)
133
163
  return false;
134
164
  return matchByRoleOptions(node, options);
135
165
  });
@@ -143,7 +173,10 @@ const getByRole = (container, role, options) => {
143
173
  if (matches.length > 1) {
144
174
  throw new Error(`Found ${matches.length} elements with ${formatByRoleError(role, options)}`);
145
175
  }
146
- return matches[0];
176
+ const [first] = matches;
177
+ if (!first)
178
+ throw new Error(`Unable to find element with ${formatByRoleError(role, options)}`);
179
+ return first;
147
180
  };
148
181
  const getAllByLabelText = (container, text, options) => {
149
182
  const matches = findAll(container, (node) => {
@@ -160,7 +193,10 @@ const getByLabelText = (container, text, options) => {
160
193
  if (matches.length > 1) {
161
194
  throw new Error(`Found ${matches.length} elements with label text "${text}"`);
162
195
  }
163
- return matches[0];
196
+ const [first] = matches;
197
+ if (!first)
198
+ throw new Error(`Unable to find element with label text "${text}"`);
199
+ return first;
164
200
  };
165
201
  const getAllByText = (container, text, options) => {
166
202
  const matches = findAll(container, (node) => {
@@ -177,7 +213,10 @@ const getByText = (container, text, options) => {
177
213
  if (matches.length > 1) {
178
214
  throw new Error(`Found ${matches.length} elements with text "${text}"`);
179
215
  }
180
- return matches[0];
216
+ const [first] = matches;
217
+ if (!first)
218
+ throw new Error(`Unable to find element with text "${text}"`);
219
+ return first;
181
220
  };
182
221
  const getAllByTestId = (container, testId, options) => {
183
222
  const matches = findAll(container, (node) => {
@@ -194,7 +233,10 @@ const getByTestId = (container, testId, options) => {
194
233
  if (matches.length > 1) {
195
234
  throw new Error(`Found ${matches.length} elements with test id "${testId}"`);
196
235
  }
197
- return matches[0];
236
+ const [first] = matches;
237
+ if (!first)
238
+ throw new Error(`Unable to find element with test id "${testId}"`);
239
+ return first;
198
240
  };
199
241
  /**
200
242
  * Waits for and finds a single widget matching the specified accessible role.
@@ -203,7 +245,7 @@ const getByTestId = (container, testId, options) => {
203
245
  * @param options - Additional filtering options (name, checked, expanded)
204
246
  * @returns Promise resolving to the matching widget
205
247
  */
206
- export const findByRole = async (container, role, options) => waitFor(() => getByRole(container, role, options));
248
+ export const findByRole = async (container, role, options) => waitFor(() => getByRole(container, role, options), { timeout: options?.timeout });
207
249
  /**
208
250
  * Waits for and finds all widgets matching the specified accessible role.
209
251
  * @param container - The container to search within
@@ -211,52 +253,52 @@ export const findByRole = async (container, role, options) => waitFor(() => getB
211
253
  * @param options - Additional filtering options (name, checked, expanded)
212
254
  * @returns Promise resolving to array of matching widgets
213
255
  */
214
- export const findAllByRole = async (container, role, options) => waitFor(() => getAllByRole(container, role, options));
256
+ export const findAllByRole = async (container, role, options) => waitFor(() => getAllByRole(container, role, options), { timeout: options?.timeout });
215
257
  /**
216
258
  * Waits for and finds a single widget matching the specified label text.
217
259
  * @param container - The container to search within
218
260
  * @param text - The text or pattern to match
219
- * @param options - Text matching options (exact, normalizer)
261
+ * @param options - Text matching options (exact, normalizer, timeout)
220
262
  * @returns Promise resolving to the matching widget
221
263
  */
222
- export const findByLabelText = async (container, text, options) => waitFor(() => getByLabelText(container, text, options));
264
+ export const findByLabelText = async (container, text, options) => waitFor(() => getByLabelText(container, text, options), { timeout: options?.timeout });
223
265
  /**
224
266
  * Waits for and finds all widgets matching the specified label text.
225
267
  * @param container - The container to search within
226
268
  * @param text - The text or pattern to match
227
- * @param options - Text matching options (exact, normalizer)
269
+ * @param options - Text matching options (exact, normalizer, timeout)
228
270
  * @returns Promise resolving to array of matching widgets
229
271
  */
230
- export const findAllByLabelText = async (container, text, options) => waitFor(() => getAllByLabelText(container, text, options));
272
+ export const findAllByLabelText = async (container, text, options) => waitFor(() => getAllByLabelText(container, text, options), { timeout: options?.timeout });
231
273
  /**
232
274
  * Waits for and finds a single widget matching the specified text content.
233
275
  * @param container - The container to search within
234
276
  * @param text - The text or pattern to match
235
- * @param options - Text matching options (exact, normalizer)
277
+ * @param options - Text matching options (exact, normalizer, timeout)
236
278
  * @returns Promise resolving to the matching widget
237
279
  */
238
- export const findByText = async (container, text, options) => waitFor(() => getByText(container, text, options));
280
+ export const findByText = async (container, text, options) => waitFor(() => getByText(container, text, options), { timeout: options?.timeout });
239
281
  /**
240
282
  * Waits for and finds all widgets matching the specified text content.
241
283
  * @param container - The container to search within
242
284
  * @param text - The text or pattern to match
243
- * @param options - Text matching options (exact, normalizer)
285
+ * @param options - Text matching options (exact, normalizer, timeout)
244
286
  * @returns Promise resolving to array of matching widgets
245
287
  */
246
- export const findAllByText = async (container, text, options) => waitFor(() => getAllByText(container, text, options));
288
+ export const findAllByText = async (container, text, options) => waitFor(() => getAllByText(container, text, options), { timeout: options?.timeout });
247
289
  /**
248
290
  * Waits for and finds a single widget matching the specified test ID.
249
291
  * @param container - The container to search within
250
292
  * @param testId - The test ID or pattern to match
251
- * @param options - Text matching options (exact, normalizer)
293
+ * @param options - Text matching options (exact, normalizer, timeout)
252
294
  * @returns Promise resolving to the matching widget
253
295
  */
254
- export const findByTestId = async (container, testId, options) => waitFor(() => getByTestId(container, testId, options));
296
+ export const findByTestId = async (container, testId, options) => waitFor(() => getByTestId(container, testId, options), { timeout: options?.timeout });
255
297
  /**
256
298
  * Waits for and finds all widgets matching the specified test ID.
257
299
  * @param container - The container to search within
258
300
  * @param testId - The test ID or pattern to match
259
- * @param options - Text matching options (exact, normalizer)
301
+ * @param options - Text matching options (exact, normalizer, timeout)
260
302
  * @returns Promise resolving to array of matching widgets
261
303
  */
262
- export const findAllByTestId = async (container, testId, options) => waitFor(() => getAllByTestId(container, testId, options));
304
+ export const findAllByTestId = async (container, testId, options) => waitFor(() => getAllByTestId(container, testId, options), { timeout: options?.timeout });
package/dist/render.js CHANGED
@@ -1,18 +1,28 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { getCurrentApp, start, stop } from "@gtkx/ffi";
2
+ import { getCurrentApp, getObject, start, stop } from "@gtkx/ffi";
3
3
  import * as Gtk from "@gtkx/ffi/gtk";
4
4
  import { ApplicationWindow, reconciler } from "@gtkx/react";
5
5
  import * as queries from "./queries.js";
6
6
  import { setScreenRoot } from "./screen.js";
7
+ import { tick } from "./timing.js";
8
+ import { asAccessible, hasLabel } from "./widget.js";
7
9
  const ROOT_NODE_CONTAINER = Symbol.for("ROOT_NODE_CONTAINER");
8
10
  const APP_ID = "com.gtkx.testing";
9
11
  let container = null;
10
- const hasGetLabel = (widget) => typeof widget.getLabel === "function";
12
+ const getWidgetLabel = (widget) => {
13
+ if (!hasLabel(widget))
14
+ return null;
15
+ const role = asAccessible(widget).getAccessibleRole();
16
+ if (role === Gtk.AccessibleRole.LABEL) {
17
+ return getObject(widget.ptr, Gtk.Label).getLabel();
18
+ }
19
+ return getObject(widget.ptr, Gtk.Button).getLabel();
20
+ };
11
21
  const printWidgetTree = (root, indent = 0) => {
12
- const accessible = root;
13
22
  const prefix = " ".repeat(indent);
14
- const role = Gtk.AccessibleRole[accessible.getAccessibleRole()] ?? "UNKNOWN";
15
- const label = hasGetLabel(root) ? ` label="${root.getLabel()}"` : "";
23
+ const role = Gtk.AccessibleRole[asAccessible(root).getAccessibleRole()] ?? "UNKNOWN";
24
+ const labelText = getWidgetLabel(root);
25
+ const label = labelText ? ` label="${labelText}"` : "";
16
26
  let result = `${prefix}<${root.constructor.name} role=${role}${label}>\n`;
17
27
  let child = root.getFirstChild();
18
28
  while (child) {
@@ -21,7 +31,6 @@ const printWidgetTree = (root, indent = 0) => {
21
31
  }
22
32
  return result;
23
33
  };
24
- const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
25
34
  const update = async (instance, element, fiberRoot) => {
26
35
  instance.updateContainer(element, fiberRoot, null, () => { });
27
36
  await tick();
@@ -30,9 +39,7 @@ const ensureInitialized = () => {
30
39
  const app = start(APP_ID);
31
40
  if (!container) {
32
41
  const instance = reconciler.getInstance();
33
- container = instance.createContainer(
34
- // biome-ignore lint/suspicious/noExplicitAny: testing only
35
- ROOT_NODE_CONTAINER, 0, null, false, null, "", (error) => console.error("Test reconciler error:", error), () => { }, () => { }, () => { }, null);
42
+ container = instance.createContainer(ROOT_NODE_CONTAINER, 0, null, false, null, "", (error) => console.error("Test reconciler error:", error), () => { }, () => { }, () => { }, null);
36
43
  }
37
44
  return { app, container };
38
45
  };
@@ -96,5 +103,4 @@ export const cleanup = async () => {
96
103
  export const teardown = async () => {
97
104
  await cleanup();
98
105
  stop();
99
- container = null;
100
106
  };
@@ -0,0 +1 @@
1
+ export declare const tick: () => Promise<void>;
package/dist/timing.js ADDED
@@ -0,0 +1 @@
1
+ export const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
package/dist/types.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { ComponentType, ReactNode } from "react";
4
4
  export interface TextMatchOptions {
5
5
  exact?: boolean;
6
6
  normalizer?: (text: string) => string;
7
+ timeout?: number;
7
8
  }
8
9
  export interface ByRoleOptions extends TextMatchOptions {
9
10
  name?: string | RegExp;
@@ -1,36 +1,20 @@
1
1
  import type * as Gtk from "@gtkx/ffi/gtk";
2
- /**
3
- * Options for configuring user event behavior.
4
- */
5
- export interface UserEventOptions {
6
- /** Delay between events in milliseconds */
7
- delay?: number;
8
- }
9
- /**
10
- * Instance returned by userEvent.setup() with bound options.
11
- */
12
- export interface UserEventInstance {
13
- /** Simulates a click on the element */
14
- click: (element: Gtk.Widget) => Promise<void>;
15
- /** Simulates a double-click on the element */
16
- dblClick: (element: Gtk.Widget) => Promise<void>;
17
- /** Activates the element (e.g., pressing Enter in an Entry) */
18
- activate: (element: Gtk.Widget) => Promise<void>;
19
- /** Types text into an input element */
20
- type: (element: Gtk.Widget, text: string) => Promise<void>;
21
- /** Clears the text content of an input element */
22
- clear: (element: Gtk.Widget) => Promise<void>;
2
+ export interface TabOptions {
3
+ /** If true, navigates backwards (Shift+Tab behavior) */
4
+ shift?: boolean;
23
5
  }
24
6
  /**
25
7
  * Simulates user interactions with GTK widgets. Provides methods that mimic
26
8
  * real user behavior like clicking, typing, and clearing input fields.
27
- * Use userEvent.setup() to create an instance with custom options.
28
9
  */
29
10
  export declare const userEvent: {
30
- setup: (options?: UserEventOptions) => UserEventInstance;
31
11
  click: (element: Gtk.Widget) => Promise<void>;
32
12
  dblClick: (element: Gtk.Widget) => Promise<void>;
13
+ tripleClick: (element: Gtk.Widget) => Promise<void>;
33
14
  activate: (element: Gtk.Widget) => Promise<void>;
15
+ tab: (element: Gtk.Widget, options?: TabOptions) => Promise<void>;
34
16
  type: (element: Gtk.Widget, text: string) => Promise<void>;
35
17
  clear: (element: Gtk.Widget) => Promise<void>;
18
+ selectOptions: (element: Gtk.Widget, values: string | string[] | number | number[]) => Promise<void>;
19
+ deselectOptions: (element: Gtk.Widget, values: number | number[]) => Promise<void>;
36
20
  };
@@ -1,73 +1,143 @@
1
+ import { getObject } from "@gtkx/ffi";
2
+ import { AccessibleRole, CheckButton, ComboBox, DirectionType, DropDown, Editable, ListBox, ListBoxRow, ToggleButton, Widget, } from "@gtkx/ffi/gtk";
1
3
  import { fireEvent } from "./fire-event.js";
2
- import { hasGetText, hasSetText } from "./widget.js";
3
- const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
4
- const createUserEventInstance = (_options) => {
5
- return {
6
- click: async (element) => {
7
- fireEvent(element, "clicked");
8
- await tick();
9
- },
10
- dblClick: async (element) => {
11
- fireEvent(element, "clicked");
12
- await tick();
13
- fireEvent(element, "clicked");
14
- await tick();
15
- },
16
- activate: async (element) => {
17
- element.activate();
18
- await tick();
19
- },
20
- type: async (element, text) => {
21
- if (!hasSetText(element)) {
22
- throw new Error("Cannot type into element: no setText method available");
4
+ import { tick } from "./timing.js";
5
+ import { asAccessible, isEditable } from "./widget.js";
6
+ const TOGGLEABLE_ROLES = new Set([AccessibleRole.CHECKBOX, AccessibleRole.RADIO, AccessibleRole.TOGGLE_BUTTON]);
7
+ const isToggleable = (widget) => {
8
+ const role = asAccessible(widget).getAccessibleRole();
9
+ return TOGGLEABLE_ROLES.has(role);
10
+ };
11
+ const click = async (element) => {
12
+ if (isToggleable(element)) {
13
+ const role = asAccessible(element).getAccessibleRole();
14
+ if (role === AccessibleRole.CHECKBOX || role === AccessibleRole.RADIO) {
15
+ const checkButton = getObject(element.ptr, CheckButton);
16
+ checkButton.setActive(!checkButton.getActive());
17
+ }
18
+ else {
19
+ const toggleButton = getObject(element.ptr, ToggleButton);
20
+ toggleButton.setActive(!toggleButton.getActive());
21
+ }
22
+ // Note: setActive() automatically emits the "toggled" signal, so we don't need to emit it manually
23
+ }
24
+ else {
25
+ fireEvent(element, "clicked");
26
+ }
27
+ await tick();
28
+ };
29
+ const dblClick = async (element) => {
30
+ fireEvent(element, "clicked");
31
+ await tick();
32
+ fireEvent(element, "clicked");
33
+ await tick();
34
+ };
35
+ const tripleClick = async (element) => {
36
+ fireEvent(element, "clicked");
37
+ await tick();
38
+ fireEvent(element, "clicked");
39
+ await tick();
40
+ fireEvent(element, "clicked");
41
+ await tick();
42
+ };
43
+ const activate = async (element) => {
44
+ element.activate();
45
+ await tick();
46
+ };
47
+ const tab = async (element, options) => {
48
+ const direction = options?.shift ? DirectionType.TAB_BACKWARD : DirectionType.TAB_FORWARD;
49
+ const root = element.getRoot();
50
+ if (root) {
51
+ const rootWidget = getObject(root.ptr, Widget);
52
+ rootWidget.childFocus(direction);
53
+ }
54
+ await tick();
55
+ };
56
+ const type = async (element, text) => {
57
+ if (!isEditable(element)) {
58
+ throw new Error("Cannot type into element: element is not editable (TEXT_BOX, SEARCH_BOX, or SPIN_BUTTON)");
59
+ }
60
+ const editable = getObject(element.ptr, Editable);
61
+ const currentText = editable.getText();
62
+ editable.setText(currentText + text);
63
+ await tick();
64
+ };
65
+ const clear = async (element) => {
66
+ if (!isEditable(element)) {
67
+ throw new Error("Cannot clear element: element is not editable (TEXT_BOX, SEARCH_BOX, or SPIN_BUTTON)");
68
+ }
69
+ const editable = getObject(element.ptr, Editable);
70
+ editable.setText("");
71
+ await tick();
72
+ };
73
+ const SELECTABLE_ROLES = new Set([AccessibleRole.COMBO_BOX, AccessibleRole.LIST]);
74
+ const isSelectable = (widget) => {
75
+ const role = asAccessible(widget).getAccessibleRole();
76
+ return SELECTABLE_ROLES.has(role);
77
+ };
78
+ const selectOptions = async (element, values) => {
79
+ if (!isSelectable(element)) {
80
+ throw new Error("Cannot select options: element is not a selectable widget (COMBO_BOX or LIST)");
81
+ }
82
+ const role = asAccessible(element).getAccessibleRole();
83
+ const valueArray = Array.isArray(values) ? values : [values];
84
+ if (role === AccessibleRole.COMBO_BOX) {
85
+ if (valueArray.length > 1) {
86
+ throw new Error("Cannot select multiple options on a ComboBox/DropDown");
87
+ }
88
+ const value = valueArray[0];
89
+ if (typeof value !== "number") {
90
+ throw new Error("ComboBox/DropDown selection requires a numeric index");
91
+ }
92
+ const isDropDown = element.constructor.name === "DropDown";
93
+ if (isDropDown) {
94
+ getObject(element.ptr, DropDown).setSelected(value);
95
+ }
96
+ else {
97
+ getObject(element.ptr, ComboBox).setActive(value);
98
+ }
99
+ }
100
+ else if (role === AccessibleRole.LIST) {
101
+ const listBox = getObject(element.ptr, ListBox);
102
+ for (const value of valueArray) {
103
+ if (typeof value !== "number") {
104
+ throw new Error("ListBox selection requires numeric indices");
23
105
  }
24
- const currentText = hasGetText(element) ? element.getText() : "";
25
- element.setText(currentText + text);
26
- await tick();
27
- },
28
- clear: async (element) => {
29
- if (!hasSetText(element)) {
30
- throw new Error("Cannot clear element: no setText method available");
106
+ const row = listBox.getRowAtIndex(value);
107
+ if (row) {
108
+ listBox.selectRow(row);
31
109
  }
32
- element.setText("");
33
- await tick();
34
- },
35
- };
110
+ }
111
+ }
112
+ await tick();
113
+ };
114
+ const deselectOptions = async (element, values) => {
115
+ const role = asAccessible(element).getAccessibleRole();
116
+ if (role !== AccessibleRole.LIST) {
117
+ throw new Error("Cannot deselect options: only ListBox supports deselection");
118
+ }
119
+ const listBox = getObject(element.ptr, ListBox);
120
+ const valueArray = Array.isArray(values) ? values : [values];
121
+ for (const value of valueArray) {
122
+ const row = listBox.getRowAtIndex(value);
123
+ if (row) {
124
+ listBox.unselectRow(getObject(row.ptr, ListBoxRow));
125
+ }
126
+ }
127
+ await tick();
36
128
  };
37
129
  /**
38
130
  * Simulates user interactions with GTK widgets. Provides methods that mimic
39
131
  * real user behavior like clicking, typing, and clearing input fields.
40
- * Use userEvent.setup() to create an instance with custom options.
41
132
  */
42
133
  export const userEvent = {
43
- setup: (options) => createUserEventInstance(options),
44
- click: async (element) => {
45
- fireEvent(element, "clicked");
46
- await tick();
47
- },
48
- dblClick: async (element) => {
49
- fireEvent(element, "clicked");
50
- await tick();
51
- fireEvent(element, "clicked");
52
- await tick();
53
- },
54
- activate: async (element) => {
55
- element.activate();
56
- await tick();
57
- },
58
- type: async (element, text) => {
59
- if (!hasSetText(element)) {
60
- throw new Error("Cannot type into element: no setText method available");
61
- }
62
- const currentText = hasGetText(element) ? element.getText() : "";
63
- element.setText(currentText + text);
64
- await tick();
65
- },
66
- clear: async (element) => {
67
- if (!hasSetText(element)) {
68
- throw new Error("Cannot clear element: no setText method available");
69
- }
70
- element.setText("");
71
- await tick();
72
- },
134
+ click,
135
+ dblClick,
136
+ tripleClick,
137
+ activate,
138
+ tab,
139
+ type,
140
+ clear,
141
+ selectOptions,
142
+ deselectOptions,
73
143
  };
package/dist/widget.d.ts CHANGED
@@ -1,9 +1,5 @@
1
- type WidgetWithSetText = {
2
- setText: (text: string) => void;
3
- };
4
- type WidgetWithGetText = {
5
- getText: () => string;
6
- };
7
- export declare const hasSetText: (widget: unknown) => widget is WidgetWithSetText;
8
- export declare const hasGetText: (widget: unknown) => widget is WidgetWithGetText;
9
- export {};
1
+ import type * as Gtk from "@gtkx/ffi/gtk";
2
+ import { type Accessible } from "@gtkx/ffi/gtk";
3
+ export declare const asAccessible: (widget: Gtk.Widget) => Accessible;
4
+ export declare const isEditable: (widget: Gtk.Widget) => boolean;
5
+ export declare const hasLabel: (widget: Gtk.Widget) => boolean;
package/dist/widget.js CHANGED
@@ -1,2 +1,21 @@
1
- export const hasSetText = (widget) => typeof widget.setText === "function";
2
- export const hasGetText = (widget) => typeof widget.getText === "function";
1
+ import { AccessibleRole } from "@gtkx/ffi/gtk";
2
+ export const asAccessible = (widget) => widget;
3
+ const EDITABLE_ROLES = new Set([AccessibleRole.TEXT_BOX, AccessibleRole.SEARCH_BOX, AccessibleRole.SPIN_BUTTON]);
4
+ export const isEditable = (widget) => {
5
+ const role = asAccessible(widget).getAccessibleRole();
6
+ return EDITABLE_ROLES.has(role);
7
+ };
8
+ const LABEL_ROLES = new Set([
9
+ AccessibleRole.BUTTON,
10
+ AccessibleRole.TOGGLE_BUTTON,
11
+ AccessibleRole.CHECKBOX,
12
+ AccessibleRole.RADIO,
13
+ AccessibleRole.LABEL,
14
+ AccessibleRole.MENU_ITEM,
15
+ AccessibleRole.MENU_ITEM_CHECKBOX,
16
+ AccessibleRole.MENU_ITEM_RADIO,
17
+ ]);
18
+ export const hasLabel = (widget) => {
19
+ const role = asAccessible(widget).getAccessibleRole();
20
+ return LABEL_ROLES.has(role);
21
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtkx/testing",
3
- "version": "0.1.48",
3
+ "version": "0.1.50",
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/native": "0.1.48",
36
- "@gtkx/ffi": "0.1.48",
37
- "@gtkx/react": "0.1.48"
35
+ "@gtkx/react": "0.1.50",
36
+ "@gtkx/ffi": "0.1.50",
37
+ "@gtkx/native": "0.1.50"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsc -b && cp ../../README.md .",