@gtkx/testing 0.18.0 → 0.18.2

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.
Files changed (101) hide show
  1. package/dist/bind-queries.d.ts +1 -0
  2. package/dist/bind-queries.d.ts.map +1 -0
  3. package/dist/bind-queries.js +1 -0
  4. package/dist/bind-queries.js.map +1 -0
  5. package/dist/config.d.ts +1 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +1 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/error-builder.d.ts +1 -0
  10. package/dist/error-builder.d.ts.map +1 -0
  11. package/dist/error-builder.js +1 -0
  12. package/dist/error-builder.js.map +1 -0
  13. package/dist/fire-event.d.ts +1 -0
  14. package/dist/fire-event.d.ts.map +1 -0
  15. package/dist/fire-event.js +1 -0
  16. package/dist/fire-event.js.map +1 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/pretty-widget.d.ts +1 -0
  22. package/dist/pretty-widget.d.ts.map +1 -0
  23. package/dist/pretty-widget.js +1 -0
  24. package/dist/pretty-widget.js.map +1 -0
  25. package/dist/queries.d.ts +1 -0
  26. package/dist/queries.d.ts.map +1 -0
  27. package/dist/queries.js +1 -0
  28. package/dist/queries.js.map +1 -0
  29. package/dist/render-hook.d.ts +1 -0
  30. package/dist/render-hook.d.ts.map +1 -0
  31. package/dist/render-hook.js +1 -0
  32. package/dist/render-hook.js.map +1 -0
  33. package/dist/render.d.ts +1 -0
  34. package/dist/render.d.ts.map +1 -0
  35. package/dist/render.js +1 -0
  36. package/dist/render.js.map +1 -0
  37. package/dist/role-helpers.d.ts +1 -0
  38. package/dist/role-helpers.d.ts.map +1 -0
  39. package/dist/role-helpers.js +1 -0
  40. package/dist/role-helpers.js.map +1 -0
  41. package/dist/screen.d.ts +1 -0
  42. package/dist/screen.d.ts.map +1 -0
  43. package/dist/screen.js +1 -0
  44. package/dist/screen.js.map +1 -0
  45. package/dist/screenshot.d.ts +1 -0
  46. package/dist/screenshot.d.ts.map +1 -0
  47. package/dist/screenshot.js +1 -0
  48. package/dist/screenshot.js.map +1 -0
  49. package/dist/timing.d.ts +1 -0
  50. package/dist/timing.d.ts.map +1 -0
  51. package/dist/timing.js +1 -0
  52. package/dist/timing.js.map +1 -0
  53. package/dist/traversal.d.ts +8 -0
  54. package/dist/traversal.d.ts.map +1 -0
  55. package/dist/traversal.js +1 -0
  56. package/dist/traversal.js.map +1 -0
  57. package/dist/types.d.ts +1 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +1 -0
  60. package/dist/types.js.map +1 -0
  61. package/dist/user-event.d.ts +1 -0
  62. package/dist/user-event.d.ts.map +1 -0
  63. package/dist/user-event.js +1 -0
  64. package/dist/user-event.js.map +1 -0
  65. package/dist/wait-for.d.ts +1 -0
  66. package/dist/wait-for.d.ts.map +1 -0
  67. package/dist/wait-for.js +1 -0
  68. package/dist/wait-for.js.map +1 -0
  69. package/dist/widget-text.d.ts +1 -0
  70. package/dist/widget-text.d.ts.map +1 -0
  71. package/dist/widget-text.js +1 -0
  72. package/dist/widget-text.js.map +1 -0
  73. package/dist/widget.d.ts +1 -0
  74. package/dist/widget.d.ts.map +1 -0
  75. package/dist/widget.js +1 -0
  76. package/dist/widget.js.map +1 -0
  77. package/dist/within.d.ts +1 -0
  78. package/dist/within.d.ts.map +1 -0
  79. package/dist/within.js +1 -0
  80. package/dist/within.js.map +1 -0
  81. package/package.json +7 -5
  82. package/src/bind-queries.ts +52 -0
  83. package/src/config.ts +89 -0
  84. package/src/error-builder.ts +102 -0
  85. package/src/fire-event.ts +43 -0
  86. package/src/index.ts +51 -0
  87. package/src/pretty-widget.ts +205 -0
  88. package/src/queries.ts +511 -0
  89. package/src/render-hook.tsx +71 -0
  90. package/src/render.tsx +192 -0
  91. package/src/role-helpers.ts +126 -0
  92. package/src/screen.ts +125 -0
  93. package/src/screenshot.ts +105 -0
  94. package/src/timing.ts +17 -0
  95. package/src/traversal.ts +48 -0
  96. package/src/types.ts +210 -0
  97. package/src/user-event.ts +492 -0
  98. package/src/wait-for.ts +115 -0
  99. package/src/widget-text.ts +206 -0
  100. package/src/widget.ts +15 -0
  101. package/src/within.ts +31 -0
package/src/types.ts ADDED
@@ -0,0 +1,210 @@
1
+ import type * as Gtk from "@gtkx/ffi/gtk";
2
+ import type { ComponentType, ReactNode, Ref } from "react";
3
+ import type { Container } from "./traversal.js";
4
+
5
+ /**
6
+ * Custom function for matching text content.
7
+ *
8
+ * @param content - The normalized text content to match against
9
+ * @param widget - The widget being tested
10
+ * @returns `true` if the content matches
11
+ */
12
+ export type TextMatchFunction = (content: string, widget: Gtk.Widget) => boolean;
13
+
14
+ /**
15
+ * Text matching pattern.
16
+ *
17
+ * Can be a string for exact/substring matching, a RegExp for pattern matching,
18
+ * or a custom function for advanced matching logic.
19
+ */
20
+ export type TextMatch = string | RegExp | TextMatchFunction;
21
+
22
+ /**
23
+ * Options for text normalization before matching.
24
+ */
25
+ export type NormalizerOptions = {
26
+ /** Remove leading/trailing whitespace (default: true) */
27
+ trim?: boolean;
28
+ /** Replace multiple whitespace characters with single space (default: true) */
29
+ collapseWhitespace?: boolean;
30
+ };
31
+
32
+ /**
33
+ * Options for text-based queries.
34
+ */
35
+ export type TextMatchOptions = {
36
+ /** Require exact match vs substring match (default: true) */
37
+ exact?: boolean;
38
+ /** Custom text normalizer function */
39
+ normalizer?: (text: string) => string;
40
+ /** Remove leading/trailing whitespace (default: true) */
41
+ trim?: boolean;
42
+ /** Replace multiple whitespace with single space (default: true) */
43
+ collapseWhitespace?: boolean;
44
+ /** Timeout in milliseconds for async queries (default: 1000) */
45
+ timeout?: number;
46
+ };
47
+
48
+ /**
49
+ * Options for role-based queries.
50
+ *
51
+ * Extends text matching options with accessible state filters.
52
+ */
53
+ export type ByRoleOptions = TextMatchOptions & {
54
+ /** Filter by accessible name/label */
55
+ name?: TextMatch;
56
+ /** Filter by checked state (checkboxes, radios, toggles) */
57
+ checked?: boolean;
58
+ /** Filter by pressed state */
59
+ pressed?: boolean;
60
+ /** Filter by selected state */
61
+ selected?: boolean;
62
+ /** Filter by expanded state (expanders) */
63
+ expanded?: boolean;
64
+ /** Filter by heading level */
65
+ level?: number;
66
+ };
67
+
68
+ /**
69
+ * Options for {@link waitFor} and {@link waitForElementToBeRemoved}.
70
+ */
71
+ export type WaitForOptions = {
72
+ /** Maximum time to wait in milliseconds (default: 1000) */
73
+ timeout?: number;
74
+ /** Polling interval in milliseconds (default: 50) */
75
+ interval?: number;
76
+ /** Custom error handler called on timeout */
77
+ onTimeout?: (error: Error) => Error;
78
+ };
79
+
80
+ /**
81
+ * A wrapper component that exposes its root GTK widget via `ref`.
82
+ * Accept `ref` as a prop and pass it through to the root intrinsic element.
83
+ */
84
+ export type WrapperComponent = ComponentType<{
85
+ children: ReactNode;
86
+ ref?: Ref<Gtk.Widget>;
87
+ }>;
88
+
89
+ /**
90
+ * Options for {@link render}.
91
+ */
92
+ export type RenderOptions = {
93
+ /**
94
+ * Wrapper component or boolean.
95
+ * - `true` (default): Wrap in GtkApplicationWindow
96
+ * - `false`: No wrapper
97
+ * - Component: Custom wrapper that passes `ref` to its root element
98
+ */
99
+ wrapper?: boolean | WrapperComponent;
100
+ /**
101
+ * The element queries are bound to.
102
+ * Defaults to the GTK Application (searches all toplevel windows).
103
+ * Provide a specific widget or application to scope queries.
104
+ */
105
+ baseElement?: Container;
106
+ };
107
+
108
+ /**
109
+ * Query methods bound to a container.
110
+ *
111
+ * @see {@link screen} for global queries
112
+ * @see {@link within} for scoped queries
113
+ */
114
+ export type BoundQueries = {
115
+ /** Query single element by accessible role (returns null if not found) */
116
+ queryByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) => Gtk.Widget | null;
117
+ /** Query single element by label/text content (returns null if not found) */
118
+ queryByLabelText: (text: TextMatch, options?: TextMatchOptions) => Gtk.Widget | null;
119
+ /** Query single element by visible text (returns null if not found) */
120
+ queryByText: (text: TextMatch, options?: TextMatchOptions) => Gtk.Widget | null;
121
+ /** Query single element by test ID (returns null if not found) */
122
+ queryByTestId: (testId: TextMatch, options?: TextMatchOptions) => Gtk.Widget | null;
123
+ /** Query all elements by accessible role (returns empty array if none found) */
124
+ queryAllByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) => Gtk.Widget[];
125
+ /** Query all elements by label/text content (returns empty array if none found) */
126
+ queryAllByLabelText: (text: TextMatch, options?: TextMatchOptions) => Gtk.Widget[];
127
+ /** Query all elements by visible text (returns empty array if none found) */
128
+ queryAllByText: (text: TextMatch, options?: TextMatchOptions) => Gtk.Widget[];
129
+ /** Query all elements by test ID (returns empty array if none found) */
130
+ queryAllByTestId: (testId: TextMatch, options?: TextMatchOptions) => Gtk.Widget[];
131
+ /** Find single element by accessible role (waits and throws if not found) */
132
+ findByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) => Promise<Gtk.Widget>;
133
+ /** Find single element by label/text content (waits and throws if not found) */
134
+ findByLabelText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
135
+ /** Find single element by visible text (waits and throws if not found) */
136
+ findByText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
137
+ /** Find single element by test ID (waits and throws if not found) */
138
+ findByTestId: (testId: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
139
+ /** Find all elements by accessible role (waits and throws if none found) */
140
+ findAllByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) => Promise<Gtk.Widget[]>;
141
+ /** Find all elements by label/text content (waits and throws if none found) */
142
+ findAllByLabelText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
143
+ /** Find all elements by visible text (waits and throws if none found) */
144
+ findAllByText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
145
+ /** Find all elements by test ID (waits and throws if none found) */
146
+ findAllByTestId: (testId: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
147
+ };
148
+
149
+ /**
150
+ * Result returned by {@link render}.
151
+ *
152
+ * Provides query methods and utilities for testing rendered components.
153
+ */
154
+ export type RenderResult = BoundQueries & {
155
+ /** The direct container widget wrapping the rendered content */
156
+ container: Gtk.Widget;
157
+ /** The element queries are bound to (defaults to the GTK Application) */
158
+ baseElement: Container;
159
+ /** Unmount the rendered component */
160
+ unmount: () => Promise<void>;
161
+ /** Re-render with a new element */
162
+ rerender: (element: ReactNode) => Promise<void>;
163
+ /** Print the widget tree to console for debugging */
164
+ debug: () => void;
165
+ };
166
+
167
+ /**
168
+ * Result returned by {@link screenshot} and screen.screenshot.
169
+ */
170
+ export type ScreenshotResult = {
171
+ /** Base64-encoded PNG image data */
172
+ data: string;
173
+ /** MIME type of the image (always "image/png") */
174
+ mimeType: string;
175
+ /** Width of the captured image in pixels */
176
+ width: number;
177
+ /** Height of the captured image in pixels */
178
+ height: number;
179
+ };
180
+
181
+ /**
182
+ * Options for {@link renderHook}.
183
+ */
184
+ export type RenderHookOptions<Props> = {
185
+ /**
186
+ * Initial props passed to the hook callback.
187
+ */
188
+ initialProps?: Props;
189
+ /**
190
+ * Wrapper component or boolean.
191
+ * - `true` (default): Wrap in GtkApplicationWindow
192
+ * - `false`: No wrapper
193
+ * - Component: Custom wrapper that passes `ref` to its root element
194
+ */
195
+ wrapper?: boolean | WrapperComponent;
196
+ };
197
+
198
+ /**
199
+ * Result returned by {@link renderHook}.
200
+ *
201
+ * Provides access to the hook result and utilities for re-rendering and cleanup.
202
+ */
203
+ export type RenderHookResult<Result, Props> = {
204
+ /** Object containing the current hook return value */
205
+ result: { current: Result };
206
+ /** Re-render the hook with optional new props */
207
+ rerender: (newProps?: Props) => Promise<void>;
208
+ /** Unmount the component containing the hook */
209
+ unmount: () => Promise<void>;
210
+ };
@@ -0,0 +1,492 @@
1
+ import * as Gdk from "@gtkx/ffi/gdk";
2
+ import { type Object as GObject, signalEmitv, signalLookup, typeFromName, Value } from "@gtkx/ffi/gobject";
3
+ import * as Gtk from "@gtkx/ffi/gtk";
4
+ import { fireEvent } from "./fire-event.js";
5
+ import { tick } from "./timing.js";
6
+ import { isEditable } from "./widget.js";
7
+
8
+ /**
9
+ * Options for tab navigation.
10
+ */
11
+ export type TabOptions = {
12
+ /** Navigate backwards (Shift+Tab) instead of forwards */
13
+ shift?: boolean;
14
+ };
15
+
16
+ const click = async (element: Gtk.Widget): Promise<void> => {
17
+ element.activate();
18
+ await tick();
19
+ };
20
+
21
+ const emitClickSequence = async (element: Gtk.Widget, nPress: number): Promise<void> => {
22
+ const controller = getOrCreateController(element, Gtk.GestureClick);
23
+
24
+ for (let i = 1; i <= nPress; i++) {
25
+ const args = [
26
+ Value.newFromObject(controller),
27
+ Value.newFromInt(i),
28
+ Value.newFromDouble(0),
29
+ Value.newFromDouble(0),
30
+ ];
31
+ signalEmitv(args, getSignalId(controller, "pressed"), 0);
32
+ signalEmitv(args, getSignalId(controller, "released"), 0);
33
+ }
34
+
35
+ await tick();
36
+ };
37
+
38
+ const dblClick = async (element: Gtk.Widget): Promise<void> => {
39
+ await emitClickSequence(element, 2);
40
+ };
41
+
42
+ const tripleClick = async (element: Gtk.Widget): Promise<void> => {
43
+ await emitClickSequence(element, 3);
44
+ };
45
+
46
+ const tab = async (element: Gtk.Widget, options?: TabOptions): Promise<void> => {
47
+ const direction = options?.shift ? Gtk.DirectionType.TAB_BACKWARD : Gtk.DirectionType.TAB_FORWARD;
48
+ const root = element.getRoot();
49
+
50
+ if (root && root instanceof Gtk.Widget) {
51
+ root.childFocus(direction);
52
+ }
53
+
54
+ await tick();
55
+ };
56
+
57
+ const type = async (element: Gtk.Widget, text: string): Promise<void> => {
58
+ if (!isEditable(element)) {
59
+ throw new Error("Cannot type into element: expected editable widget (TEXT_BOX, SEARCH_BOX, or SPIN_BUTTON)");
60
+ }
61
+
62
+ const currentText = element.getText();
63
+ element.setText(currentText + text);
64
+
65
+ await tick();
66
+ };
67
+
68
+ const clear = async (element: Gtk.Widget): Promise<void> => {
69
+ if (!isEditable(element)) {
70
+ throw new Error("Cannot clear element: expected editable widget (TEXT_BOX, SEARCH_BOX, or SPIN_BUTTON)");
71
+ }
72
+
73
+ element.setText("");
74
+
75
+ await tick();
76
+ };
77
+
78
+ const SELECTABLE_ROLES = new Set([Gtk.AccessibleRole.COMBO_BOX, Gtk.AccessibleRole.LIST]);
79
+
80
+ const isSelectable = (widget: Gtk.Widget): boolean => {
81
+ if (!widget) return false;
82
+ return SELECTABLE_ROLES.has(widget.getAccessibleRole());
83
+ };
84
+
85
+ const selectListViewItems = (selectionModel: Gtk.SelectionModel, positions: number[], exclusive: boolean): void => {
86
+ if (positions.length === 0) {
87
+ selectionModel.unselectRange(0, selectionModel.getNItems());
88
+ return;
89
+ }
90
+
91
+ if (exclusive && positions.length === 1) {
92
+ selectionModel.selectItem(positions[0] as number, true);
93
+ return;
94
+ }
95
+
96
+ const nItems = selectionModel.getNItems();
97
+ const selected = new Gtk.Bitset();
98
+ const mask = Gtk.Bitset.newRange(0, nItems);
99
+
100
+ for (const pos of positions) {
101
+ selected.add(pos);
102
+ }
103
+
104
+ selectionModel.setSelection(selected, mask);
105
+ };
106
+
107
+ const isListView = (widget: Gtk.Widget): widget is Gtk.ListView | Gtk.GridView | Gtk.ColumnView => {
108
+ return widget instanceof Gtk.ListView || widget instanceof Gtk.GridView || widget instanceof Gtk.ColumnView;
109
+ };
110
+
111
+ const selectOptions = async (element: Gtk.Widget, values: number | number[]): Promise<void> => {
112
+ const valueArray = Array.isArray(values) ? values : [values];
113
+
114
+ if (isListView(element)) {
115
+ const selectionModel = element.getModel() as Gtk.SelectionModel;
116
+ const isMultiSelection = selectionModel instanceof Gtk.MultiSelection;
117
+ selectListViewItems(selectionModel, valueArray, !isMultiSelection);
118
+ await tick();
119
+ return;
120
+ }
121
+
122
+ if (!isSelectable(element)) {
123
+ throw new Error("Cannot select options: expected selectable widget (COMBO_BOX or LIST)");
124
+ }
125
+
126
+ const role = element.getAccessibleRole();
127
+
128
+ if (role === Gtk.AccessibleRole.COMBO_BOX) {
129
+ if (Array.isArray(values) && values.length > 1) {
130
+ throw new Error("Cannot select multiple options: ComboBox only supports single selection");
131
+ }
132
+ if (element instanceof Gtk.DropDown) {
133
+ (element as Gtk.DropDown).setSelected(valueArray[0] as number);
134
+ } else {
135
+ (element as Gtk.ComboBox).setActive(valueArray[0] as number);
136
+ }
137
+ } else if (role === Gtk.AccessibleRole.LIST) {
138
+ const listBox = element as Gtk.ListBox;
139
+
140
+ for (const value of valueArray) {
141
+ const row = listBox.getRowAtIndex(value);
142
+
143
+ if (row) {
144
+ listBox.selectRow(row);
145
+ row.activate();
146
+ }
147
+ }
148
+ }
149
+
150
+ await tick();
151
+ };
152
+
153
+ const deselectOptions = async (element: Gtk.Widget, values: number | number[]): Promise<void> => {
154
+ const valueArray = Array.isArray(values) ? values : [values];
155
+
156
+ if (isListView(element)) {
157
+ const selectionModel = element.getModel() as Gtk.SelectionModel;
158
+
159
+ for (const pos of valueArray) {
160
+ selectionModel.unselectItem(pos);
161
+ }
162
+
163
+ await tick();
164
+ return;
165
+ }
166
+
167
+ const role = element.getAccessibleRole();
168
+
169
+ if (role !== Gtk.AccessibleRole.LIST) {
170
+ throw new Error("Cannot deselect options: only ListBox supports deselection");
171
+ }
172
+
173
+ const listBox = element as Gtk.ListBox;
174
+
175
+ for (const value of valueArray) {
176
+ const row = listBox.getRowAtIndex(value);
177
+
178
+ if (row) {
179
+ listBox.unselectRow(row as Gtk.ListBoxRow);
180
+ }
181
+ }
182
+
183
+ await tick();
184
+ };
185
+
186
+ const getOrCreateController = <T extends Gtk.EventController>(element: Gtk.Widget, controllerType: new () => T): T => {
187
+ const controllers = element.observeControllers();
188
+ const nItems = controllers.getNItems();
189
+
190
+ for (let i = 0; i < nItems; i++) {
191
+ const controller = controllers.getObject(i);
192
+ if (controller instanceof controllerType) {
193
+ return controller as T;
194
+ }
195
+ }
196
+
197
+ const controller = new controllerType();
198
+ element.addController(controller);
199
+ return controller;
200
+ };
201
+
202
+ const getSignalId = (target: Gtk.EventController, signalName: string): number => {
203
+ const gtype = typeFromName((target.constructor as typeof GObject).glibTypeName);
204
+ return signalLookup(signalName, gtype);
205
+ };
206
+
207
+ const hover = async (element: Gtk.Widget): Promise<void> => {
208
+ const controller = getOrCreateController(element, Gtk.EventControllerMotion);
209
+ signalEmitv(
210
+ [Value.newFromObject(controller), Value.newFromDouble(0), Value.newFromDouble(0)],
211
+ getSignalId(controller, "enter"),
212
+ 0,
213
+ );
214
+ await tick();
215
+ };
216
+
217
+ const unhover = async (element: Gtk.Widget): Promise<void> => {
218
+ const controller = getOrCreateController(element, Gtk.EventControllerMotion);
219
+ signalEmitv([Value.newFromObject(controller)], getSignalId(controller, "leave"), 0);
220
+ await tick();
221
+ };
222
+
223
+ const KEY_MAP: Record<string, number> = {
224
+ Enter: Gdk.KEY_Return,
225
+ Tab: Gdk.KEY_Tab,
226
+ Escape: Gdk.KEY_Escape,
227
+ Backspace: Gdk.KEY_BackSpace,
228
+ Delete: Gdk.KEY_Delete,
229
+ ArrowUp: Gdk.KEY_Up,
230
+ ArrowDown: Gdk.KEY_Down,
231
+ ArrowLeft: Gdk.KEY_Left,
232
+ ArrowRight: Gdk.KEY_Right,
233
+ Home: Gdk.KEY_Home,
234
+ End: Gdk.KEY_End,
235
+ PageUp: Gdk.KEY_Page_Up,
236
+ PageDown: Gdk.KEY_Page_Down,
237
+ Space: Gdk.KEY_space,
238
+ Shift: Gdk.KEY_Shift_L,
239
+ Control: Gdk.KEY_Control_L,
240
+ Alt: Gdk.KEY_Alt_L,
241
+ Meta: Gdk.KEY_Meta_L,
242
+ };
243
+
244
+ const parseKeyboardInput = (input: string): Array<{ keyval: number; press: boolean }> => {
245
+ const actions: Array<{ keyval: number; press: boolean }> = [];
246
+ let i = 0;
247
+
248
+ while (i < input.length) {
249
+ if (input[i] === "{") {
250
+ const endBrace = input.indexOf("}", i);
251
+ if (endBrace === -1) break;
252
+
253
+ let keyName = input.slice(i + 1, endBrace);
254
+ let press = true;
255
+ let release = true;
256
+
257
+ if (keyName.startsWith("/")) {
258
+ keyName = keyName.slice(1);
259
+ press = false;
260
+ } else if (keyName.endsWith(">")) {
261
+ keyName = keyName.slice(0, -1);
262
+ release = false;
263
+ }
264
+
265
+ const keyval = KEY_MAP[keyName];
266
+ if (keyval === undefined) {
267
+ throw new Error(`Unknown key: {${keyName}}`);
268
+ }
269
+ if (press) actions.push({ keyval, press: true });
270
+ if (release) actions.push({ keyval, press: false });
271
+
272
+ i = endBrace + 1;
273
+ } else {
274
+ const keyval = input.charCodeAt(i);
275
+ actions.push({ keyval, press: true });
276
+ actions.push({ keyval, press: false });
277
+ i++;
278
+ }
279
+ }
280
+
281
+ return actions;
282
+ };
283
+
284
+ const MODIFIER_KEYVAL_TO_MASK: Record<number, number> = {
285
+ [Gdk.KEY_Shift_L]: Gdk.ModifierType.SHIFT_MASK,
286
+ [Gdk.KEY_Shift_R]: Gdk.ModifierType.SHIFT_MASK,
287
+ [Gdk.KEY_Control_L]: Gdk.ModifierType.CONTROL_MASK,
288
+ [Gdk.KEY_Control_R]: Gdk.ModifierType.CONTROL_MASK,
289
+ [Gdk.KEY_Alt_L]: Gdk.ModifierType.ALT_MASK,
290
+ [Gdk.KEY_Alt_R]: Gdk.ModifierType.ALT_MASK,
291
+ [Gdk.KEY_Meta_L]: Gdk.ModifierType.META_MASK,
292
+ [Gdk.KEY_Meta_R]: Gdk.ModifierType.META_MASK,
293
+ };
294
+
295
+ let gdkModifierType: number | null = null;
296
+
297
+ const keyboard = async (element: Gtk.Widget, input: string): Promise<void> => {
298
+ gdkModifierType ??= typeFromName("GdkModifierType");
299
+ const controller = getOrCreateController(element, Gtk.EventControllerKey);
300
+ const actions = parseKeyboardInput(input);
301
+ let modifierState = 0;
302
+
303
+ for (const action of actions) {
304
+ const mask = MODIFIER_KEYVAL_TO_MASK[action.keyval];
305
+
306
+ if (mask) {
307
+ if (action.press) {
308
+ modifierState |= mask;
309
+ } else {
310
+ modifierState &= ~mask;
311
+ }
312
+ }
313
+
314
+ const signalName = action.press ? "key-pressed" : "key-released";
315
+ const returnValue = action.press ? Value.newFromBoolean(false) : undefined;
316
+ signalEmitv(
317
+ [
318
+ Value.newFromObject(controller),
319
+ Value.newFromUint(action.keyval),
320
+ Value.newFromUint(0),
321
+ Value.newFromFlags(gdkModifierType, modifierState),
322
+ ],
323
+ getSignalId(controller, signalName),
324
+ 0,
325
+ returnValue,
326
+ );
327
+
328
+ if (action.press && action.keyval === Gdk.KEY_Return && isEditable(element)) {
329
+ await fireEvent(element, "activate");
330
+ }
331
+ }
332
+
333
+ await tick();
334
+ };
335
+
336
+ /**
337
+ * Pointer input actions for simulating mouse interactions.
338
+ *
339
+ * - `"click"` or `"[MouseLeft]"`: Full click (press + release)
340
+ * - `"down"` or `"[MouseLeft>]"`: Press and hold
341
+ * - `"up"` or `"[/MouseLeft]"`: Release
342
+ */
343
+ export type PointerInput = "click" | "down" | "up" | "[MouseLeft]" | "[MouseLeft>]" | "[/MouseLeft]";
344
+
345
+ const pointer = async (element: Gtk.Widget, input: PointerInput): Promise<void> => {
346
+ const controller = getOrCreateController(element, Gtk.GestureClick);
347
+ const pressedArgs = [
348
+ Value.newFromObject(controller),
349
+ Value.newFromInt(1),
350
+ Value.newFromDouble(0),
351
+ Value.newFromDouble(0),
352
+ ];
353
+ const releasedArgs = [
354
+ Value.newFromObject(controller),
355
+ Value.newFromInt(1),
356
+ Value.newFromDouble(0),
357
+ Value.newFromDouble(0),
358
+ ];
359
+
360
+ if (input === "[MouseLeft]" || input === "click") {
361
+ signalEmitv(pressedArgs, getSignalId(controller, "pressed"), 0);
362
+ signalEmitv(releasedArgs, getSignalId(controller, "released"), 0);
363
+ } else if (input === "[MouseLeft>]" || input === "down") {
364
+ signalEmitv(pressedArgs, getSignalId(controller, "pressed"), 0);
365
+ } else if (input === "[/MouseLeft]" || input === "up") {
366
+ signalEmitv(releasedArgs, getSignalId(controller, "released"), 0);
367
+ }
368
+
369
+ await tick();
370
+ };
371
+
372
+ /**
373
+ * User interaction utilities for testing.
374
+ *
375
+ * Simulates user actions like clicking, typing, and selecting.
376
+ * All methods are async and wait for GTK event processing.
377
+ *
378
+ * @example
379
+ * ```tsx
380
+ * import { render, screen, userEvent } from "@gtkx/testing";
381
+ *
382
+ * test("form submission", async () => {
383
+ * await render(<LoginForm />);
384
+ *
385
+ * const input = await screen.findByRole(Gtk.AccessibleRole.TEXT_BOX);
386
+ * await userEvent.type(input, "username");
387
+ *
388
+ * const button = await screen.findByRole(Gtk.AccessibleRole.BUTTON);
389
+ * await userEvent.click(button);
390
+ * });
391
+ * ```
392
+ */
393
+ export const userEvent = {
394
+ /**
395
+ * Activates a widget.
396
+ *
397
+ * Uses GTK's native {@link Gtk.Widget.activate} to trigger the widget's
398
+ * default action — clicking buttons, toggling checkboxes/switches, etc.
399
+ */
400
+ click,
401
+ /**
402
+ * Double-clicks a widget.
403
+ *
404
+ * Emits pressed/released signals with n_press=1, then n_press=2.
405
+ */
406
+ dblClick,
407
+ /**
408
+ * Triple-clicks a widget.
409
+ *
410
+ * Emits pressed/released signals with n_press=1, 2, then 3. Useful for text selection.
411
+ */
412
+ tripleClick,
413
+ /**
414
+ * Simulates Tab key navigation.
415
+ *
416
+ * @param element - Starting element
417
+ * @param options - Use `shift: true` for backwards navigation
418
+ */
419
+ tab,
420
+ /**
421
+ * Types text into an editable widget.
422
+ *
423
+ * Appends text to the current content. Works with Entry, SearchEntry,
424
+ * and SpinButton widgets.
425
+ *
426
+ * @param element - The editable widget
427
+ * @param text - Text to type
428
+ */
429
+ type,
430
+ /**
431
+ * Clears an editable widget's content.
432
+ *
433
+ * Sets the text to empty string.
434
+ */
435
+ clear,
436
+ /**
437
+ * Selects options in a dropdown or list.
438
+ *
439
+ * Works with DropDown, ComboBox, ListBox, ListView, GridView, and ColumnView.
440
+ *
441
+ * @param element - The selectable widget
442
+ * @param values - Index or array of indices to select
443
+ */
444
+ selectOptions,
445
+ /**
446
+ * Deselects options in a list.
447
+ *
448
+ * Works with ListBox and multi-selection list views.
449
+ *
450
+ * @param element - The selectable widget
451
+ * @param values - Index or array of indices to deselect
452
+ */
453
+ deselectOptions,
454
+ /**
455
+ * Simulates mouse entering a widget (hover).
456
+ *
457
+ * Triggers the "enter" signal on the widget's EventControllerMotion.
458
+ */
459
+ hover,
460
+ /**
461
+ * Simulates mouse leaving a widget (unhover).
462
+ *
463
+ * Triggers the "leave" signal on the widget's EventControllerMotion.
464
+ */
465
+ unhover,
466
+ /**
467
+ * Simulates keyboard input.
468
+ *
469
+ * Supports special keys in braces: `{Enter}`, `{Tab}`, `{Escape}`, etc.
470
+ * Use `{Key>}` to hold a key down, `{/Key}` to release.
471
+ *
472
+ * @example
473
+ * ```tsx
474
+ * await userEvent.keyboard(element, "hello");
475
+ * await userEvent.keyboard(element, "{Enter}");
476
+ * await userEvent.keyboard(element, "{Shift>}A{/Shift}");
477
+ * ```
478
+ */
479
+ keyboard,
480
+ /**
481
+ * Simulates pointer (mouse) input.
482
+ *
483
+ * Supports: `"click"`, `"[MouseLeft]"`, `"down"`, `"up"`.
484
+ *
485
+ * @example
486
+ * ```tsx
487
+ * await userEvent.pointer(element, "click");
488
+ * await userEvent.pointer(element, "[MouseLeft]");
489
+ * ```
490
+ */
491
+ pointer,
492
+ };