@gtkx/testing 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,9 +10,9 @@
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://www.npmjs.com/package/@gtkx/react"><img src="https://img.shields.io/npm/v/@gtkx/react.svg" alt="npm version"></a>
13
- <a href="https://github.com/eugeniodepalo/gtkx/actions"><img src="https://img.shields.io/github/actions/workflow/status/eugeniodepalo/gtkx/ci.yml" alt="CI"></a>
14
- <a href="https://github.com/eugeniodepalo/gtkx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MPL--2.0-blue.svg" alt="License"></a>
15
- <a href="https://github.com/eugeniodepalo/gtkx/discussions"><img src="https://img.shields.io/badge/discussions-GitHub-blue" alt="GitHub Discussions"></a>
13
+ <a href="https://github.com/gtkx-org/gtkx/actions"><img src="https://img.shields.io/github/actions/workflow/status/eugeniodepalo/gtkx/ci.yml" alt="CI"></a>
14
+ <a href="https://github.com/gtkx-org/gtkx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MPL--2.0-blue.svg" alt="License"></a>
15
+ <a href="https://github.com/gtkx-org/gtkx/discussions"><img src="https://img.shields.io/badge/discussions-GitHub-blue" alt="GitHub Discussions"></a>
16
16
  </p>
17
17
 
18
18
  ---
@@ -90,16 +90,16 @@ Explore complete applications in the [`examples/`](./examples) directory:
90
90
 
91
91
  ## Documentation
92
92
 
93
- Visit [https://eugeniodepalo.github.io/gtkx](https://eugeniodepalo.github.io/gtkx/) for the full documentation.
93
+ Visit [https://gtkx.dev](https://gtkx.dev) for the full documentation.
94
94
 
95
95
  ## Contributing
96
96
 
97
- Contributions are welcome! Please see the [contributing guidelines](./CONTRIBUTING.md) and check out the [good first issues](https://github.com/eugeniodepalo/gtkx/labels/good%20first%20issue).
97
+ Contributions are welcome! Please see the [contributing guidelines](./CONTRIBUTING.md) and check out the [good first issues](https://github.com/gtkx-org/gtkx/labels/good%20first%20issue).
98
98
 
99
99
  ## Community
100
100
 
101
- - [GitHub Discussions](https://github.com/eugeniodepalo/gtkx/discussions) — Questions, ideas, and general discussion
102
- - [Issue Tracker](https://github.com/eugeniodepalo/gtkx/issues) — Bug reports and feature requests
101
+ - [GitHub Discussions](https://github.com/gtkx-org/gtkx/discussions) — Questions, ideas, and general discussion
102
+ - [Issue Tracker](https://github.com/gtkx-org/gtkx/issues) — Bug reports and feature requests
103
103
 
104
104
  ## License
105
105
 
@@ -29,6 +29,6 @@ export const fireEvent = async (element, signalName, ...args) => {
29
29
  const gtype = typeFromName(element.constructor.glibTypeName);
30
30
  const signalId = signalLookup(signalName, gtype);
31
31
  const instanceValue = Value.newFromObject(element);
32
- signalEmitv([instanceValue, ...args], signalId, 0, null);
32
+ signalEmitv([instanceValue, ...args], signalId, 0);
33
33
  await tick();
34
34
  };
package/dist/index.d.ts CHANGED
@@ -12,7 +12,8 @@ export { screen } from "./screen.js";
12
12
  export type { ScreenshotOptions } from "./screenshot.js";
13
13
  export { screenshot } from "./screenshot.js";
14
14
  export { tick } from "./timing.js";
15
- export type { BoundQueries, ByRoleOptions, NormalizerOptions, RenderHookOptions, RenderHookResult, RenderOptions, RenderResult, ScreenshotResult, TextMatch, TextMatchFunction, TextMatchOptions, WaitForOptions, } from "./types.js";
15
+ export type { Container } from "./traversal.js";
16
+ export type { BoundQueries, ByRoleOptions, NormalizerOptions, RenderHookOptions, RenderHookResult, RenderOptions, RenderResult, ScreenshotResult, TextMatch, TextMatchFunction, TextMatchOptions, WaitForOptions, WrapperComponent, } from "./types.js";
16
17
  export type { PointerInput, TabOptions } from "./user-event.js";
17
18
  export { userEvent } from "./user-event.js";
18
19
  export { waitFor, waitForElementToBeRemoved } from "./wait-for.js";
@@ -1,10 +1,19 @@
1
- import { getNativeId } from "@gtkx/ffi";
2
1
  import * as Gtk from "@gtkx/ffi/gtk";
3
2
  import { formatRole } from "./role-helpers.js";
4
3
  import { isApplication } from "./traversal.js";
5
4
  import { getWidgetText } from "./widget-text.js";
6
5
  const DEFAULT_MAX_LENGTH = 7000;
7
6
  const INDENT = " ";
7
+ const debugIdMap = new WeakMap();
8
+ let nextDebugId = 0;
9
+ const getWidgetDebugId = (widget) => {
10
+ let id = debugIdMap.get(widget);
11
+ if (!id) {
12
+ id = String(nextDebugId++);
13
+ debugIdMap.set(widget, id);
14
+ }
15
+ return id;
16
+ };
8
17
  const shouldHighlight = () => {
9
18
  if (typeof process === "undefined")
10
19
  return false;
@@ -42,7 +51,7 @@ const escapeAttrValue = (value) => {
42
51
  const formatAttributes = (widget, colors, includeIds) => {
43
52
  const attrs = [];
44
53
  if (includeIds) {
45
- attrs.push(["id", String(getNativeId(widget.handle))]);
54
+ attrs.push(["id", getWidgetDebugId(widget)]);
46
55
  }
47
56
  const name = widget.getName();
48
57
  if (name) {
package/dist/queries.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type * as Gtk from "@gtkx/ffi/gtk";
1
+ import * as Gtk from "@gtkx/ffi/gtk";
2
2
  import { type Container } from "./traversal.js";
3
3
  import type { ByRoleOptions, TextMatch, TextMatchOptions } from "./types.js";
4
4
  /**
@@ -21,12 +21,16 @@ export declare const queryAllByRole: (container: Container, role: Gtk.Accessible
21
21
  */
22
22
  export declare const queryByRole: (container: Container, role: Gtk.AccessibleRole, options?: ByRoleOptions) => Gtk.Widget | null;
23
23
  /**
24
- * Finds all elements matching label text without throwing.
24
+ * Finds all elements that are labelled by a GtkLabel whose text matches.
25
+ *
26
+ * Uses GtkLabel's mnemonic widget association to find form elements
27
+ * by their label text. Only returns widgets that are properly labelled
28
+ * via GtkLabel's mnemonic-widget property.
25
29
  *
26
30
  * @param container - The container to search within
27
- * @param text - Text to match (string, RegExp, or custom matcher)
31
+ * @param text - Label text to match (string, RegExp, or custom matcher)
28
32
  * @param options - Query options including normalization
29
- * @returns Array of matching widgets (empty if none found)
33
+ * @returns Array of labelled widgets (empty if none found)
30
34
  */
31
35
  export declare const queryAllByLabelText: (container: Container, text: TextMatch, options?: TextMatchOptions) => Gtk.Widget[];
32
36
  /**
@@ -103,20 +107,19 @@ export declare const findByRole: (container: Container, role: Gtk.AccessibleRole
103
107
  */
104
108
  export declare const findAllByRole: (container: Container, role: Gtk.AccessibleRole, options?: ByRoleOptions) => Promise<Gtk.Widget[]>;
105
109
  /**
106
- * Finds a single element by its label or text content.
110
+ * Finds a single element that is labelled by a GtkLabel whose text matches.
107
111
  *
108
- * Matches button labels, input placeholders, window titles, and other
109
- * accessible text content.
112
+ * Waits for the element to appear, throwing if not found within timeout.
113
+ * Uses GtkLabel's mnemonic widget association to find form elements.
110
114
  *
111
115
  * @param container - The container to search within
112
- * @param text - Text to match (string, RegExp, or custom matcher)
116
+ * @param text - Label text to match (string, RegExp, or custom matcher)
113
117
  * @param options - Query options including normalization and timeout
114
- * @returns Promise resolving to the matching widget
118
+ * @returns Promise resolving to the labelled widget
115
119
  *
116
120
  * @example
117
121
  * ```tsx
118
- * const button = await findByLabelText(container, "Click me");
119
- * const input = await findByLabelText(container, /search/i);
122
+ * const input = await findByLabelText(container, "Username");
120
123
  * ```
121
124
  */
122
125
  export declare const findByLabelText: (container: Container, text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
package/dist/queries.js CHANGED
@@ -1,8 +1,8 @@
1
- import { getConfig } from "./config.js";
2
- import { buildMultipleFoundError, buildNotFoundError, buildTimeoutError } from "./error-builder.js";
3
- import { findAll } from "./traversal.js";
4
- import { getWidgetCheckedState, getWidgetExpandedState, getWidgetTestId, getWidgetText } from "./widget-text.js";
5
- const DEFAULT_INTERVAL = 50;
1
+ import * as Gtk from "@gtkx/ffi/gtk";
2
+ import { buildMultipleFoundError, buildNotFoundError } from "./error-builder.js";
3
+ import { findAll, traverse } from "./traversal.js";
4
+ import { waitFor } from "./wait-for.js";
5
+ import { getWidgetCheckedState, getWidgetExpandedState, getWidgetPressedState, getWidgetSelectedState, getWidgetTestId, getWidgetText, } from "./widget-text.js";
6
6
  const buildNormalizer = (options) => {
7
7
  if (options?.normalizer) {
8
8
  return options.normalizer;
@@ -37,7 +37,9 @@ const matchText = (actual, expected, widget, options) => {
37
37
  }
38
38
  const normalizedExpected = normalizeText(expected, options);
39
39
  const exact = options?.exact ?? true;
40
- return exact ? normalizedActual === normalizedExpected : normalizedActual.includes(normalizedExpected);
40
+ return exact
41
+ ? normalizedActual === normalizedExpected
42
+ : normalizedActual.toLowerCase().includes(normalizedExpected.toLowerCase());
41
43
  };
42
44
  const matchByRoleOptions = (widget, options) => {
43
45
  if (!options)
@@ -52,32 +54,22 @@ const matchByRoleOptions = (widget, options) => {
52
54
  if (checked !== options.checked)
53
55
  return false;
54
56
  }
57
+ if (options.pressed !== undefined) {
58
+ const pressed = getWidgetPressedState(widget);
59
+ if (pressed !== options.pressed)
60
+ return false;
61
+ }
55
62
  if (options.expanded !== undefined) {
56
63
  const expanded = getWidgetExpandedState(widget);
57
64
  if (expanded !== options.expanded)
58
65
  return false;
59
66
  }
60
- return true;
61
- };
62
- const waitFor = async (callback, options) => {
63
- const config = getConfig();
64
- const { timeout = config.asyncUtilTimeout, interval = DEFAULT_INTERVAL, onTimeout } = options ?? {};
65
- const startTime = Date.now();
66
- let lastError = null;
67
- while (Date.now() - startTime < timeout) {
68
- try {
69
- return await callback();
70
- }
71
- catch (error) {
72
- lastError = error;
73
- await new Promise((resolve) => setTimeout(resolve, interval));
74
- }
75
- }
76
- const timeoutError = buildTimeoutError(timeout, lastError);
77
- if (onTimeout) {
78
- throw onTimeout(timeoutError);
67
+ if (options.selected !== undefined) {
68
+ const selected = getWidgetSelectedState(widget);
69
+ if (selected !== options.selected)
70
+ return false;
79
71
  }
80
- throw timeoutError;
72
+ return true;
81
73
  };
82
74
  /**
83
75
  * Finds all elements matching a role without throwing.
@@ -111,18 +103,33 @@ export const queryByRole = (container, role, options) => {
111
103
  return matches[0] ?? null;
112
104
  };
113
105
  /**
114
- * Finds all elements matching label text without throwing.
106
+ * Finds all elements that are labelled by a GtkLabel whose text matches.
107
+ *
108
+ * Uses GtkLabel's mnemonic widget association to find form elements
109
+ * by their label text. Only returns widgets that are properly labelled
110
+ * via GtkLabel's mnemonic-widget property.
115
111
  *
116
112
  * @param container - The container to search within
117
- * @param text - Text to match (string, RegExp, or custom matcher)
113
+ * @param text - Label text to match (string, RegExp, or custom matcher)
118
114
  * @param options - Query options including normalization
119
- * @returns Array of matching widgets (empty if none found)
115
+ * @returns Array of labelled widgets (empty if none found)
120
116
  */
121
117
  export const queryAllByLabelText = (container, text, options) => {
122
- return findAll(container, (node) => {
123
- const widgetText = getWidgetText(node);
124
- return matchText(widgetText, text, node, options);
125
- });
118
+ const results = [];
119
+ for (const node of traverse(container)) {
120
+ if (!(node instanceof Gtk.Label))
121
+ continue;
122
+ const labelText = node.getLabel();
123
+ if (!labelText)
124
+ continue;
125
+ if (!matchText(labelText, text, node, options))
126
+ continue;
127
+ const target = node.getMnemonicWidget();
128
+ if (target) {
129
+ results.push(target);
130
+ }
131
+ }
132
+ return results;
126
133
  };
127
134
  /**
128
135
  * Finds a single element matching label text without throwing.
@@ -286,20 +293,19 @@ export const findAllByRole = async (container, role, options) => waitFor(() => g
286
293
  timeout: options?.timeout,
287
294
  });
288
295
  /**
289
- * Finds a single element by its label or text content.
296
+ * Finds a single element that is labelled by a GtkLabel whose text matches.
290
297
  *
291
- * Matches button labels, input placeholders, window titles, and other
292
- * accessible text content.
298
+ * Waits for the element to appear, throwing if not found within timeout.
299
+ * Uses GtkLabel's mnemonic widget association to find form elements.
293
300
  *
294
301
  * @param container - The container to search within
295
- * @param text - Text to match (string, RegExp, or custom matcher)
302
+ * @param text - Label text to match (string, RegExp, or custom matcher)
296
303
  * @param options - Query options including normalization and timeout
297
- * @returns Promise resolving to the matching widget
304
+ * @returns Promise resolving to the labelled widget
298
305
  *
299
306
  * @example
300
307
  * ```tsx
301
- * const button = await findByLabelText(container, "Click me");
302
- * const input = await findByLabelText(container, /search/i);
308
+ * const input = await findByLabelText(container, "Username");
303
309
  * ```
304
310
  */
305
311
  export const findByLabelText = async (container, text, options) => waitFor(() => getByLabelText(container, text, options), {
@@ -49,7 +49,7 @@ export const renderHook = async (callback, options) => {
49
49
  return null;
50
50
  };
51
51
  const renderResult = await render(_jsx(TestComponent, { props: currentProps }), {
52
- wrapper: options?.wrapper ?? false,
52
+ wrapper: options?.wrapper ?? true,
53
53
  });
54
54
  return {
55
55
  result: resultRef,
package/dist/render.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import { type ReactNode } from "react";
2
2
  import type { RenderOptions, RenderResult } from "./types.js";
3
3
  /**
4
4
  * Renders a React element for testing.
package/dist/render.js CHANGED
@@ -2,10 +2,12 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { start, stop } from "@gtkx/ffi";
3
3
  import * as Gio from "@gtkx/ffi/gio";
4
4
  import { ApplicationContext, GtkApplicationWindow, reconciler } from "@gtkx/react";
5
+ import { createRef } from "react";
5
6
  import { bindQueries } from "./bind-queries.js";
6
7
  import { prettyWidget } from "./pretty-widget.js";
7
8
  import { setScreenRoot } from "./screen.js";
8
9
  import { tick } from "./timing.js";
10
+ import { isApplication, traverse } from "./traversal.js";
9
11
  let application = null;
10
12
  let container = null;
11
13
  let lastRenderError = null;
@@ -30,14 +32,30 @@ const ensureInitialized = () => {
30
32
  }
31
33
  return { app: application, container };
32
34
  };
33
- const DefaultWrapper = ({ children }) => (_jsx(GtkApplicationWindow, { defaultWidth: 800, defaultHeight: 600, children: children }));
34
- const wrapElement = (element, wrapper = true) => {
35
- if (wrapper === false)
35
+ const DefaultWrapper = ({ children, ref }) => (_jsx(GtkApplicationWindow, { ref: ref, defaultWidth: 800, defaultHeight: 600, children: children }));
36
+ const findFirstWidget = (root) => {
37
+ for (const widget of traverse(root)) {
38
+ if (isApplication(root))
39
+ return widget;
40
+ return root;
41
+ }
42
+ return null;
43
+ };
44
+ const wrapElement = (element, wrapperRef, wrapper) => {
45
+ if (wrapper === false || wrapper === undefined)
36
46
  return element;
37
- if (wrapper === true)
38
- return _jsx(DefaultWrapper, { children: element });
39
- const Wrapper = wrapper;
40
- return _jsx(Wrapper, { children: element });
47
+ const Wrapper = wrapper === true ? DefaultWrapper : wrapper;
48
+ return _jsx(Wrapper, { ref: wrapperRef, children: element });
49
+ };
50
+ const resolveContainer = (wrapper, wrapperRef, baseElement) => {
51
+ if (wrapper !== false && wrapper !== undefined && wrapperRef.current) {
52
+ return wrapperRef.current;
53
+ }
54
+ const firstWidget = findFirstWidget(baseElement);
55
+ if (!firstWidget) {
56
+ throw new Error("render() produced no widgets. Ensure the element renders visible content.");
57
+ }
58
+ return firstWidget;
41
59
  };
42
60
  /**
43
61
  * Renders a React element for testing.
@@ -66,18 +84,23 @@ const wrapElement = (element, wrapper = true) => {
66
84
  export const render = async (element, options) => {
67
85
  const { app: application, container: fiberRoot } = ensureInitialized();
68
86
  const instance = reconciler.getInstance();
69
- const wrappedElement = wrapElement(element, options?.wrapper);
87
+ const baseElement = options?.baseElement ?? application;
88
+ const wrapper = options?.wrapper ?? true;
89
+ const wrapperRef = createRef();
90
+ const wrappedElement = wrapElement(element, wrapperRef, wrapper);
70
91
  const withContext = _jsx(ApplicationContext.Provider, { value: application, children: wrappedElement });
71
92
  await update(instance, withContext, fiberRoot);
72
93
  setScreenRoot(application);
73
94
  return {
74
- container: application,
75
- ...bindQueries(application),
95
+ container: resolveContainer(wrapper, wrapperRef, baseElement),
96
+ baseElement,
97
+ ...bindQueries(baseElement),
76
98
  unmount: () => update(instance, null, fiberRoot),
77
- rerender: (newElement) => {
78
- const wrapped = wrapElement(newElement, options?.wrapper);
99
+ rerender: async (newElement) => {
100
+ const newWrapperRef = createRef();
101
+ const wrapped = wrapElement(newElement, newWrapperRef, wrapper);
79
102
  const withCtx = _jsx(ApplicationContext.Provider, { value: application, children: wrapped });
80
- return update(instance, withCtx, fiberRoot);
103
+ await update(instance, withCtx, fiberRoot);
81
104
  },
82
105
  debug: () => {
83
106
  console.log(prettyWidget(application));
package/dist/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type * as Gtk from "@gtkx/ffi/gtk";
2
- import type { ComponentType, ReactNode } from "react";
2
+ import type { ComponentType, ReactNode, Ref } from "react";
3
+ import type { Container } from "./traversal.js";
3
4
  /**
4
5
  * Custom function for matching text content.
5
6
  *
@@ -69,6 +70,14 @@ export type WaitForOptions = {
69
70
  /** Custom error handler called on timeout */
70
71
  onTimeout?: (error: Error) => Error;
71
72
  };
73
+ /**
74
+ * A wrapper component that exposes its root GTK widget via `ref`.
75
+ * Accept `ref` as a prop and pass it through to the root intrinsic element.
76
+ */
77
+ export type WrapperComponent = ComponentType<{
78
+ children: ReactNode;
79
+ ref?: Ref<Gtk.Widget>;
80
+ }>;
72
81
  /**
73
82
  * Options for {@link render}.
74
83
  */
@@ -77,11 +86,15 @@ export type RenderOptions = {
77
86
  * Wrapper component or boolean.
78
87
  * - `true` (default): Wrap in GtkApplicationWindow
79
88
  * - `false`: No wrapper
80
- * - Component: Custom wrapper component
89
+ * - Component: Custom wrapper that passes `ref` to its root element
90
+ */
91
+ wrapper?: boolean | WrapperComponent;
92
+ /**
93
+ * The element queries are bound to.
94
+ * Defaults to the GTK Application (searches all toplevel windows).
95
+ * Provide a specific widget or application to scope queries.
81
96
  */
82
- wrapper?: boolean | ComponentType<{
83
- children: ReactNode;
84
- }>;
97
+ baseElement?: Container;
85
98
  };
86
99
  /**
87
100
  * Query methods bound to a container.
@@ -129,8 +142,10 @@ export type BoundQueries = {
129
142
  * Provides query methods and utilities for testing rendered components.
130
143
  */
131
144
  export type RenderResult = BoundQueries & {
132
- /** The GTK Application container */
133
- container: Gtk.Application;
145
+ /** The direct container widget wrapping the rendered content */
146
+ container: Gtk.Widget;
147
+ /** The element queries are bound to (defaults to the GTK Application) */
148
+ baseElement: Container;
134
149
  /** Unmount the rendered component */
135
150
  unmount: () => Promise<void>;
136
151
  /** Re-render with a new element */
@@ -161,13 +176,11 @@ export type RenderHookOptions<Props> = {
161
176
  initialProps?: Props;
162
177
  /**
163
178
  * Wrapper component or boolean.
164
- * - `false` (default): No wrapper
165
- * - `true`: Wrap in GtkApplicationWindow
166
- * - Component: Custom wrapper component
179
+ * - `true` (default): Wrap in GtkApplicationWindow
180
+ * - `false`: No wrapper
181
+ * - Component: Custom wrapper that passes `ref` to its root element
167
182
  */
168
- wrapper?: boolean | ComponentType<{
169
- children: ReactNode;
170
- }>;
183
+ wrapper?: boolean | WrapperComponent;
171
184
  };
172
185
  /**
173
186
  * Result returned by {@link renderHook}.
@@ -37,10 +37,10 @@ export type PointerInput = "click" | "down" | "up" | "[MouseLeft]" | "[MouseLeft
37
37
  */
38
38
  export declare const userEvent: {
39
39
  /**
40
- * Clicks or toggles a widget.
40
+ * Activates a widget.
41
41
  *
42
- * For toggleable widgets (checkboxes, switches, toggle buttons),
43
- * toggles the active state. For buttons, emits clicked signal.
42
+ * Uses GTK's native {@link Gtk.Widget.activate} to trigger the widget's
43
+ * default action clicking buttons, toggling checkboxes/switches, etc.
44
44
  */
45
45
  click: (element: Gtk.Widget) => Promise<void>;
46
46
  /**
@@ -4,35 +4,9 @@ import * as Gtk from "@gtkx/ffi/gtk";
4
4
  import { fireEvent } from "./fire-event.js";
5
5
  import { tick } from "./timing.js";
6
6
  import { isEditable } from "./widget.js";
7
- const TOGGLEABLE_ROLES = new Set([
8
- Gtk.AccessibleRole.CHECKBOX,
9
- Gtk.AccessibleRole.RADIO,
10
- Gtk.AccessibleRole.TOGGLE_BUTTON,
11
- Gtk.AccessibleRole.SWITCH,
12
- ]);
13
- const isToggleable = (widget) => {
14
- return TOGGLEABLE_ROLES.has(widget.getAccessibleRole());
15
- };
16
7
  const click = async (element) => {
17
- if (isToggleable(element)) {
18
- const role = element.getAccessibleRole();
19
- if (role === Gtk.AccessibleRole.CHECKBOX || role === Gtk.AccessibleRole.RADIO) {
20
- const checkButton = element;
21
- checkButton.setActive(!checkButton.getActive());
22
- }
23
- else if (role === Gtk.AccessibleRole.SWITCH) {
24
- const switchWidget = element;
25
- switchWidget.setActive(!switchWidget.getActive());
26
- }
27
- else {
28
- const toggleButton = element;
29
- toggleButton.setActive(!toggleButton.getActive());
30
- }
31
- await tick();
32
- }
33
- else {
34
- await fireEvent(element, "clicked");
35
- }
8
+ element.activate();
9
+ await tick();
36
10
  };
37
11
  const emitClickSequence = async (element, nPress) => {
38
12
  const controller = getOrCreateController(element, Gtk.GestureClick);
@@ -43,8 +17,8 @@ const emitClickSequence = async (element, nPress) => {
43
17
  Value.newFromDouble(0),
44
18
  Value.newFromDouble(0),
45
19
  ];
46
- signalEmitv(args, getSignalId(controller, "pressed"), 0, null);
47
- signalEmitv(args, getSignalId(controller, "released"), 0, null);
20
+ signalEmitv(args, getSignalId(controller, "pressed"), 0);
21
+ signalEmitv(args, getSignalId(controller, "released"), 0);
48
22
  }
49
23
  await tick();
50
24
  };
@@ -181,12 +155,12 @@ const getSignalId = (target, signalName) => {
181
155
  };
182
156
  const hover = async (element) => {
183
157
  const controller = getOrCreateController(element, Gtk.EventControllerMotion);
184
- signalEmitv([Value.newFromObject(controller), Value.newFromDouble(0), Value.newFromDouble(0)], getSignalId(controller, "enter"), 0, null);
158
+ signalEmitv([Value.newFromObject(controller), Value.newFromDouble(0), Value.newFromDouble(0)], getSignalId(controller, "enter"), 0);
185
159
  await tick();
186
160
  };
187
161
  const unhover = async (element) => {
188
162
  const controller = getOrCreateController(element, Gtk.EventControllerMotion);
189
- signalEmitv([Value.newFromObject(controller)], getSignalId(controller, "leave"), 0, null);
163
+ signalEmitv([Value.newFromObject(controller)], getSignalId(controller, "leave"), 0);
190
164
  await tick();
191
165
  };
192
166
  const KEY_MAP = {
@@ -247,19 +221,39 @@ const parseKeyboardInput = (input) => {
247
221
  }
248
222
  return actions;
249
223
  };
224
+ const MODIFIER_KEYVAL_TO_MASK = {
225
+ [Gdk.KEY_Shift_L]: Gdk.ModifierType.SHIFT_MASK,
226
+ [Gdk.KEY_Shift_R]: Gdk.ModifierType.SHIFT_MASK,
227
+ [Gdk.KEY_Control_L]: Gdk.ModifierType.CONTROL_MASK,
228
+ [Gdk.KEY_Control_R]: Gdk.ModifierType.CONTROL_MASK,
229
+ [Gdk.KEY_Alt_L]: Gdk.ModifierType.ALT_MASK,
230
+ [Gdk.KEY_Alt_R]: Gdk.ModifierType.ALT_MASK,
231
+ [Gdk.KEY_Meta_L]: Gdk.ModifierType.META_MASK,
232
+ [Gdk.KEY_Meta_R]: Gdk.ModifierType.META_MASK,
233
+ };
250
234
  let gdkModifierType = null;
251
235
  const keyboard = async (element, input) => {
252
236
  gdkModifierType ??= typeFromName("GdkModifierType");
253
237
  const controller = getOrCreateController(element, Gtk.EventControllerKey);
254
238
  const actions = parseKeyboardInput(input);
239
+ let modifierState = 0;
255
240
  for (const action of actions) {
241
+ const mask = MODIFIER_KEYVAL_TO_MASK[action.keyval];
242
+ if (mask) {
243
+ if (action.press) {
244
+ modifierState |= mask;
245
+ }
246
+ else {
247
+ modifierState &= ~mask;
248
+ }
249
+ }
256
250
  const signalName = action.press ? "key-pressed" : "key-released";
257
- const returnValue = action.press ? Value.newFromBoolean(false) : null;
251
+ const returnValue = action.press ? Value.newFromBoolean(false) : undefined;
258
252
  signalEmitv([
259
253
  Value.newFromObject(controller),
260
254
  Value.newFromUint(action.keyval),
261
255
  Value.newFromUint(0),
262
- Value.newFromFlags(gdkModifierType, 0),
256
+ Value.newFromFlags(gdkModifierType, modifierState),
263
257
  ], getSignalId(controller, signalName), 0, returnValue);
264
258
  if (action.press && action.keyval === Gdk.KEY_Return && isEditable(element)) {
265
259
  await fireEvent(element, "activate");
@@ -282,14 +276,14 @@ const pointer = async (element, input) => {
282
276
  Value.newFromDouble(0),
283
277
  ];
284
278
  if (input === "[MouseLeft]" || input === "click") {
285
- signalEmitv(pressedArgs, getSignalId(controller, "pressed"), 0, null);
286
- signalEmitv(releasedArgs, getSignalId(controller, "released"), 0, null);
279
+ signalEmitv(pressedArgs, getSignalId(controller, "pressed"), 0);
280
+ signalEmitv(releasedArgs, getSignalId(controller, "released"), 0);
287
281
  }
288
282
  else if (input === "[MouseLeft>]" || input === "down") {
289
- signalEmitv(pressedArgs, getSignalId(controller, "pressed"), 0, null);
283
+ signalEmitv(pressedArgs, getSignalId(controller, "pressed"), 0);
290
284
  }
291
285
  else if (input === "[/MouseLeft]" || input === "up") {
292
- signalEmitv(releasedArgs, getSignalId(controller, "released"), 0, null);
286
+ signalEmitv(releasedArgs, getSignalId(controller, "released"), 0);
293
287
  }
294
288
  await tick();
295
289
  };
@@ -316,10 +310,10 @@ const pointer = async (element, input) => {
316
310
  */
317
311
  export const userEvent = {
318
312
  /**
319
- * Clicks or toggles a widget.
313
+ * Activates a widget.
320
314
  *
321
- * For toggleable widgets (checkboxes, switches, toggle buttons),
322
- * toggles the active state. For buttons, emits clicked signal.
315
+ * Uses GTK's native {@link Gtk.Widget.activate} to trigger the widget's
316
+ * default action clicking buttons, toggling checkboxes/switches, etc.
323
317
  */
324
318
  click,
325
319
  /**
package/dist/wait-for.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getConfig } from "./config.js";
2
+ import { buildTimeoutError } from "./error-builder.js";
2
3
  const DEFAULT_INTERVAL = 50;
3
4
  /**
4
5
  * Waits for a callback to succeed.
@@ -33,7 +34,7 @@ export const waitFor = async (callback, options) => {
33
34
  await new Promise((resolve) => setTimeout(resolve, interval));
34
35
  }
35
36
  }
36
- const timeoutError = new Error(`Timed out after ${timeout}ms. Last error: ${lastError?.message}`);
37
+ const timeoutError = buildTimeoutError(timeout, lastError);
37
38
  if (onTimeout) {
38
39
  throw onTimeout(timeoutError);
39
40
  }
@@ -20,6 +20,13 @@ export declare const getWidgetTestId: (widget: Gtk.Widget) => string | null;
20
20
  * @returns The checked state or null if not applicable
21
21
  */
22
22
  export declare const getWidgetCheckedState: (widget: Gtk.Widget) => boolean | null;
23
+ /**
24
+ * Gets the pressed state from toggle button widgets.
25
+ *
26
+ * @param widget - The widget to get the pressed state from
27
+ * @returns The pressed state or null if not applicable
28
+ */
29
+ export declare const getWidgetPressedState: (widget: Gtk.Widget) => boolean | null;
23
30
  /**
24
31
  * Gets the expanded state from expander widgets.
25
32
  *
@@ -27,3 +34,10 @@ export declare const getWidgetCheckedState: (widget: Gtk.Widget) => boolean | nu
27
34
  * @returns The expanded state or null if not applicable
28
35
  */
29
36
  export declare const getWidgetExpandedState: (widget: Gtk.Widget) => boolean | null;
37
+ /**
38
+ * Gets the selected state from selectable widgets.
39
+ *
40
+ * @param widget - The widget to get the selected state from
41
+ * @returns The selected state or null if not applicable
42
+ */
43
+ export declare const getWidgetSelectedState: (widget: Gtk.Widget) => boolean | null;
@@ -5,9 +5,11 @@ const ROLES_WITH_INTERNAL_LABELS = new Set([
5
5
  Gtk.AccessibleRole.TOGGLE_BUTTON,
6
6
  Gtk.AccessibleRole.CHECKBOX,
7
7
  Gtk.AccessibleRole.RADIO,
8
+ Gtk.AccessibleRole.LIST_ITEM,
8
9
  Gtk.AccessibleRole.MENU_ITEM,
9
10
  Gtk.AccessibleRole.MENU_ITEM_CHECKBOX,
10
11
  Gtk.AccessibleRole.MENU_ITEM_RADIO,
12
+ Gtk.AccessibleRole.ROW,
11
13
  Gtk.AccessibleRole.TAB,
12
14
  Gtk.AccessibleRole.LINK,
13
15
  ]);
@@ -22,13 +24,40 @@ const isInternalLabel = (widget) => {
22
24
  const parentRole = parent.getAccessibleRole();
23
25
  if (!parentRole)
24
26
  return false;
25
- return ROLES_WITH_INTERNAL_LABELS.has(parentRole);
27
+ if (ROLES_WITH_INTERNAL_LABELS.has(parentRole))
28
+ return true;
29
+ const labelText = getLabelText(widget);
30
+ if (!labelText)
31
+ return false;
32
+ let ancestor = parent;
33
+ while (ancestor) {
34
+ if (ancestor.getAccessibleRole === undefined)
35
+ 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
+ return false;
26
43
  };
27
44
  const getLabelText = (widget) => {
28
45
  const asLabel = widget;
29
46
  const asInscription = widget;
30
47
  return asLabel.getLabel?.() ?? asInscription.getText?.() ?? null;
31
48
  };
49
+ const getDefaultText = (widget) => {
50
+ if ("getLabel" in widget && typeof widget.getLabel === "function") {
51
+ return widget.getLabel() ?? null;
52
+ }
53
+ if ("getText" in widget && typeof widget.getText === "function") {
54
+ return widget.getText() ?? null;
55
+ }
56
+ if ("getTitle" in widget && typeof widget.getTitle === "function") {
57
+ return widget.getTitle() ?? null;
58
+ }
59
+ return getNativeInterface(widget, Gtk.Editable)?.getText() ?? null;
60
+ };
32
61
  const collectChildLabels = (widget) => {
33
62
  const labels = [];
34
63
  let child = widget.getFirstChild();
@@ -58,32 +87,16 @@ export const getWidgetText = (widget) => {
58
87
  switch (role) {
59
88
  case Gtk.AccessibleRole.BUTTON:
60
89
  case Gtk.AccessibleRole.LINK:
61
- case Gtk.AccessibleRole.TAB: {
62
- const directLabel = widget.getLabel?.() ??
63
- widget.getLabel?.() ??
64
- widget.getLabel?.();
90
+ case Gtk.AccessibleRole.TAB:
91
+ case Gtk.AccessibleRole.MENU_ITEM:
92
+ case Gtk.AccessibleRole.MENU_ITEM_CHECKBOX:
93
+ case Gtk.AccessibleRole.MENU_ITEM_RADIO: {
94
+ const directLabel = getDefaultText(widget);
65
95
  if (directLabel)
66
96
  return directLabel;
67
97
  const childLabels = collectChildLabels(widget);
68
98
  return childLabels.length > 0 ? childLabels.join(" ") : null;
69
99
  }
70
- case Gtk.AccessibleRole.TOGGLE_BUTTON:
71
- return widget.getLabel?.() ?? null;
72
- case Gtk.AccessibleRole.CHECKBOX:
73
- case Gtk.AccessibleRole.RADIO:
74
- return widget.getLabel?.() ?? null;
75
- case Gtk.AccessibleRole.LABEL:
76
- return getLabelText(widget);
77
- case Gtk.AccessibleRole.TEXT_BOX:
78
- case Gtk.AccessibleRole.SEARCH_BOX:
79
- case Gtk.AccessibleRole.SPIN_BUTTON:
80
- return getNativeInterface(widget, Gtk.Editable)?.getText() ?? null;
81
- case Gtk.AccessibleRole.GROUP:
82
- return widget.getLabel?.() ?? null;
83
- case Gtk.AccessibleRole.WINDOW:
84
- case Gtk.AccessibleRole.DIALOG:
85
- case Gtk.AccessibleRole.ALERT_DIALOG:
86
- return widget.getTitle() ?? null;
87
100
  case Gtk.AccessibleRole.TAB_PANEL: {
88
101
  const parent = widget.getParent();
89
102
  if (parent) {
@@ -96,7 +109,7 @@ export const getWidgetText = (widget) => {
96
109
  return null;
97
110
  }
98
111
  default:
99
- return null;
112
+ return getDefaultText(widget);
100
113
  }
101
114
  };
102
115
  /**
@@ -128,6 +141,19 @@ export const getWidgetCheckedState = (widget) => {
128
141
  return null;
129
142
  }
130
143
  };
144
+ /**
145
+ * Gets the pressed state from toggle button widgets.
146
+ *
147
+ * @param widget - The widget to get the pressed state from
148
+ * @returns The pressed state or null if not applicable
149
+ */
150
+ export const getWidgetPressedState = (widget) => {
151
+ const role = widget.getAccessibleRole();
152
+ if (role === Gtk.AccessibleRole.TOGGLE_BUTTON) {
153
+ return widget.getActive();
154
+ }
155
+ return null;
156
+ };
131
157
  /**
132
158
  * Gets the expanded state from expander widgets.
133
159
  *
@@ -135,12 +161,24 @@ export const getWidgetCheckedState = (widget) => {
135
161
  * @returns The expanded state or null if not applicable
136
162
  */
137
163
  export const getWidgetExpandedState = (widget) => {
164
+ if (widget instanceof Gtk.Expander) {
165
+ return widget.getExpanded();
166
+ }
167
+ if (widget instanceof Gtk.TreeExpander) {
168
+ return widget.getListRow()?.getExpanded() ?? null;
169
+ }
170
+ return null;
171
+ };
172
+ /**
173
+ * Gets the selected state from selectable widgets.
174
+ *
175
+ * @param widget - The widget to get the selected state from
176
+ * @returns The selected state or null if not applicable
177
+ */
178
+ export const getWidgetSelectedState = (widget) => {
138
179
  const role = widget.getAccessibleRole();
139
- if (role === Gtk.AccessibleRole.BUTTON) {
140
- const parent = widget.getParent();
141
- if (!parent)
142
- return null;
143
- return parent.getExpanded?.() ?? null;
180
+ if (role === Gtk.AccessibleRole.ROW) {
181
+ return widget.isSelected();
144
182
  }
145
183
  return null;
146
184
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtkx/testing",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "description": "Testing utilities for GTKX applications",
5
5
  "keywords": [
6
6
  "gtkx",
@@ -13,13 +13,13 @@
13
13
  "linux",
14
14
  "desktop"
15
15
  ],
16
- "homepage": "https://eugeniodepalo.github.io/gtkx",
16
+ "homepage": "https://gtkx.dev",
17
17
  "bugs": {
18
- "url": "https://github.com/eugeniodepalo/gtkx/issues"
18
+ "url": "https://github.com/gtkx-org/gtkx/issues"
19
19
  },
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "https://github.com/eugeniodepalo/gtkx.git",
22
+ "url": "https://github.com/gtkx-org/gtkx.git",
23
23
  "directory": "packages/testing"
24
24
  },
25
25
  "license": "MPL-2.0",
@@ -36,13 +36,13 @@
36
36
  "dist"
37
37
  ],
38
38
  "dependencies": {
39
- "@gtkx/react": "0.17.2",
40
- "@gtkx/ffi": "0.17.2"
39
+ "@gtkx/ffi": "0.18.0",
40
+ "@gtkx/react": "0.18.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/react-reconciler": "^0.33.0",
44
44
  "react-reconciler": "^0.33.0",
45
- "@gtkx/vitest": "0.17.2"
45
+ "@gtkx/vitest": "0.18.0"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "tsc -b && cp ../../README.md .",