@gtkx/testing 0.10.4 → 0.11.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 ADDED
@@ -0,0 +1,103 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/eugeniodepalo/gtkx/main/logo.svg" alt="GTKX" width="60" height="60">
3
+ </p>
4
+
5
+ <h1 align="center">GTKX</h1>
6
+
7
+ <p align="center">
8
+ <strong>Build native GTK4 desktop applications with React and TypeScript.</strong>
9
+ </p>
10
+
11
+ <p align="center">
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>
16
+ </p>
17
+
18
+ ---
19
+
20
+ GTKX lets you write Linux desktop applications using React. Your components render as native GTK4 widgets through a Rust FFI bridge—no webviews, no Electron, just native performance with the developer experience you already know.
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ npx @gtkx/cli create my-app
26
+ cd my-app
27
+ npm run dev
28
+ ```
29
+
30
+ ## Example
31
+
32
+ ```tsx
33
+ import {
34
+ GtkApplicationWindow,
35
+ GtkBox,
36
+ GtkButton,
37
+ GtkLabel,
38
+ quit,
39
+ render,
40
+ } from "@gtkx/react";
41
+ import * as Gtk from "@gtkx/ffi/gtk";
42
+ import { useState } from "react";
43
+
44
+ const App = () => {
45
+ const [count, setCount] = useState(0);
46
+
47
+ return (
48
+ <GtkApplicationWindow
49
+ title="Counter"
50
+ defaultWidth={300}
51
+ defaultHeight={200}
52
+ onCloseRequest={quit}
53
+ >
54
+ <GtkBox
55
+ orientation={Gtk.Orientation.VERTICAL}
56
+ spacing={20}
57
+ valign={Gtk.Align.CENTER}
58
+ >
59
+ <GtkLabel label={`Count: ${count}`} cssClasses={["title-1"]} />
60
+ <GtkButton label="Increment" onClicked={() => setCount((c) => c + 1)} />
61
+ </GtkBox>
62
+ </GtkApplicationWindow>
63
+ );
64
+ };
65
+
66
+ render(<App />, "com.example.counter");
67
+ ```
68
+
69
+ ## Features
70
+
71
+ - **React 19** — Hooks, concurrent features, and the component model you know
72
+ - **Native GTK4 widgets** — Real native controls, not web components in a webview
73
+ - **Adwaita support** — Modern GNOME styling with Libadwaita components
74
+ - **Hot Module Replacement** — Fast refresh during development
75
+ - **TypeScript first** — Full type safety with auto-generated bindings
76
+ - **CSS-in-JS styling** — Familiar styling patterns adapted for GTK
77
+ - **Testing utilities** — Component testing similar to Testing Library
78
+
79
+ ## Examples
80
+
81
+ Explore complete applications in the [`examples/`](./examples) directory:
82
+
83
+ - **[gtk-demo](./examples/gtk-demo)** — Full replica of the official GTK demo app
84
+ - **[hello-world](./examples/hello-world)** — Minimal application showing a counter
85
+ - **[todo](./examples/todo)** — Full-featured todo application with Adwaita styling and testing
86
+ - **[deploying](./examples/deploying)** — Example of packaging and distributing a GTKX app
87
+
88
+ ## Documentation
89
+
90
+ Visit [https://eugeniodepalo.github.io/gtkx](https://eugeniodepalo.github.io/gtkx/) for the full documentation.
91
+
92
+ ## Contributing
93
+
94
+ 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).
95
+
96
+ ## Community
97
+
98
+ - [GitHub Discussions](https://github.com/eugeniodepalo/gtkx/discussions) — Questions, ideas, and general discussion
99
+ - [Issue Tracker](https://github.com/eugeniodepalo/gtkx/issues) — Bug reports and feature requests
100
+
101
+ ## License
102
+
103
+ [MPL-2.0](./LICENSE)
@@ -21,6 +21,10 @@ import { tick } from "./timing.js";
21
21
  * @see {@link userEvent} for high-level user interactions
22
22
  */
23
23
  export const fireEvent = async (element, signalName, ...args) => {
24
- call("libgobject-2.0.so.0", "g_signal_emit_by_name", [{ type: { type: "gobject" }, value: element.id }, { type: { type: "string" }, value: signalName }, ...args], { type: "undefined" });
24
+ call("libgobject-2.0.so.0", "g_signal_emit_by_name", [
25
+ { type: { type: "gobject", ownership: "none" }, value: element.id },
26
+ { type: { type: "string", ownership: "none" }, value: signalName },
27
+ ...args,
28
+ ], { type: "undefined" });
25
29
  await tick();
26
30
  };
package/dist/queries.js CHANGED
@@ -50,16 +50,17 @@ const ROLES_WITH_INTERNAL_LABELS = new Set([
50
50
  Gtk.AccessibleRole.LINK,
51
51
  ]);
52
52
  const isInternalLabel = (widget) => {
53
- const accessible = getNativeObject(widget.id, Gtk.Accessible);
54
- if (!accessible || accessible.getAccessibleRole() !== Gtk.AccessibleRole.LABEL)
53
+ if (widget.getAccessibleRole() !== Gtk.AccessibleRole.LABEL)
55
54
  return false;
56
55
  const parent = widget.getParent();
57
56
  if (!parent)
58
57
  return false;
59
- const parentAccessible = getNativeObject(parent.id, Gtk.Accessible);
60
- if (!parentAccessible)
58
+ if (parent.getAccessibleRole === undefined)
61
59
  return false;
62
- return ROLES_WITH_INTERNAL_LABELS.has(parentAccessible.getAccessibleRole());
60
+ const parentRole = parent.getAccessibleRole();
61
+ if (!parentRole)
62
+ return false;
63
+ return ROLES_WITH_INTERNAL_LABELS.has(parentRole);
63
64
  };
64
65
  const getLabelText = (widget) => {
65
66
  const asLabel = widget;
@@ -70,8 +71,7 @@ const collectChildLabels = (widget) => {
70
71
  const labels = [];
71
72
  let child = widget.getFirstChild();
72
73
  while (child) {
73
- const childAccessible = getNativeObject(child.id, Gtk.Accessible);
74
- if (childAccessible?.getAccessibleRole() === Gtk.AccessibleRole.LABEL) {
74
+ if (child.getAccessibleRole() === Gtk.AccessibleRole.LABEL) {
75
75
  const labelText = getLabelText(child);
76
76
  if (labelText)
77
77
  labels.push(labelText);
@@ -84,7 +84,7 @@ const collectChildLabels = (widget) => {
84
84
  const getWidgetText = (widget) => {
85
85
  if (isInternalLabel(widget))
86
86
  return null;
87
- const role = getNativeObject(widget.id, Gtk.Accessible)?.getAccessibleRole();
87
+ const role = widget.getAccessibleRole();
88
88
  if (role === undefined)
89
89
  return null;
90
90
  switch (role) {
@@ -109,7 +109,7 @@ const getWidgetText = (widget) => {
109
109
  case Gtk.AccessibleRole.TEXT_BOX:
110
110
  case Gtk.AccessibleRole.SEARCH_BOX:
111
111
  case Gtk.AccessibleRole.SPIN_BUTTON:
112
- return getNativeObject(widget.id, Gtk.Editable)?.getText() ?? null;
112
+ return getNativeObject(widget.id, Gtk.Editable).getText() ?? null;
113
113
  case Gtk.AccessibleRole.GROUP:
114
114
  return widget.getLabel?.() ?? null;
115
115
  case Gtk.AccessibleRole.WINDOW:
@@ -135,10 +135,7 @@ const getWidgetTestId = (widget) => {
135
135
  return widget.getName();
136
136
  };
137
137
  const getWidgetCheckedState = (widget) => {
138
- const accessible = getNativeObject(widget.id, Gtk.Accessible);
139
- if (!accessible)
140
- return undefined;
141
- const role = accessible.getAccessibleRole();
138
+ const role = widget.getAccessibleRole();
142
139
  switch (role) {
143
140
  case Gtk.AccessibleRole.CHECKBOX:
144
141
  case Gtk.AccessibleRole.RADIO:
@@ -148,19 +145,18 @@ const getWidgetCheckedState = (widget) => {
148
145
  case Gtk.AccessibleRole.SWITCH:
149
146
  return widget.getActive();
150
147
  default:
148
+ return null;
151
149
  }
152
150
  };
153
151
  const getWidgetExpandedState = (widget) => {
154
- const accessible = getNativeObject(widget.id, Gtk.Accessible);
155
- if (!accessible)
156
- return undefined;
157
- const role = accessible.getAccessibleRole();
152
+ const role = widget.getAccessibleRole();
158
153
  if (role === Gtk.AccessibleRole.BUTTON) {
159
154
  const parent = widget.getParent();
160
155
  if (!parent)
161
- return undefined;
162
- return parent.getExpanded?.();
156
+ return null;
157
+ return parent.getExpanded?.() ?? null;
163
158
  }
159
+ return null;
164
160
  };
165
161
  const matchByRoleOptions = (widget, options) => {
166
162
  if (!options)
@@ -201,8 +197,7 @@ const formatByRoleError = (role, options) => {
201
197
  };
202
198
  const getAllByRole = (container, role, options) => {
203
199
  const matches = findAll(container, (node) => {
204
- const accessible = getNativeObject(node.id, Gtk.Accessible);
205
- if (!accessible || accessible.getAccessibleRole() !== role)
200
+ if (node.getAccessibleRole() !== role)
206
201
  return false;
207
202
  return matchByRoleOptions(node, options);
208
203
  });
package/dist/render.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { discardAllBatches, getNativeObject, start } from "@gtkx/ffi";
2
+ import { discardAllBatches, start } from "@gtkx/ffi";
3
+ import * as Gio from "@gtkx/ffi/gio";
3
4
  import * as Gtk from "@gtkx/ffi/gtk";
4
5
  import { ApplicationContext, GtkApplicationWindow, reconciler } from "@gtkx/react";
5
6
  import * as queries from "./queries.js";
@@ -9,14 +10,10 @@ import { hasLabel } from "./widget.js";
9
10
  let application = null;
10
11
  let container = null;
11
12
  let lastRenderError = null;
12
- const APP_ID = `com.gtkx.test${process.pid}`;
13
13
  const getWidgetLabel = (widget) => {
14
14
  if (!hasLabel(widget))
15
15
  return null;
16
- const accessible = getNativeObject(widget.id, Gtk.Accessible);
17
- if (!accessible)
18
- return null;
19
- const role = accessible.getAccessibleRole();
16
+ const role = widget.getAccessibleRole();
20
17
  if (role === Gtk.AccessibleRole.LABEL) {
21
18
  return widget.getLabel?.() ?? null;
22
19
  }
@@ -24,11 +21,11 @@ const getWidgetLabel = (widget) => {
24
21
  };
25
22
  const printWidgetTree = (root, indent = 0) => {
26
23
  const prefix = " ".repeat(indent);
27
- const accessibleRole = getNativeObject(root.id, Gtk.Accessible)?.getAccessibleRole();
28
- const role = accessibleRole !== undefined ? (Gtk.AccessibleRole[accessibleRole] ?? "UNKNOWN") : "UNKNOWN";
24
+ const role = root.getAccessibleRole();
25
+ const roleName = role !== undefined ? (Gtk.AccessibleRole[role] ?? "UNKNOWN") : "UNKNOWN";
29
26
  const labelText = getWidgetLabel(root);
30
27
  const label = labelText ? ` label="${labelText}"` : "";
31
- let result = `${prefix}<${root.constructor.name} role=${role}${label}>\n`;
28
+ let result = `${prefix}<${root.constructor.name} role=${roleName}${label}>\n`;
32
29
  let child = root.getFirstChild();
33
30
  while (child) {
34
31
  result += printWidgetTree(child, indent + 1);
@@ -51,10 +48,10 @@ const handleError = (error) => {
51
48
  lastRenderError = error;
52
49
  };
53
50
  const ensureInitialized = () => {
54
- application = start(APP_ID);
51
+ application = start("org.gtkx.testing", Gio.ApplicationFlags.NON_UNIQUE);
55
52
  if (!container) {
56
53
  const instance = reconciler.getInstance();
57
- container = instance.createContainer(application, 0, null, false, null, "", handleError, handleError, () => { }, () => { }, null);
54
+ container = instance.createContainer(application, 1, null, false, null, "", handleError, handleError, () => { }, () => { }, null);
58
55
  }
59
56
  return { app: application, container };
60
57
  };
@@ -10,14 +10,11 @@ const TOGGLEABLE_ROLES = new Set([
10
10
  Gtk.AccessibleRole.SWITCH,
11
11
  ]);
12
12
  const isToggleable = (widget) => {
13
- const accessible = getNativeObject(widget.id, Gtk.Accessible);
14
- if (!accessible)
15
- return false;
16
- return TOGGLEABLE_ROLES.has(accessible.getAccessibleRole());
13
+ return TOGGLEABLE_ROLES.has(widget.getAccessibleRole());
17
14
  };
18
15
  const click = async (element) => {
19
16
  if (isToggleable(element)) {
20
- const role = getNativeObject(element.id, Gtk.Accessible)?.getAccessibleRole();
17
+ const role = element.getAccessibleRole();
21
18
  if (role === Gtk.AccessibleRole.CHECKBOX || role === Gtk.AccessibleRole.RADIO) {
22
19
  const checkButton = element;
23
20
  checkButton.setActive(!checkButton.getActive());
@@ -62,8 +59,6 @@ const type = async (element, text) => {
62
59
  throw new Error("Cannot type into element: expected editable widget (TEXT_BOX, SEARCH_BOX, or SPIN_BUTTON)");
63
60
  }
64
61
  const editable = getNativeObject(element.id, Gtk.Editable);
65
- if (!editable)
66
- return;
67
62
  const currentText = editable.getText();
68
63
  editable.setText(currentText + text);
69
64
  await tick();
@@ -77,10 +72,9 @@ const clear = async (element) => {
77
72
  };
78
73
  const SELECTABLE_ROLES = new Set([Gtk.AccessibleRole.COMBO_BOX, Gtk.AccessibleRole.LIST]);
79
74
  const isSelectable = (widget) => {
80
- const accessible = getNativeObject(widget.id, Gtk.Accessible);
81
- if (!accessible)
75
+ if (!widget)
82
76
  return false;
83
- return SELECTABLE_ROLES.has(accessible.getAccessibleRole());
77
+ return SELECTABLE_ROLES.has(widget.getAccessibleRole());
84
78
  };
85
79
  const selectListViewItems = (selectionModel, positions, exclusive) => {
86
80
  if (positions.length === 0) {
@@ -114,7 +108,7 @@ const selectOptions = async (element, values) => {
114
108
  if (!isSelectable(element)) {
115
109
  throw new Error("Cannot select options: expected selectable widget (COMBO_BOX or LIST)");
116
110
  }
117
- const role = getNativeObject(element.id, Gtk.Accessible)?.getAccessibleRole();
111
+ const role = element.getAccessibleRole();
118
112
  if (role === Gtk.AccessibleRole.COMBO_BOX) {
119
113
  if (Array.isArray(values) && values.length > 1) {
120
114
  throw new Error("Cannot select multiple options: ComboBox only supports single selection");
@@ -148,7 +142,7 @@ const deselectOptions = async (element, values) => {
148
142
  await tick();
149
143
  return;
150
144
  }
151
- const role = getNativeObject(element.id, Gtk.Accessible)?.getAccessibleRole();
145
+ const role = element.getAccessibleRole();
152
146
  if (role !== Gtk.AccessibleRole.LIST) {
153
147
  throw new Error("Cannot deselect options: only ListBox supports deselection");
154
148
  }
@@ -19,7 +19,7 @@ import type { WaitForOptions } from "./types.js";
19
19
  * }, { timeout: 2000 });
20
20
  * ```
21
21
  */
22
- export declare const waitFor: <T>(callback: () => T, options?: WaitForOptions) => Promise<T>;
22
+ export declare const waitFor: <T>(callback: () => T | Promise<T>, options?: WaitForOptions) => Promise<T>;
23
23
  type ElementOrCallback = Gtk.Widget | (() => Gtk.Widget | null);
24
24
  /**
25
25
  * Waits for an element to be removed from the widget tree.
package/dist/wait-for.js CHANGED
@@ -25,7 +25,7 @@ export const waitFor = async (callback, options) => {
25
25
  let lastError = null;
26
26
  while (Date.now() - startTime < timeout) {
27
27
  try {
28
- return callback();
28
+ return await callback();
29
29
  }
30
30
  catch (error) {
31
31
  lastError = error;
@@ -75,7 +75,7 @@ const isElementRemoved = (element) => {
75
75
  export const waitForElementToBeRemoved = async (elementOrCallback, options) => {
76
76
  const { timeout = DEFAULT_TIMEOUT, interval = DEFAULT_INTERVAL, onTimeout } = options ?? {};
77
77
  const initialElement = getElement(elementOrCallback);
78
- if (initialElement === null) {
78
+ if (initialElement === null || isElementRemoved(initialElement)) {
79
79
  throw new Error("Elements already removed: waitForElementToBeRemoved requires elements to be present initially");
80
80
  }
81
81
  const startTime = Date.now();
package/dist/widget.js CHANGED
@@ -1,4 +1,3 @@
1
- import { getNativeObject } from "@gtkx/ffi";
2
1
  import * as Gtk from "@gtkx/ffi/gtk";
3
2
  const EDITABLE_ROLES = new Set([
4
3
  Gtk.AccessibleRole.TEXT_BOX,
@@ -6,10 +5,7 @@ const EDITABLE_ROLES = new Set([
6
5
  Gtk.AccessibleRole.SPIN_BUTTON,
7
6
  ]);
8
7
  export const isEditable = (widget) => {
9
- const accessible = getNativeObject(widget.id, Gtk.Accessible);
10
- if (!accessible)
11
- return false;
12
- return EDITABLE_ROLES.has(accessible.getAccessibleRole());
8
+ return EDITABLE_ROLES.has(widget.getAccessibleRole());
13
9
  };
14
10
  const LABEL_ROLES = new Set([
15
11
  Gtk.AccessibleRole.BUTTON,
@@ -22,8 +18,5 @@ const LABEL_ROLES = new Set([
22
18
  Gtk.AccessibleRole.MENU_ITEM_RADIO,
23
19
  ]);
24
20
  export const hasLabel = (widget) => {
25
- const accessible = getNativeObject(widget.id, Gtk.Accessible);
26
- if (!accessible)
27
- return false;
28
- return LABEL_ROLES.has(accessible.getAccessibleRole());
21
+ return LABEL_ROLES.has(widget.getAccessibleRole());
29
22
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtkx/testing",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "description": "Testing utilities for GTKX applications",
5
5
  "keywords": [
6
6
  "gtk",
@@ -32,16 +32,17 @@
32
32
  "dist"
33
33
  ],
34
34
  "dependencies": {
35
- "@gtkx/ffi": "0.10.4",
36
- "@gtkx/react": "0.10.4",
37
- "@gtkx/native": "0.10.4"
35
+ "@gtkx/ffi": "0.11.0",
36
+ "@gtkx/native": "0.11.0",
37
+ "@gtkx/react": "0.11.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/react-reconciler": "^0.32.3",
41
- "react-reconciler": "^0.33.0"
41
+ "react-reconciler": "^0.33.0",
42
+ "@gtkx/vitest": "0.11.0"
42
43
  },
43
44
  "scripts": {
44
45
  "build": "tsc -b && cp ../../README.md .",
45
- "test": "../../scripts/run-tests.sh"
46
+ "test": "vitest run"
46
47
  }
47
48
  }