@gtkx/testing 0.20.0 → 1.0.0-rc.1

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 (216) hide show
  1. package/README.md +122 -55
  2. package/dist/act.d.ts +19 -0
  3. package/dist/act.d.ts.map +1 -0
  4. package/dist/act.js +48 -0
  5. package/dist/act.js.map +1 -0
  6. package/dist/bound-queries.d.ts +32 -0
  7. package/dist/bound-queries.d.ts.map +1 -0
  8. package/dist/bound-queries.js +2 -0
  9. package/dist/bound-queries.js.map +1 -0
  10. package/dist/build-queries.d.ts +16 -0
  11. package/dist/build-queries.d.ts.map +1 -0
  12. package/dist/build-queries.js +103 -0
  13. package/dist/build-queries.js.map +1 -0
  14. package/dist/cleanup-registry.d.ts +4 -0
  15. package/dist/cleanup-registry.d.ts.map +1 -0
  16. package/dist/cleanup-registry.js +11 -0
  17. package/dist/cleanup-registry.js.map +1 -0
  18. package/dist/config.d.ts +18 -51
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +9 -40
  21. package/dist/config.js.map +1 -1
  22. package/dist/editable.d.ts +7 -0
  23. package/dist/editable.d.ts.map +1 -0
  24. package/dist/editable.js +24 -0
  25. package/dist/editable.js.map +1 -0
  26. package/dist/errors.d.ts +29 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +119 -0
  29. package/dist/errors.js.map +1 -0
  30. package/dist/fire-event.d.ts +7 -25
  31. package/dist/fire-event.d.ts.map +1 -1
  32. package/dist/fire-event.js +9 -29
  33. package/dist/fire-event.js.map +1 -1
  34. package/dist/frame-sync.d.ts +3 -0
  35. package/dist/frame-sync.d.ts.map +1 -0
  36. package/dist/frame-sync.js +30 -0
  37. package/dist/frame-sync.js.map +1 -0
  38. package/dist/index.d.ts +16 -11
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +10 -6
  41. package/dist/index.js.map +1 -1
  42. package/dist/matchers.d.ts +55 -0
  43. package/dist/matchers.d.ts.map +1 -0
  44. package/dist/matchers.js +103 -0
  45. package/dist/matchers.js.map +1 -0
  46. package/dist/pretty-widget.d.ts +21 -25
  47. package/dist/pretty-widget.d.ts.map +1 -1
  48. package/dist/pretty-widget.js +69 -115
  49. package/dist/pretty-widget.js.map +1 -1
  50. package/dist/queries.d.ts +130 -177
  51. package/dist/queries.d.ts.map +1 -1
  52. package/dist/queries.js +212 -325
  53. package/dist/queries.js.map +1 -1
  54. package/dist/register-test-hooks.d.ts +2 -0
  55. package/dist/register-test-hooks.d.ts.map +1 -0
  56. package/dist/register-test-hooks.js +15 -0
  57. package/dist/register-test-hooks.js.map +1 -0
  58. package/dist/render-hook.d.ts +14 -35
  59. package/dist/render-hook.d.ts.map +1 -1
  60. package/dist/render-hook.js +8 -45
  61. package/dist/render-hook.js.map +1 -1
  62. package/dist/render.d.ts +11 -41
  63. package/dist/render.d.ts.map +1 -1
  64. package/dist/render.js +129 -118
  65. package/dist/render.js.map +1 -1
  66. package/dist/role-helpers.d.ts +16 -51
  67. package/dist/role-helpers.d.ts.map +1 -1
  68. package/dist/role-helpers.js +39 -58
  69. package/dist/role-helpers.js.map +1 -1
  70. package/dist/screen.d.ts +6 -63
  71. package/dist/screen.d.ts.map +1 -1
  72. package/dist/screen.js +21 -109
  73. package/dist/screen.js.map +1 -1
  74. package/dist/screenshot.d.ts +20 -23
  75. package/dist/screenshot.d.ts.map +1 -1
  76. package/dist/screenshot.js +89 -48
  77. package/dist/screenshot.js.map +1 -1
  78. package/dist/suggestions.d.ts +22 -0
  79. package/dist/suggestions.d.ts.map +1 -0
  80. package/dist/suggestions.js +56 -0
  81. package/dist/suggestions.js.map +1 -0
  82. package/dist/traversal.d.ts +10 -8
  83. package/dist/traversal.d.ts.map +1 -1
  84. package/dist/traversal.js +28 -13
  85. package/dist/traversal.js.map +1 -1
  86. package/dist/types.d.ts +103 -172
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/types.js.map +1 -1
  89. package/dist/user-event/adjustment.d.ts +8 -0
  90. package/dist/user-event/adjustment.d.ts.map +1 -0
  91. package/dist/user-event/adjustment.js +33 -0
  92. package/dist/user-event/adjustment.js.map +1 -0
  93. package/dist/user-event/click.d.ts +7 -0
  94. package/dist/user-event/click.d.ts.map +1 -0
  95. package/dist/user-event/click.js +65 -0
  96. package/dist/user-event/click.js.map +1 -0
  97. package/dist/user-event/controller.d.ts +7 -0
  98. package/dist/user-event/controller.d.ts.map +1 -0
  99. package/dist/user-event/controller.js +28 -0
  100. package/dist/user-event/controller.js.map +1 -0
  101. package/dist/user-event/dispatch.d.ts +7 -0
  102. package/dist/user-event/dispatch.d.ts.map +1 -0
  103. package/dist/user-event/dispatch.js +10 -0
  104. package/dist/user-event/dispatch.js.map +1 -0
  105. package/dist/user-event/event-wrapper.d.ts +3 -0
  106. package/dist/user-event/event-wrapper.d.ts.map +1 -0
  107. package/dist/user-event/event-wrapper.js +48 -0
  108. package/dist/user-event/event-wrapper.js.map +1 -0
  109. package/dist/user-event/gesture.d.ts +31 -0
  110. package/dist/user-event/gesture.d.ts.map +1 -0
  111. package/dist/user-event/gesture.js +105 -0
  112. package/dist/user-event/gesture.js.map +1 -0
  113. package/dist/user-event/index.d.ts +44 -0
  114. package/dist/user-event/index.d.ts.map +1 -0
  115. package/dist/user-event/index.js +38 -0
  116. package/dist/user-event/index.js.map +1 -0
  117. package/dist/user-event/keyboard.d.ts +9 -0
  118. package/dist/user-event/keyboard.d.ts.map +1 -0
  119. package/dist/user-event/keyboard.js +173 -0
  120. package/dist/user-event/keyboard.js.map +1 -0
  121. package/dist/user-event/pointer.d.ts +6 -0
  122. package/dist/user-event/pointer.d.ts.map +1 -0
  123. package/dist/user-event/pointer.js +27 -0
  124. package/dist/user-event/pointer.js.map +1 -0
  125. package/dist/user-event/selection.d.ts +4 -0
  126. package/dist/user-event/selection.d.ts.map +1 -0
  127. package/dist/user-event/selection.js +106 -0
  128. package/dist/user-event/selection.js.map +1 -0
  129. package/dist/user-event/state.d.ts +6 -0
  130. package/dist/user-event/state.d.ts.map +1 -0
  131. package/dist/user-event/state.js +2 -0
  132. package/dist/user-event/state.js.map +1 -0
  133. package/dist/user-event/text.d.ts +17 -0
  134. package/dist/user-event/text.d.ts.map +1 -0
  135. package/dist/user-event/text.js +107 -0
  136. package/dist/user-event/text.js.map +1 -0
  137. package/dist/wait-for.d.ts +13 -32
  138. package/dist/wait-for.d.ts.map +1 -1
  139. package/dist/wait-for.js +62 -73
  140. package/dist/wait-for.js.map +1 -1
  141. package/dist/widget-accessible-properties.d.ts +33 -0
  142. package/dist/widget-accessible-properties.d.ts.map +1 -0
  143. package/dist/widget-accessible-properties.js +215 -0
  144. package/dist/widget-accessible-properties.js.map +1 -0
  145. package/dist/within.d.ts +9 -26
  146. package/dist/within.d.ts.map +1 -1
  147. package/dist/within.js +15 -25
  148. package/dist/within.js.map +1 -1
  149. package/package.json +21 -12
  150. package/src/act.ts +68 -0
  151. package/src/bound-queries.ts +36 -0
  152. package/src/build-queries.ts +185 -0
  153. package/src/cleanup-registry.ts +14 -0
  154. package/src/config.ts +28 -59
  155. package/src/editable.ts +30 -0
  156. package/src/errors.ts +130 -0
  157. package/src/fire-event.ts +11 -38
  158. package/src/frame-sync.ts +34 -0
  159. package/src/index.ts +60 -18
  160. package/src/matchers.ts +200 -0
  161. package/src/pretty-widget.ts +99 -138
  162. package/src/queries.ts +321 -425
  163. package/src/register-test-hooks.ts +16 -0
  164. package/src/render-hook.tsx +31 -44
  165. package/src/render.tsx +161 -149
  166. package/src/role-helpers.ts +47 -67
  167. package/src/screen.ts +21 -114
  168. package/src/screenshot.ts +104 -55
  169. package/src/suggestions.ts +87 -0
  170. package/src/traversal.ts +32 -18
  171. package/src/types.ts +115 -176
  172. package/src/user-event/adjustment.ts +49 -0
  173. package/src/user-event/click.ts +70 -0
  174. package/src/user-event/controller.ts +44 -0
  175. package/src/user-event/dispatch.ts +23 -0
  176. package/src/user-event/event-wrapper.ts +54 -0
  177. package/src/user-event/gesture.ts +154 -0
  178. package/src/user-event/index.ts +74 -0
  179. package/src/user-event/keyboard.ts +204 -0
  180. package/src/user-event/pointer.ts +34 -0
  181. package/src/user-event/selection.ts +122 -0
  182. package/src/user-event/state.ts +6 -0
  183. package/src/user-event/text.ts +137 -0
  184. package/src/wait-for.ts +79 -80
  185. package/src/widget-accessible-properties.ts +244 -0
  186. package/src/within.ts +26 -27
  187. package/dist/bind-queries.d.ts +0 -14
  188. package/dist/bind-queries.d.ts.map +0 -1
  189. package/dist/bind-queries.js +0 -29
  190. package/dist/bind-queries.js.map +0 -1
  191. package/dist/error-builder.d.ts +0 -28
  192. package/dist/error-builder.d.ts.map +0 -1
  193. package/dist/error-builder.js +0 -81
  194. package/dist/error-builder.js.map +0 -1
  195. package/dist/timing.d.ts +0 -18
  196. package/dist/timing.d.ts.map +0 -1
  197. package/dist/timing.js +0 -18
  198. package/dist/timing.js.map +0 -1
  199. package/dist/user-event.d.ts +0 -138
  200. package/dist/user-event.d.ts.map +0 -1
  201. package/dist/user-event.js +0 -411
  202. package/dist/user-event.js.map +0 -1
  203. package/dist/widget-text.d.ts +0 -69
  204. package/dist/widget-text.d.ts.map +0 -1
  205. package/dist/widget-text.js +0 -177
  206. package/dist/widget-text.js.map +0 -1
  207. package/dist/widget.d.ts +0 -3
  208. package/dist/widget.d.ts.map +0 -1
  209. package/dist/widget.js +0 -13
  210. package/dist/widget.js.map +0 -1
  211. package/src/bind-queries.ts +0 -52
  212. package/src/error-builder.ts +0 -102
  213. package/src/timing.ts +0 -17
  214. package/src/user-event.ts +0 -492
  215. package/src/widget-text.ts +0 -202
  216. package/src/widget.ts +0 -15
@@ -0,0 +1,137 @@
1
+ import * as Gdk from "@gtkx/gi/gdk";
2
+ import * as GObject from "@gtkx/gi/gobject";
3
+ import * as Gtk from "@gtkx/gi/gtk";
4
+ import { EDITABLE_ROLES, type EditableTarget, getEditableDelegate, isEditable } from "../editable.js";
5
+ import { formatRoleList } from "../role-helpers.js";
6
+ import { wrapEvent } from "./event-wrapper.js";
7
+
8
+ /** Options for {@link type}. */
9
+ export type TypeOptions = {
10
+ /** Do not focus the widget before typing. */
11
+ skipClick?: boolean | undefined;
12
+ /** Select from this offset before typing, replacing the selected range with the typed text. */
13
+ initialSelectionStart?: number | undefined;
14
+ /** End offset of the initial selection (defaults to the start offset). */
15
+ initialSelectionEnd?: number | undefined;
16
+ };
17
+
18
+ const EDITABLE_REQUIRED = `expected editable widget (${formatRoleList(EDITABLE_ROLES)})`;
19
+
20
+ const insertEditableText = (widget: EditableTarget, text: string): void => {
21
+ if (widget instanceof Gtk.TextView) {
22
+ widget.emit("insert-at-cursor", text);
23
+ return;
24
+ }
25
+ const target = getEditableDelegate(widget) ?? widget;
26
+ if (target instanceof Gtk.Text) {
27
+ target.emit("insert-at-cursor", text);
28
+ return;
29
+ }
30
+
31
+ const position = widget.getPosition();
32
+ const newPosition = widget.insertText(text, text.length, position);
33
+ widget.setPosition(newPosition);
34
+ };
35
+
36
+ const readSelection = (widget: EditableTarget): string => {
37
+ if (widget instanceof Gtk.TextView) {
38
+ const buffer = widget.getBuffer();
39
+ const [hasSelection, start, end] = buffer.getSelectionBounds();
40
+ return hasSelection ? buffer.getText(start, end, true) : "";
41
+ }
42
+ const [hasSelection, start, end] = widget.getSelectionBounds();
43
+ return hasSelection ? widget.getChars(start, end) : "";
44
+ };
45
+
46
+ const deleteSelection = (widget: EditableTarget): void => {
47
+ if (widget instanceof Gtk.TextView) {
48
+ widget.getBuffer().deleteSelection(false, true);
49
+ return;
50
+ }
51
+ widget.deleteSelection();
52
+ };
53
+
54
+ const setEditableText = (widget: EditableTarget, text: string): void => {
55
+ if (widget instanceof Gtk.TextView) {
56
+ widget.getBuffer().setText(text, text.length);
57
+ return;
58
+ }
59
+ widget.setText(text);
60
+ };
61
+
62
+ const applyInitialSelection = (widget: EditableTarget, options: TypeOptions): void => {
63
+ if (options.initialSelectionStart === undefined) return;
64
+ const start = options.initialSelectionStart;
65
+ const end = options.initialSelectionEnd ?? start;
66
+ if (widget instanceof Gtk.TextView) {
67
+ const buffer = widget.getBuffer();
68
+ buffer.selectRange(buffer.getIterAtOffset(start), buffer.getIterAtOffset(end));
69
+ if (end !== start) deleteSelection(widget);
70
+ return;
71
+ }
72
+ widget.selectRegion(start, end);
73
+ if (end !== start) widget.deleteSelection();
74
+ widget.setPosition(start);
75
+ };
76
+
77
+ const writeClipboardText = (widget: Gtk.Widget, text: string): void => {
78
+ const value = GObject.buildValue(GObject.TYPE_STRING, (v) => v.setString(text));
79
+ widget.getClipboard().set(value);
80
+ };
81
+
82
+ export const resetClipboard = (): void => {
83
+ Gdk.Display.getDefault()?.getClipboard().setContent(null);
84
+ };
85
+
86
+ export const type = (widget: Gtk.Widget, text: string, options?: TypeOptions): Promise<void> =>
87
+ wrapEvent(widget, () => {
88
+ if (!isEditable(widget)) {
89
+ throw new Error(`Cannot type into element: ${EDITABLE_REQUIRED}`);
90
+ }
91
+
92
+ if (!options?.skipClick) {
93
+ widget.grabFocus();
94
+ }
95
+
96
+ applyInitialSelection(widget, options ?? {});
97
+ insertEditableText(widget, text);
98
+ });
99
+
100
+ export const clear = (widget: Gtk.Widget): Promise<void> =>
101
+ wrapEvent(widget, () => {
102
+ if (!isEditable(widget)) {
103
+ throw new Error(`Cannot clear element: ${EDITABLE_REQUIRED}`);
104
+ }
105
+
106
+ setEditableText(widget, "");
107
+ });
108
+
109
+ export const copy = (widget: Gtk.Widget): Promise<void> =>
110
+ wrapEvent(widget, () => {
111
+ if (!isEditable(widget)) {
112
+ throw new Error(`Cannot copy: ${EDITABLE_REQUIRED}`);
113
+ }
114
+
115
+ writeClipboardText(widget, readSelection(widget));
116
+ });
117
+
118
+ export const cut = (widget: Gtk.Widget): Promise<void> =>
119
+ wrapEvent(widget, () => {
120
+ if (!isEditable(widget)) {
121
+ throw new Error(`Cannot cut: ${EDITABLE_REQUIRED}`);
122
+ }
123
+
124
+ writeClipboardText(widget, readSelection(widget));
125
+ deleteSelection(widget);
126
+ });
127
+
128
+ export const paste = async (widget: Gtk.Widget, text?: string): Promise<void> => {
129
+ if (!isEditable(widget)) {
130
+ throw new Error(`Cannot paste: ${EDITABLE_REQUIRED}`);
131
+ }
132
+
133
+ const content = text ?? (await widget.getClipboard().readTextAsync(null)) ?? "";
134
+ await wrapEvent(widget, () => {
135
+ insertEditableText(widget, content);
136
+ });
137
+ };
package/src/wait-for.ts CHANGED
@@ -1,115 +1,114 @@
1
- import type * as Gtk from "@gtkx/ffi/gtk";
1
+ import type * as Gtk from "@gtkx/gi/gtk";
2
+ import { runWithActEnvironment } from "./act.js";
2
3
  import { getConfig } from "./config.js";
3
- import { buildTimeoutError } from "./error-builder.js";
4
+ import { timeoutError } from "./errors.js";
4
5
  import type { WaitForOptions } from "./types.js";
5
6
 
6
7
  const DEFAULT_INTERVAL = 50;
7
8
 
9
+ const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
10
+
11
+ const copyStackTrace = (target: Error, source: Error): void => {
12
+ if (source.stack) {
13
+ target.stack = source.stack.replace(source.message, target.message);
14
+ }
15
+ };
16
+
8
17
  /**
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";
18
+ * Repeatedly invokes a callback until it succeeds without throwing or the
19
+ * timeout elapses, retrying on each rejection at a fixed interval.
21
20
  *
22
- * await waitFor(() => {
23
- * expect(counter.value).toBe(5);
24
- * }, { timeout: 2000 });
25
- * ```
21
+ * @param callback The assertion or query to retry; its resolved value is returned.
22
+ * @param options Optional timeout, interval, and timeout error customization.
23
+ * @returns The callback's result once it succeeds.
26
24
  */
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
- }
25
+ export const waitFor = <T>(callback: () => T | Promise<T>, options?: WaitForOptions): Promise<T> => {
26
+ if (typeof callback !== "function") {
27
+ throw new TypeError("Received `callback` arg must be a function");
40
28
  }
41
29
 
42
- const timeoutError = buildTimeoutError(timeout, lastError);
43
- if (onTimeout) {
44
- throw onTimeout(timeoutError);
45
- }
46
- throw timeoutError;
30
+ const stackTraceError = options?.stackTraceError ?? new Error("STACK_TRACE_MESSAGE");
31
+
32
+ return Promise.resolve(
33
+ runWithActEnvironment(false, async () => {
34
+ const config = getConfig();
35
+ const { timeout = config.asyncUtilTimeout, interval = DEFAULT_INTERVAL, onTimeout } = options ?? {};
36
+ const startTime = Date.now();
37
+ let lastError: Error | null = null;
38
+
39
+ while (Date.now() - startTime < timeout) {
40
+ try {
41
+ const result = await callback();
42
+ await delay(0);
43
+ return result;
44
+ } catch (error) {
45
+ lastError = error as Error;
46
+ await delay(interval);
47
+ }
48
+ }
49
+
50
+ const error = timeoutError(timeout, lastError);
51
+ const finalError = onTimeout ? onTimeout(error) : error;
52
+ copyStackTrace(finalError, stackTraceError);
53
+ throw finalError;
54
+ }),
55
+ );
47
56
  };
48
57
 
49
- type ElementOrCallback = Gtk.Widget | (() => Gtk.Widget | null);
58
+ type RemovalTarget = Gtk.Widget | Gtk.Widget[] | null;
59
+
60
+ type ElementOrCallback = Gtk.Widget | Gtk.Widget[] | (() => RemovalTarget);
50
61
 
51
- const getElement = (elementOrCallback: ElementOrCallback): Gtk.Widget | null => {
62
+ const getTarget = (elementOrCallback: ElementOrCallback): RemovalTarget => {
52
63
  if (typeof elementOrCallback === "function") {
53
64
  return elementOrCallback();
54
65
  }
55
66
  return elementOrCallback;
56
67
  };
57
68
 
58
- const isElementRemoved = (element: Gtk.Widget | null): boolean => {
59
- if (element === null) return true;
69
+ const isWidgetRemoved = (widget: Gtk.Widget | null): boolean => {
70
+ if (widget === null) return true;
60
71
 
61
72
  try {
62
- const parent = element.getParent();
63
- return parent === null;
73
+ return widget.getRoot() === null;
64
74
  } catch {
65
75
  return true;
66
76
  }
67
77
  };
68
78
 
79
+ const isTargetRemoved = (target: RemovalTarget): boolean => {
80
+ if (Array.isArray(target)) {
81
+ return target.length === 0 || target.every(isWidgetRemoved);
82
+ }
83
+ return isWidgetRemoved(target);
84
+ };
85
+
86
+ const ELEMENT_NOT_REMOVED = new Error("Element not yet removed");
87
+
69
88
  /**
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";
89
+ * Waits until the given widget or widgets are detached from the tree. Rejects
90
+ * immediately if the target is already absent when called.
80
91
  *
81
- * const loader = await screen.findByRole(Gtk.AccessibleRole.PROGRESS_BAR);
82
- * await waitForElementToBeRemoved(loader);
83
- * // Loader is now gone
84
- * ```
92
+ * @param elementOrCallback The widget, array of widgets, or a function
93
+ * returning them to observe for removal.
94
+ * @param options Optional timeout and interval settings.
85
95
  */
86
- export const waitForElementToBeRemoved = async (
96
+ export const waitForElementToBeRemoved = (
87
97
  elementOrCallback: ElementOrCallback,
88
98
  options?: WaitForOptions,
89
99
  ): 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",
100
+ const stackTraceError = new Error("STACK_TRACE_MESSAGE");
101
+ if (isTargetRemoved(getTarget(elementOrCallback))) {
102
+ return Promise.reject(
103
+ new Error(
104
+ "Element already removed: waitForElementToBeRemoved requires the element to be present initially",
105
+ ),
97
106
  );
98
107
  }
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;
108
+ return waitFor(
109
+ () => {
110
+ if (!isTargetRemoved(getTarget(elementOrCallback))) throw ELEMENT_NOT_REMOVED;
111
+ },
112
+ { ...options, stackTraceError },
113
+ );
115
114
  };
@@ -0,0 +1,244 @@
1
+ import * as Gtk from "@gtkx/gi/gtk";
2
+ import { getAccessibleMetadata } from "@gtkx/react/internal";
3
+ import { EDITABLE_ROLES, isEditable, readEditableText } from "./editable.js";
4
+ import { descendants } from "./traversal.js";
5
+
6
+ const callStringGetter = (widget: Gtk.Widget, method: string): string | null => {
7
+ const fn: unknown = Reflect.get(widget, method);
8
+ if (typeof fn !== "function") return null;
9
+ const value = (fn as () => string | null).call(widget);
10
+ return value ?? null;
11
+ };
12
+
13
+ const readAccessibleString = (widget: Gtk.Widget, key: string): string | null => {
14
+ const value = getAccessibleMetadata<string>(widget, key);
15
+ return typeof value === "string" ? value : null;
16
+ };
17
+
18
+ const readAccessibleNumber = (widget: Gtk.Widget, key: string): number | null => {
19
+ const value = getAccessibleMetadata<number>(widget, key);
20
+ return typeof value === "number" ? value : null;
21
+ };
22
+
23
+ const readAccessibleBoolean = (widget: Gtk.Widget, key: string): boolean | null => {
24
+ const value = getAccessibleMetadata<boolean>(widget, key);
25
+ return typeof value === "boolean" ? value : null;
26
+ };
27
+
28
+ const getLabelText = (widget: Gtk.Widget): string | null => {
29
+ if (widget instanceof Gtk.Label) return widget.getLabel();
30
+ if (widget instanceof Gtk.Inscription) return widget.getText() ?? null;
31
+ return null;
32
+ };
33
+
34
+ const DEFAULT_TEXT_GETTERS = ["getLabel", "getText", "getTitle"] as const;
35
+
36
+ /**
37
+ * Returns a widget's own text by trying its label, text, and title getters in
38
+ * order, or null when none produce a value.
39
+ *
40
+ * @param widget The widget to read text from.
41
+ */
42
+ export const getWidgetNodeText = (widget: Gtk.Widget): string | null => {
43
+ for (const getter of DEFAULT_TEXT_GETTERS) {
44
+ const value = callStringGetter(widget, getter);
45
+ if (value) return value;
46
+ }
47
+ return null;
48
+ };
49
+
50
+ const stripMnemonic = (text: string): string => text.replace(/_(.)/g, "$1");
51
+
52
+ const namingLabelText = (widget: Gtk.Widget): string | null => {
53
+ const text = getLabelText(widget);
54
+ if (text === null) return null;
55
+ return widget instanceof Gtk.Label && widget.getUseUnderline() ? stripMnemonic(text) : text;
56
+ };
57
+
58
+ const isNamingLabelRole = (role: Gtk.AccessibleRole, includePresentation: boolean): boolean =>
59
+ role === Gtk.AccessibleRole.LABEL || (includePresentation && role === Gtk.AccessibleRole.PRESENTATION);
60
+
61
+ const collectLabels = (widget: Gtk.Widget): string[] => {
62
+ const includePresentation = widget.getAccessibleRole() === Gtk.AccessibleRole.MENU_ITEM;
63
+ const labels: string[] = [];
64
+ for (const descendant of descendants(widget)) {
65
+ if (!isNamingLabelRole(descendant.getAccessibleRole(), includePresentation)) continue;
66
+ const labelText = namingLabelText(descendant);
67
+ if (labelText) labels.push(labelText);
68
+ }
69
+ return labels;
70
+ };
71
+
72
+ export const getWidgetLabelText = (widget: Gtk.Widget): string | null => {
73
+ if (widget.getAccessibleRole() !== Gtk.AccessibleRole.LABEL) return null;
74
+ return getLabelText(widget);
75
+ };
76
+
77
+ export const getWidgetAccessibleName = (widget: Gtk.Widget): string | null => {
78
+ const role = widget.getAccessibleRole();
79
+
80
+ if (role === Gtk.AccessibleRole.TAB_PANEL) {
81
+ const parent = widget.getParent();
82
+ if (parent instanceof Gtk.Stack) {
83
+ const page = parent.getPage(widget);
84
+ return page.getTitle() ?? null;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ const accessibleLabel = getAccessibleMetadata<string>(widget, "accessibleLabel");
90
+ if (accessibleLabel) return accessibleLabel;
91
+
92
+ const ownText = getWidgetNodeText(widget);
93
+ if (ownText) return ownText;
94
+
95
+ const childLabels = collectLabels(widget);
96
+ if (childLabels.length > 0) return childLabels.join(" ");
97
+
98
+ return callStringGetter(widget, "getTooltipText");
99
+ };
100
+
101
+ export const getWidgetName = (widget: Gtk.Widget): string | null => {
102
+ return widget.getName();
103
+ };
104
+
105
+ export const getWidgetPlaceholderText = (widget: Gtk.Widget): string | null => {
106
+ if (!EDITABLE_ROLES.has(widget.getAccessibleRole()) || !(widget instanceof Gtk.Editable)) {
107
+ return null;
108
+ }
109
+
110
+ return callStringGetter(widget, "getPlaceholderText");
111
+ };
112
+
113
+ export const getWidgetDisplayValue = (widget: Gtk.Widget): string | null => {
114
+ if (EDITABLE_ROLES.has(widget.getAccessibleRole()) && isEditable(widget)) {
115
+ return readEditableText(widget);
116
+ }
117
+
118
+ return null;
119
+ };
120
+
121
+ export const getWidgetCheckedState = (widget: Gtk.Widget): boolean | null => {
122
+ if (widget instanceof Gtk.CheckButton) return widget.getActive();
123
+ if (widget instanceof Gtk.Switch) return widget.getActive();
124
+ if (widget instanceof Gtk.ToggleButton && widget.getAccessibleRole() === Gtk.AccessibleRole.RADIO) {
125
+ return widget.getActive();
126
+ }
127
+ return null;
128
+ };
129
+
130
+ export const getWidgetPressedState = (widget: Gtk.Widget): boolean | null => {
131
+ if (widget instanceof Gtk.ToggleButton) return widget.getActive();
132
+ return null;
133
+ };
134
+
135
+ export const getWidgetExpandedState = (widget: Gtk.Widget): boolean | null => {
136
+ if (widget instanceof Gtk.Expander) {
137
+ return widget.getExpanded();
138
+ }
139
+
140
+ if (widget instanceof Gtk.TreeExpander) {
141
+ return widget.getListRow()?.getExpanded() ?? null;
142
+ }
143
+
144
+ return null;
145
+ };
146
+
147
+ export const getWidgetSelectedState = (widget: Gtk.Widget): boolean | null => {
148
+ if (widget instanceof Gtk.ListBoxRow) {
149
+ return widget.isSelected();
150
+ }
151
+
152
+ if (widget.getAccessibleRole() === Gtk.AccessibleRole.ROW) {
153
+ return (widget.getStateFlags() & Gtk.StateFlags.SELECTED) !== 0;
154
+ }
155
+
156
+ return null;
157
+ };
158
+
159
+ export const getWidgetLevel = (widget: Gtk.Widget): number | null => {
160
+ return readAccessibleNumber(widget, "accessibleLevel");
161
+ };
162
+
163
+ export const getWidgetInvalidState = (widget: Gtk.Widget): Gtk.AccessibleInvalidState | null => {
164
+ return getAccessibleMetadata<Gtk.AccessibleInvalidState>(widget, "accessibleInvalid");
165
+ };
166
+
167
+ export const getWidgetErrorMessage = (widget: Gtk.Widget): Gtk.Widget[] | null => {
168
+ const targets = getAccessibleMetadata<Gtk.Widget[]>(widget, "accessibleErrorMessage");
169
+ return Array.isArray(targets) && targets.length > 0 ? targets : null;
170
+ };
171
+
172
+ export const getWidgetBusyState = (widget: Gtk.Widget): boolean | null => {
173
+ return readAccessibleBoolean(widget, "accessibleBusy");
174
+ };
175
+
176
+ export const getWidgetDescription = (widget: Gtk.Widget): string | null => {
177
+ return readAccessibleString(widget, "accessibleDescription");
178
+ };
179
+
180
+ export type WidgetValue = {
181
+ now: number | null;
182
+ min: number | null;
183
+ max: number | null;
184
+ text: string | null;
185
+ };
186
+
187
+ type ValueTriplet = { now: number | null; min: number | null; max: number | null };
188
+
189
+ const adjustmentValue = (adjustment: Gtk.Adjustment): ValueTriplet => ({
190
+ now: adjustment.getValue(),
191
+ min: adjustment.getLower(),
192
+ max: adjustment.getUpper(),
193
+ });
194
+
195
+ const getWidgetLiveValue = (widget: Gtk.Widget): ValueTriplet | null => {
196
+ if (widget instanceof Gtk.Range) return adjustmentValue(widget.getAdjustment());
197
+ if (widget instanceof Gtk.Scrollbar) return adjustmentValue(widget.getAdjustment());
198
+ if (widget instanceof Gtk.SpinButton) return adjustmentValue(widget.getAdjustment());
199
+ if (widget instanceof Gtk.ScaleButton) return adjustmentValue(widget.getAdjustment());
200
+ if (widget instanceof Gtk.LevelBar) {
201
+ return { now: widget.getValue(), min: widget.getMinValue(), max: widget.getMaxValue() };
202
+ }
203
+ if (widget instanceof Gtk.ProgressBar) return { now: widget.getFraction(), min: 0, max: 1 };
204
+ return null;
205
+ };
206
+
207
+ export const getWidgetValue = (widget: Gtk.Widget): WidgetValue => {
208
+ const text = readAccessibleString(widget, "accessibleValueText");
209
+ const live = getWidgetLiveValue(widget);
210
+ if (live) return { ...live, text };
211
+ return {
212
+ now: readAccessibleNumber(widget, "accessibleValueNow"),
213
+ min: readAccessibleNumber(widget, "accessibleValueMin"),
214
+ max: readAccessibleNumber(widget, "accessibleValueMax"),
215
+ text,
216
+ };
217
+ };
218
+
219
+ export const getWidgetOwnLabel = (widget: Gtk.Widget): string | null => {
220
+ return readAccessibleString(widget, "accessibleLabel");
221
+ };
222
+
223
+ export const getWidgetLabelledByText = (widget: Gtk.Widget): string | null => {
224
+ const targets = getAccessibleMetadata<Gtk.Widget[]>(widget, "accessibleLabelledBy");
225
+ if (!Array.isArray(targets) || targets.length === 0) return null;
226
+
227
+ const texts: string[] = [];
228
+ for (const target of targets) {
229
+ const text = getWidgetAccessibleName(target);
230
+ if (text !== null) texts.push(text);
231
+ }
232
+
233
+ return texts.length > 0 ? texts.join(" ") : null;
234
+ };
235
+
236
+ export const isInaccessible = (widget: Gtk.Widget): boolean => {
237
+ let current: Gtk.Widget | null = widget;
238
+ while (current) {
239
+ if (!current.getVisible()) return true;
240
+ if (readAccessibleBoolean(current, "accessibleHidden") === true) return true;
241
+ current = current.getParent();
242
+ }
243
+ return false;
244
+ };
package/src/within.ts CHANGED
@@ -1,31 +1,30 @@
1
- import type * as Gtk from "@gtkx/ffi/gtk";
2
- import { bindQueries } from "./bind-queries.js";
3
- import type { BoundQueries } from "./types.js";
1
+ import type { BoundQueries } from "./bound-queries.js";
2
+ import { builtinQueries } from "./queries.js";
3
+ import type { Container } from "./traversal.js";
4
+ import type { BoundCustomQueries, QueryMap } from "./types.js";
5
+
6
+ const bindQuery =
7
+ <Args extends unknown[], Result>(query: (container: Container, ...args: Args) => Result, container: Container) =>
8
+ (...args: Args): Result =>
9
+ query(container, ...args);
10
+
11
+ const bindCustomQueries = <Q extends QueryMap>(customQueries: Q, container: Container): BoundCustomQueries<Q> => {
12
+ const entries = Object.entries(customQueries).map(([key, query]) => [key, bindQuery(query, container)] as const);
13
+ return Object.fromEntries(entries) as BoundCustomQueries<Q>;
14
+ };
4
15
 
5
16
  /**
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
- * ```
17
+ * Binds the built-in queries, and any custom queries, to a specific container
18
+ * so they search only within that scope.
28
19
  *
29
- * @see {@link screen} for global queries
20
+ * @param container The scope the returned queries operate on.
21
+ * @param queries Optional custom queries to bind alongside the built-in ones.
22
+ * @returns The queries bound to the container.
30
23
  */
31
- export const within = (container: Gtk.Widget): BoundQueries => bindQueries(container);
24
+ export const within = <Q extends QueryMap = Record<never, never>>(
25
+ container: Container,
26
+ queries?: Q,
27
+ ): BoundQueries & BoundCustomQueries<Q> => ({
28
+ ...bindCustomQueries(builtinQueries, container),
29
+ ...(queries ? bindCustomQueries(queries, container) : ({} as BoundCustomQueries<Q>)),
30
+ });
@@ -1,14 +0,0 @@
1
- import type { Container } from "./traversal.js";
2
- import type { BoundQueries } from "./types.js";
3
- type ContainerOrGetter = Container | (() => Container);
4
- /**
5
- * Binds all query functions to a container.
6
- *
7
- * @param containerOrGetter - The container to bind queries to, or a function that returns it
8
- * @returns Object with all query methods bound to the container
9
- *
10
- * @internal
11
- */
12
- export declare const bindQueries: (containerOrGetter: ContainerOrGetter) => BoundQueries;
13
- export {};
14
- //# sourceMappingURL=bind-queries.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"bind-queries.d.ts","sourceRoot":"","sources":["../src/bind-queries.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,KAAK,EAAE,YAAY,EAA8C,MAAM,YAAY,CAAC;AAE3F,KAAK,iBAAiB,GAAG,SAAS,GAAG,CAAC,MAAM,SAAS,CAAC,CAAC;AAKvD;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,GAAI,mBAAmB,iBAAiB,KAAG,YAiCjE,CAAC"}
@@ -1,29 +0,0 @@
1
- import * as queries from "./queries.js";
2
- const resolveContainer = (containerOrGetter) => typeof containerOrGetter === "function" ? containerOrGetter() : containerOrGetter;
3
- /**
4
- * Binds all query functions to a container.
5
- *
6
- * @param containerOrGetter - The container to bind queries to, or a function that returns it
7
- * @returns Object with all query methods bound to the container
8
- *
9
- * @internal
10
- */
11
- export const bindQueries = (containerOrGetter) => ({
12
- queryByRole: (role, options) => queries.queryByRole(resolveContainer(containerOrGetter), role, options),
13
- queryByLabelText: (text, options) => queries.queryByLabelText(resolveContainer(containerOrGetter), text, options),
14
- queryByText: (text, options) => queries.queryByText(resolveContainer(containerOrGetter), text, options),
15
- queryByTestId: (testId, options) => queries.queryByTestId(resolveContainer(containerOrGetter), testId, options),
16
- queryAllByRole: (role, options) => queries.queryAllByRole(resolveContainer(containerOrGetter), role, options),
17
- queryAllByLabelText: (text, options) => queries.queryAllByLabelText(resolveContainer(containerOrGetter), text, options),
18
- queryAllByText: (text, options) => queries.queryAllByText(resolveContainer(containerOrGetter), text, options),
19
- queryAllByTestId: (testId, options) => queries.queryAllByTestId(resolveContainer(containerOrGetter), testId, options),
20
- findByRole: (role, options) => queries.findByRole(resolveContainer(containerOrGetter), role, options),
21
- findByLabelText: (text, options) => queries.findByLabelText(resolveContainer(containerOrGetter), text, options),
22
- findByText: (text, options) => queries.findByText(resolveContainer(containerOrGetter), text, options),
23
- findByTestId: (testId, options) => queries.findByTestId(resolveContainer(containerOrGetter), testId, options),
24
- findAllByRole: (role, options) => queries.findAllByRole(resolveContainer(containerOrGetter), role, options),
25
- findAllByLabelText: (text, options) => queries.findAllByLabelText(resolveContainer(containerOrGetter), text, options),
26
- findAllByText: (text, options) => queries.findAllByText(resolveContainer(containerOrGetter), text, options),
27
- findAllByTestId: (testId, options) => queries.findAllByTestId(resolveContainer(containerOrGetter), testId, options),
28
- });
29
- //# sourceMappingURL=bind-queries.js.map