@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
@@ -0,0 +1,115 @@
1
+ import type * as Gtk from "@gtkx/ffi/gtk";
2
+ import { getConfig } from "./config.js";
3
+ import { buildTimeoutError } from "./error-builder.js";
4
+ import type { WaitForOptions } from "./types.js";
5
+
6
+ const DEFAULT_INTERVAL = 50;
7
+
8
+ /**
9
+ * Waits for a callback to succeed.
10
+ *
11
+ * Repeatedly calls the callback until it returns without throwing,
12
+ * or until the timeout is reached.
13
+ *
14
+ * @param callback - Function to execute repeatedly
15
+ * @param options - Timeout and interval configuration
16
+ * @returns Promise resolving to the callback's return value
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * import { waitFor } from "@gtkx/testing";
21
+ *
22
+ * await waitFor(() => {
23
+ * expect(counter.value).toBe(5);
24
+ * }, { timeout: 2000 });
25
+ * ```
26
+ */
27
+ export const waitFor = async <T>(callback: () => T | Promise<T>, options?: WaitForOptions): Promise<T> => {
28
+ const config = getConfig();
29
+ const { timeout = config.asyncUtilTimeout, interval = DEFAULT_INTERVAL, onTimeout } = options ?? {};
30
+ const startTime = Date.now();
31
+ let lastError: Error | null = null;
32
+
33
+ while (Date.now() - startTime < timeout) {
34
+ try {
35
+ return await callback();
36
+ } catch (error) {
37
+ lastError = error as Error;
38
+ await new Promise((resolve) => setTimeout(resolve, interval));
39
+ }
40
+ }
41
+
42
+ const timeoutError = buildTimeoutError(timeout, lastError);
43
+ if (onTimeout) {
44
+ throw onTimeout(timeoutError);
45
+ }
46
+ throw timeoutError;
47
+ };
48
+
49
+ type ElementOrCallback = Gtk.Widget | (() => Gtk.Widget | null);
50
+
51
+ const getElement = (elementOrCallback: ElementOrCallback): Gtk.Widget | null => {
52
+ if (typeof elementOrCallback === "function") {
53
+ return elementOrCallback();
54
+ }
55
+ return elementOrCallback;
56
+ };
57
+
58
+ const isElementRemoved = (element: Gtk.Widget | null): boolean => {
59
+ if (element === null) return true;
60
+
61
+ try {
62
+ const parent = element.getParent();
63
+ return parent === null;
64
+ } catch {
65
+ return true;
66
+ }
67
+ };
68
+
69
+ /**
70
+ * Waits for an element to be removed from the widget tree.
71
+ *
72
+ * Polls until the element no longer has a parent or no longer exists.
73
+ *
74
+ * @param elementOrCallback - Element or function returning element to watch
75
+ * @param options - Timeout and interval configuration
76
+ *
77
+ * @example
78
+ * ```tsx
79
+ * import { waitForElementToBeRemoved } from "@gtkx/testing";
80
+ *
81
+ * const loader = await screen.findByRole(Gtk.AccessibleRole.PROGRESS_BAR);
82
+ * await waitForElementToBeRemoved(loader);
83
+ * // Loader is now gone
84
+ * ```
85
+ */
86
+ export const waitForElementToBeRemoved = async (
87
+ elementOrCallback: ElementOrCallback,
88
+ options?: WaitForOptions,
89
+ ): Promise<void> => {
90
+ const config = getConfig();
91
+ const { timeout = config.asyncUtilTimeout, interval = DEFAULT_INTERVAL, onTimeout } = options ?? {};
92
+
93
+ const initialElement = getElement(elementOrCallback);
94
+ if (initialElement === null || isElementRemoved(initialElement)) {
95
+ throw new Error(
96
+ "Element already removed: waitForElementToBeRemoved requires the element to be present initially",
97
+ );
98
+ }
99
+
100
+ const startTime = Date.now();
101
+
102
+ while (Date.now() - startTime < timeout) {
103
+ const element = getElement(elementOrCallback);
104
+ if (isElementRemoved(element)) {
105
+ return;
106
+ }
107
+ await new Promise((resolve) => setTimeout(resolve, interval));
108
+ }
109
+
110
+ const timeoutError = new Error(`Timed out after ${timeout}ms waiting for element to be removed.`);
111
+ if (onTimeout) {
112
+ throw onTimeout(timeoutError);
113
+ }
114
+ throw timeoutError;
115
+ };
@@ -0,0 +1,206 @@
1
+ import { getNativeInterface } from "@gtkx/ffi";
2
+ import * as Gtk from "@gtkx/ffi/gtk";
3
+
4
+ const ROLES_WITH_INTERNAL_LABELS = new Set([
5
+ Gtk.AccessibleRole.BUTTON,
6
+ Gtk.AccessibleRole.TOGGLE_BUTTON,
7
+ Gtk.AccessibleRole.CHECKBOX,
8
+ Gtk.AccessibleRole.RADIO,
9
+ Gtk.AccessibleRole.LIST_ITEM,
10
+ Gtk.AccessibleRole.MENU_ITEM,
11
+ Gtk.AccessibleRole.MENU_ITEM_CHECKBOX,
12
+ Gtk.AccessibleRole.MENU_ITEM_RADIO,
13
+ Gtk.AccessibleRole.ROW,
14
+ Gtk.AccessibleRole.TAB,
15
+ Gtk.AccessibleRole.LINK,
16
+ ]);
17
+
18
+ const isInternalLabel = (widget: Gtk.Widget): boolean => {
19
+ if (widget.getAccessibleRole() !== Gtk.AccessibleRole.LABEL) return false;
20
+
21
+ const parent = widget.getParent();
22
+ if (!parent) return false;
23
+
24
+ if (parent.getAccessibleRole === undefined) return false;
25
+ const parentRole = parent.getAccessibleRole();
26
+ if (!parentRole) return false;
27
+
28
+ if (ROLES_WITH_INTERNAL_LABELS.has(parentRole)) return true;
29
+
30
+ const labelText = getLabelText(widget);
31
+ if (!labelText) return false;
32
+
33
+ let ancestor: Gtk.Widget | null = parent;
34
+ while (ancestor) {
35
+ if (ancestor.getAccessibleRole === undefined) return false;
36
+ const role = ancestor.getAccessibleRole();
37
+ if (role && ROLES_WITH_INTERNAL_LABELS.has(role)) {
38
+ return getDefaultText(ancestor) === labelText;
39
+ }
40
+ ancestor = ancestor.getParent();
41
+ }
42
+
43
+ return false;
44
+ };
45
+
46
+ const getLabelText = (widget: Gtk.Widget): string | null => {
47
+ const asLabel = widget as Gtk.Label;
48
+ const asInscription = widget as Gtk.Inscription;
49
+ return asLabel.getLabel?.() ?? asInscription.getText?.() ?? null;
50
+ };
51
+
52
+ const getDefaultText = (widget: Gtk.Widget): string | null => {
53
+ if ("getLabel" in widget && typeof widget.getLabel === "function") {
54
+ return (widget.getLabel() as string) ?? null;
55
+ }
56
+
57
+ if ("getText" in widget && typeof widget.getText === "function") {
58
+ return (widget.getText() as string) ?? null;
59
+ }
60
+
61
+ if ("getTitle" in widget && typeof widget.getTitle === "function") {
62
+ return (widget.getTitle() as string) ?? null;
63
+ }
64
+
65
+ return getNativeInterface(widget, Gtk.Editable)?.getText() ?? null;
66
+ };
67
+
68
+ const collectChildLabels = (widget: Gtk.Widget): string[] => {
69
+ const labels: string[] = [];
70
+ let child = widget.getFirstChild();
71
+
72
+ while (child) {
73
+ if (child.getAccessibleRole() === Gtk.AccessibleRole.LABEL) {
74
+ const labelText = getLabelText(child);
75
+ if (labelText) labels.push(labelText);
76
+ }
77
+
78
+ labels.push(...collectChildLabels(child));
79
+ child = child.getNextSibling();
80
+ }
81
+
82
+ return labels;
83
+ };
84
+
85
+ /**
86
+ * Extracts the accessible text content from a widget based on its role.
87
+ *
88
+ * @param widget - The widget to extract text from
89
+ * @returns The accessible text or null if none found
90
+ */
91
+ export const getWidgetText = (widget: Gtk.Widget): string | null => {
92
+ if (isInternalLabel(widget)) return null;
93
+
94
+ const role = widget.getAccessibleRole();
95
+ if (role === undefined) return null;
96
+
97
+ switch (role) {
98
+ case Gtk.AccessibleRole.BUTTON:
99
+ case Gtk.AccessibleRole.LINK:
100
+ case Gtk.AccessibleRole.TAB:
101
+ case Gtk.AccessibleRole.MENU_ITEM:
102
+ case Gtk.AccessibleRole.MENU_ITEM_CHECKBOX:
103
+ case Gtk.AccessibleRole.MENU_ITEM_RADIO: {
104
+ const directLabel = getDefaultText(widget);
105
+ if (directLabel) return directLabel;
106
+
107
+ const childLabels = collectChildLabels(widget);
108
+ return childLabels.length > 0 ? childLabels.join(" ") : null;
109
+ }
110
+ case Gtk.AccessibleRole.TAB_PANEL: {
111
+ const parent = widget.getParent();
112
+ if (parent) {
113
+ const stack = parent as Gtk.Stack;
114
+ const page = stack.getPage?.(widget);
115
+ if (page) {
116
+ return page.getTitle() ?? null;
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+ default:
122
+ return getDefaultText(widget);
123
+ }
124
+ };
125
+
126
+ /**
127
+ * Gets the test ID from a widget (uses the widget's name property).
128
+ *
129
+ * @param widget - The widget to get the test ID from
130
+ * @returns The test ID or null if not set
131
+ */
132
+ export const getWidgetTestId = (widget: Gtk.Widget): string | null => {
133
+ return widget.getName();
134
+ };
135
+
136
+ /**
137
+ * Gets the checked state from toggle-like widgets.
138
+ *
139
+ * @param widget - The widget to get the checked state from
140
+ * @returns The checked state or null if not applicable
141
+ */
142
+ export const getWidgetCheckedState = (widget: Gtk.Widget): boolean | null => {
143
+ const role = widget.getAccessibleRole();
144
+
145
+ switch (role) {
146
+ case Gtk.AccessibleRole.CHECKBOX:
147
+ case Gtk.AccessibleRole.RADIO:
148
+ return (widget as Gtk.CheckButton).getActive();
149
+ case Gtk.AccessibleRole.TOGGLE_BUTTON:
150
+ return (widget as Gtk.ToggleButton).getActive();
151
+ case Gtk.AccessibleRole.SWITCH:
152
+ return (widget as Gtk.Switch).getActive();
153
+ default:
154
+ return null;
155
+ }
156
+ };
157
+
158
+ /**
159
+ * Gets the pressed state from toggle button widgets.
160
+ *
161
+ * @param widget - The widget to get the pressed state from
162
+ * @returns The pressed state or null if not applicable
163
+ */
164
+ export const getWidgetPressedState = (widget: Gtk.Widget): boolean | null => {
165
+ const role = widget.getAccessibleRole();
166
+
167
+ if (role === Gtk.AccessibleRole.TOGGLE_BUTTON) {
168
+ return (widget as Gtk.ToggleButton).getActive();
169
+ }
170
+
171
+ return null;
172
+ };
173
+
174
+ /**
175
+ * Gets the expanded state from expander widgets.
176
+ *
177
+ * @param widget - The widget to get the expanded state from
178
+ * @returns The expanded state or null if not applicable
179
+ */
180
+ export const getWidgetExpandedState = (widget: Gtk.Widget): boolean | null => {
181
+ if (widget instanceof Gtk.Expander) {
182
+ return widget.getExpanded();
183
+ }
184
+
185
+ if (widget instanceof Gtk.TreeExpander) {
186
+ return widget.getListRow()?.getExpanded() ?? null;
187
+ }
188
+
189
+ return null;
190
+ };
191
+
192
+ /**
193
+ * Gets the selected state from selectable widgets.
194
+ *
195
+ * @param widget - The widget to get the selected state from
196
+ * @returns The selected state or null if not applicable
197
+ */
198
+ export const getWidgetSelectedState = (widget: Gtk.Widget): boolean | null => {
199
+ const role = widget.getAccessibleRole();
200
+
201
+ if (role === Gtk.AccessibleRole.ROW) {
202
+ return (widget as Gtk.ListBoxRow).isSelected();
203
+ }
204
+
205
+ return null;
206
+ };
package/src/widget.ts ADDED
@@ -0,0 +1,15 @@
1
+ import * as Gtk from "@gtkx/ffi/gtk";
2
+
3
+ const EDITABLE_ROLES = new Set([
4
+ Gtk.AccessibleRole.TEXT_BOX,
5
+ Gtk.AccessibleRole.SEARCH_BOX,
6
+ Gtk.AccessibleRole.SPIN_BUTTON,
7
+ ]);
8
+
9
+ export const isEditable = (widget: unknown): widget is Gtk.Editable => {
10
+ if (!(widget instanceof Gtk.Widget)) {
11
+ return false;
12
+ }
13
+
14
+ return EDITABLE_ROLES.has(widget.getAccessibleRole());
15
+ };
package/src/within.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type * as Gtk from "@gtkx/ffi/gtk";
2
+ import { bindQueries } from "./bind-queries.js";
3
+ import type { BoundQueries } from "./types.js";
4
+
5
+ /**
6
+ * Creates scoped query methods for a container widget.
7
+ *
8
+ * Use this to query within a specific section of your UI rather than
9
+ * the entire application.
10
+ *
11
+ * @param container - The widget to scope queries to
12
+ * @returns Object with query methods bound to the container
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * import { render, within } from "@gtkx/testing";
17
+ *
18
+ * test("scoped queries", async () => {
19
+ * await render(<MyPage />);
20
+ *
21
+ * const sidebar = await screen.findByRole(Gtk.AccessibleRole.NAVIGATION);
22
+ * const sidebarQueries = within(sidebar);
23
+ *
24
+ * // Only searches within the sidebar
25
+ * const navButton = await sidebarQueries.findByRole(Gtk.AccessibleRole.BUTTON);
26
+ * });
27
+ * ```
28
+ *
29
+ * @see {@link screen} for global queries
30
+ */
31
+ export const within = (container: Gtk.Widget): BoundQueries => bindQueries(container);