@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,16 @@
1
+ import { quit } from "@gtkx/runtime";
2
+ import { registerMatchers } from "./matchers.js";
3
+ import { cleanup } from "./render.js";
4
+
5
+ const callRunnerHook = (name: "afterEach" | "afterAll", callback: () => unknown): void => {
6
+ const hook: unknown = Reflect.get(globalThis, name);
7
+ if (typeof hook === "function") (hook as (callback: () => unknown) => void)(callback);
8
+ };
9
+
10
+ const registerTestRuntimeHooks = (): void => {
11
+ callRunnerHook("afterEach", cleanup);
12
+ callRunnerHook("afterAll", quit);
13
+ registerMatchers();
14
+ };
15
+
16
+ registerTestRuntimeHooks();
@@ -1,65 +1,52 @@
1
- import { useRef } from "react";
1
+ import * as Gtk from "@gtkx/gi/gtk";
2
2
  import { render } from "./render.js";
3
3
  import type { RenderHookOptions, RenderHookResult } from "./types.js";
4
4
 
5
5
  /**
6
- * Renders a React hook for testing.
6
+ * Renders a test component that calls the given hook, exposing its latest
7
+ * return value along with rerender and unmount controls.
7
8
  *
8
- * Creates a test component that executes the hook and provides utilities
9
- * for accessing the result, re-rendering with new props, and cleanup.
10
- *
11
- * @param callback - Function that calls the hook and returns its result
12
- * @param options - Render options including initialProps and wrapper
13
- * @returns A promise resolving to the hook result and utilities
14
- *
15
- * @example
16
- * ```tsx
17
- * import { renderHook } from "@gtkx/testing";
18
- * import { useState } from "react";
19
- *
20
- * test("useState hook", async () => {
21
- * const { result } = await renderHook(() => useState(0));
22
- * expect(result.current[0]).toBe(0);
23
- * });
24
- * ```
25
- *
26
- * @example
27
- * ```tsx
28
- * import { renderHook } from "@gtkx/testing";
29
- *
30
- * test("hook with props", async () => {
31
- * const { result, rerender } = await renderHook(
32
- * ({ multiplier }) => useMultiplier(multiplier),
33
- * { initialProps: { multiplier: 2 } }
34
- * );
35
- *
36
- * expect(result.current).toBe(2);
9
+ * @param callback The hook to run, invoked with the current props.
10
+ * @param options Optional initial props and wrapper component.
11
+ * @returns A result whose `result.current` holds the hook's latest value.
12
+ */
13
+ export function renderHook<Result>(
14
+ callback: () => Result,
15
+ options?: RenderHookOptions<undefined>,
16
+ ): Promise<RenderHookResult<Result, undefined>>;
17
+ /**
18
+ * Renders a test component that calls the given hook with props, exposing its
19
+ * latest return value along with rerender and unmount controls.
37
20
  *
38
- * await rerender({ multiplier: 3 });
39
- * expect(result.current).toBe(3);
40
- * });
41
- * ```
21
+ * @param callback The hook to run, invoked with the current props.
22
+ * @param options Initial props and optional wrapper component.
23
+ * @returns A result whose `result.current` holds the hook's latest value.
42
24
  */
43
- export const renderHook = async <Result, Props>(
25
+ export function renderHook<Result, Props>(
26
+ callback: (props: Props) => Result,
27
+ options: RenderHookOptions<Props>,
28
+ ): Promise<RenderHookResult<Result, Props>>;
29
+ export async function renderHook<Result, Props>(
44
30
  callback: (props: Props) => Result,
45
31
  options?: RenderHookOptions<Props>,
46
- ): Promise<RenderHookResult<Result, Props>> => {
47
- const resultRef = { current: undefined as Result };
48
- let currentProps = options?.initialProps as Props;
32
+ ): Promise<RenderHookResult<Result, Props>> {
33
+ const initialProps = (options as { initialProps?: Props } | undefined)?.initialProps as Props;
34
+ const resultRef: { current: Result | undefined } = { current: undefined };
35
+ let currentProps: Props = initialProps;
49
36
 
50
37
  const TestComponent = ({ props }: { props: Props }): null => {
51
38
  const result = callback(props);
52
- const ref = useRef(resultRef);
53
- ref.current.current = result;
39
+ resultRef.current = result;
54
40
  return null;
55
41
  };
56
42
 
57
43
  const renderResult = await render(<TestComponent props={currentProps} />, {
58
- wrapper: options?.wrapper ?? true,
44
+ container: new Gtk.Box(),
45
+ wrapper: options?.wrapper,
59
46
  });
60
47
 
61
48
  return {
62
- result: resultRef,
49
+ result: resultRef as { current: Result },
63
50
  rerender: async (newProps?: Props) => {
64
51
  if (newProps !== undefined) {
65
52
  currentProps = newProps;
@@ -68,4 +55,4 @@ export const renderHook = async <Result, Props>(
68
55
  },
69
56
  unmount: renderResult.unmount,
70
57
  };
71
- };
58
+ }
package/src/render.tsx CHANGED
@@ -1,192 +1,204 @@
1
- import { start, stop } from "@gtkx/ffi";
2
- import * as Gio from "@gtkx/ffi/gio";
3
- import type * as Gtk from "@gtkx/ffi/gtk";
4
- import { ApplicationContext, GtkApplicationWindow, reconciler } from "@gtkx/react";
5
- import { createRef, type ReactNode, type Ref } from "react";
6
- import type Reconciler from "react-reconciler";
7
- import { bindQueries } from "./bind-queries.js";
8
- import { prettyWidget } from "./pretty-widget.js";
9
- import { setScreenRoot } from "./screen.js";
10
- import { tick } from "./timing.js";
11
- import { type Container, isApplication, traverse } from "./traversal.js";
12
- import type { RenderOptions, RenderResult, WrapperComponent } from "./types.js";
13
-
14
- let application: Gtk.Application | null = null;
15
- let container: Reconciler.FiberRoot | null = null;
1
+ import * as Gtk from "@gtkx/gi/gtk";
2
+ import type { RootElement } from "@gtkx/react";
3
+ import {
4
+ createReconcilerRoot,
5
+ isRootElement,
6
+ type ReconcilerRoot,
7
+ setReconcilerErrorHandler,
8
+ } from "@gtkx/react/internal";
9
+ import { type ErrorInfo, type ReactNode, StrictMode } from "react";
10
+ import { runInAct } from "./act.js";
11
+ import type { RenderResult } from "./bound-queries.js";
12
+ import { addToCleanupQueue, runCleanup } from "./cleanup-registry.js";
13
+ import { scheduleAfterLayout } from "./frame-sync.js";
14
+ import { logWidget, type PrettyWidgetOptions } from "./pretty-widget.js";
15
+ import { logRoles } from "./role-helpers.js";
16
+ import { clearScreen, setScreen } from "./screen.js";
17
+ import { captureAndSaveScreenshot } from "./screenshot.js";
18
+ import { type Container, TOPLEVELS, traverse } from "./traversal.js";
19
+ import type { QueryMap, RenderOptions, ScreenshotOptions, WindowSelector } from "./types.js";
20
+ import { resetClipboard } from "./user-event/index.js";
21
+ import { within } from "./within.js";
22
+
16
23
  let lastRenderError: Error | null = null;
24
+ let errorHandlerInstalled = false;
17
25
 
18
- type ReconcilerInstance = ReturnType<typeof reconciler.getInstance>;
26
+ type ActiveRender = {
27
+ root: ReconcilerRoot;
28
+ window: Gtk.Window | null;
29
+ };
19
30
 
20
- const update = async (
21
- instance: ReconcilerInstance,
22
- element: ReactNode,
23
- fiberRoot: Reconciler.FiberRoot,
24
- ): Promise<void> => {
25
- lastRenderError = null;
26
- instance.updateContainer(element, fiberRoot, null, () => {});
27
- await tick();
31
+ const activeRenders = new Set<ActiveRender>();
32
+
33
+ const HARNESS_WINDOW_WIDTH = 800;
34
+ const HARNESS_WINDOW_HEIGHT = 600;
35
+
36
+ const flushLayout = (window: Gtk.Window | null): Promise<void> =>
37
+ new Promise((resolve) => {
38
+ scheduleAfterLayout(window, resolve);
39
+ });
40
+
41
+ const update = async (element: ReactNode, root: ReconcilerRoot): Promise<void> => {
42
+ await runInAct(() => {
43
+ root.update(element);
44
+ });
28
45
 
29
46
  if (lastRenderError) {
30
- const error = lastRenderError;
47
+ const captured = lastRenderError;
31
48
  lastRenderError = null;
32
- throw error;
49
+ throw captured;
33
50
  }
34
51
  };
35
52
 
36
- const handleError = (error: Error): void => {
37
- lastRenderError = error;
53
+ const disposeActiveRender = async (active: ActiveRender): Promise<void> => {
54
+ if (!activeRenders.delete(active)) return;
55
+ await active.root.unmount(async (root) => {
56
+ await update(null, root);
57
+ active.window?.destroy();
58
+ });
38
59
  };
39
60
 
40
- const ensureInitialized = (): { app: Gtk.Application; container: Reconciler.FiberRoot } => {
41
- application = start("org.gtkx.testing", Gio.ApplicationFlags.NON_UNIQUE);
42
-
43
- if (!container) {
44
- const instance = reconciler.getInstance();
45
- container = instance.createContainer(
46
- application,
47
- 1,
48
- null,
49
- false,
50
- null,
51
- "",
52
- handleError,
53
- handleError,
54
- () => {},
55
- () => {},
56
- );
61
+ const disposeAllActiveRenders = async (): Promise<void> => {
62
+ for (const active of [...activeRenders]) {
63
+ await disposeActiveRender(active);
57
64
  }
65
+ };
58
66
 
59
- return { app: application, container };
67
+ const handleError = (error: unknown): void => {
68
+ lastRenderError = error instanceof Error ? error : new Error(String(error));
60
69
  };
61
70
 
62
- const DefaultWrapper: WrapperComponent = ({ children, ref }) => (
63
- <GtkApplicationWindow ref={ref as Ref<Gtk.ApplicationWindow>} defaultWidth={800} defaultHeight={600}>
64
- {children}
65
- </GtkApplicationWindow>
66
- );
71
+ const installErrorHandler = (): void => {
72
+ if (errorHandlerInstalled) return;
73
+ setReconcilerErrorHandler(handleError);
74
+ errorHandlerInstalled = true;
75
+ };
67
76
 
68
- const findFirstWidget = (root: Container): Gtk.Widget | null => {
69
- for (const widget of traverse(root)) {
70
- if (isApplication(root)) return widget;
71
- return root;
77
+ type ResolvedContainer = {
78
+ containerInfo: Gtk.Widget | RootElement;
79
+ window: Gtk.Window | null;
80
+ };
81
+
82
+ const resolveContainer = (container: RenderOptions["container"]): ResolvedContainer => {
83
+ if (isRootElement(container)) {
84
+ return { containerInfo: container, window: null };
85
+ }
86
+ if (container instanceof Gtk.Widget) {
87
+ return { containerInfo: container, window: null };
72
88
  }
73
- return null;
89
+ const window = new Gtk.Window({ defaultWidth: HARNESS_WINDOW_WIDTH, defaultHeight: HARNESS_WINDOW_HEIGHT });
90
+ window.setTitlebar(new Gtk.HeaderBar({ showTitleButtons: false }));
91
+ return { containerInfo: window, window };
74
92
  };
75
93
 
76
- const wrapElement = (
77
- element: ReactNode,
78
- wrapperRef: React.RefObject<Gtk.Widget | null>,
79
- wrapper: RenderOptions["wrapper"],
80
- ): ReactNode => {
81
- if (wrapper === false || wrapper === undefined) return element;
82
- const Wrapper = wrapper === true ? DefaultWrapper : wrapper;
83
- return <Wrapper ref={wrapperRef}>{element}</Wrapper>;
94
+ const firstToplevelWidget = (baseElement: Container): Gtk.Widget => {
95
+ if (baseElement instanceof Gtk.Widget) return baseElement;
96
+ const [first] = traverse(baseElement);
97
+ if (first) return first;
98
+ throw new Error("render() produced no widgets: ensure the element renders visible content");
84
99
  };
85
100
 
86
- const resolveContainer = (
87
- wrapper: RenderOptions["wrapper"],
88
- wrapperRef: React.RefObject<Gtk.Widget | null>,
101
+ const resolveResultContainer = (
102
+ resolved: ResolvedContainer,
103
+ container: RenderOptions["container"],
89
104
  baseElement: Container,
90
105
  ): Gtk.Widget => {
91
- if (wrapper !== false && wrapper !== undefined && wrapperRef.current) {
92
- return wrapperRef.current;
93
- }
94
- const firstWidget = findFirstWidget(baseElement);
95
- if (!firstWidget) {
96
- throw new Error("render() produced no widgets. Ensure the element renders visible content.");
106
+ if (resolved.window) return resolved.window;
107
+ if (container instanceof Gtk.Widget) return container;
108
+ return firstToplevelWidget(baseElement);
109
+ };
110
+
111
+ const renderErrorHandlers = <Q extends QueryMap>(options: RenderOptions<Q> | undefined) => ({
112
+ onUncaughtError: handleError,
113
+ onCaughtError: (error: unknown, errorInfo: ErrorInfo): void => {
114
+ handleError(error);
115
+ options?.onCaughtError?.(error, errorInfo);
116
+ },
117
+ onRecoverableError: (error: unknown, errorInfo: ErrorInfo): void => {
118
+ options?.onRecoverableError?.(error, errorInfo);
119
+ },
120
+ });
121
+
122
+ const applyEnableAnimations = (enabled: boolean): void => {
123
+ const settings = Gtk.Settings.getDefault();
124
+ if (settings) {
125
+ settings.gtkEnableAnimations = enabled;
97
126
  }
98
- return firstWidget;
99
127
  };
100
128
 
101
129
  /**
102
- * Renders a React element for testing.
103
- *
104
- * Creates a GTK application context and renders the element, returning
105
- * query methods and utilities for interacting with the rendered widgets.
106
- *
107
- * @param element - The React element to render
108
- * @param options - Render options including wrapper configuration
109
- * @returns A promise resolving to query methods and utilities
130
+ * Renders a React element into a GTK4 widget tree and returns queries
131
+ * scoped to it along with controls for rerendering and unmounting. When no
132
+ * container is supplied, a harness window is created and presented.
110
133
  *
111
- * @example
112
- * ```tsx
113
- * import { render, screen } from "@gtkx/testing";
114
- *
115
- * test("button click", async () => {
116
- * await render(<MyButton />);
117
- * const button = await screen.findByRole(Gtk.AccessibleRole.BUTTON);
118
- * await userEvent.click(button);
119
- * });
120
- * ```
121
- *
122
- * @see {@link cleanup} for cleaning up after tests
123
- * @see {@link screen} for global query access
134
+ * @param element The React element to render.
135
+ * @param options Optional container, wrapper, custom queries, and other render settings.
136
+ * @returns A render result with bound queries, debug helpers, and lifecycle controls.
124
137
  */
125
- export const render = async (element: ReactNode, options?: RenderOptions): Promise<RenderResult> => {
126
- const { app: application, container: fiberRoot } = ensureInitialized();
127
- const instance = reconciler.getInstance();
128
- const baseElement: Container = options?.baseElement ?? application;
129
- const wrapper = options?.wrapper ?? true;
138
+ export const render = async <Q extends QueryMap = Record<never, never>>(
139
+ element: ReactNode,
140
+ options?: RenderOptions<Q>,
141
+ ): Promise<RenderResult<Q>> => {
142
+ installErrorHandler();
143
+
144
+ applyEnableAnimations(options?.animations === true);
145
+
146
+ const baseElement: Container = options?.baseElement ?? TOPLEVELS;
147
+ const Wrapper = options?.wrapper;
148
+
149
+ const resolved = resolveContainer(options?.container);
150
+ const root = createReconcilerRoot({
151
+ containerInfo: resolved.containerInfo,
152
+ ...renderErrorHandlers(options),
153
+ });
154
+ const active: ActiveRender = { root, window: resolved.window };
155
+ activeRenders.add(active);
156
+
157
+ addToCleanupQueue(disposeAllActiveRenders);
158
+ addToCleanupQueue(clearScreen);
159
+ addToCleanupQueue(resetClipboard);
160
+
161
+ const wrap = (node: ReactNode): ReactNode => {
162
+ const wrapped = Wrapper ? <Wrapper>{node}</Wrapper> : node;
163
+ return options?.reactStrictMode ? <StrictMode>{wrapped}</StrictMode> : wrapped;
164
+ };
130
165
 
131
- const wrapperRef = createRef<Gtk.Widget>();
132
- const wrappedElement = wrapElement(element, wrapperRef, wrapper);
133
- const withContext = <ApplicationContext.Provider value={application}>{wrappedElement}</ApplicationContext.Provider>;
134
- await update(instance, withContext, fiberRoot);
166
+ await update(wrap(element), root);
167
+ resolved.window?.present();
168
+ await flushLayout(resolved.window);
135
169
 
136
- setScreenRoot(application);
170
+ const container = resolveResultContainer(resolved, options?.container, baseElement);
137
171
 
138
- return {
139
- container: resolveContainer(wrapper, wrapperRef, baseElement),
172
+ const result: RenderResult<Q> = {
173
+ ...within(baseElement, options?.queries),
174
+ container,
140
175
  baseElement,
141
- ...bindQueries(baseElement),
142
- unmount: () => update(instance, null, fiberRoot),
176
+ unmount: async () => {
177
+ await disposeActiveRender(active);
178
+ },
143
179
  rerender: async (newElement: ReactNode) => {
144
- const newWrapperRef = createRef<Gtk.Widget>();
145
- const wrapped = wrapElement(newElement, newWrapperRef, wrapper);
146
- const withCtx = <ApplicationContext.Provider value={application}>{wrapped}</ApplicationContext.Provider>;
147
- await update(instance, withCtx, fiberRoot);
180
+ await update(wrap(newElement), root);
181
+ await flushLayout(resolved.window);
182
+ },
183
+ debug: (element: Container | Container[] = baseElement, debugOptions?: PrettyWidgetOptions) => {
184
+ logWidget(element, debugOptions);
148
185
  },
149
- debug: () => {
150
- console.log(prettyWidget(application));
186
+ logRoles: () => {
187
+ logRoles(baseElement);
151
188
  },
189
+ screenshot: (selector?: WindowSelector, screenshotOptions?: ScreenshotOptions) =>
190
+ captureAndSaveScreenshot(selector, screenshotOptions),
152
191
  };
192
+
193
+ setScreen(result);
194
+
195
+ return result;
153
196
  };
154
197
 
155
198
  /**
156
- * Cleans up the rendered component tree.
157
- *
158
- * Unmounts all rendered components and resets the testing environment.
159
- * Call this in `afterEach` to ensure tests don't affect each other.
160
- *
161
- * @example
162
- * ```tsx
163
- * import { render, cleanup } from "@gtkx/testing";
164
- *
165
- * afterEach(async () => {
166
- * await cleanup();
167
- * });
168
- *
169
- * test("my test", async () => {
170
- * await render(<MyComponent />);
171
- * // ...
172
- * });
173
- * ```
199
+ * Unmounts every active render and runs all registered cleanup callbacks,
200
+ * resetting the screen and clipboard. Called automatically after each test.
174
201
  */
175
202
  export const cleanup = async (): Promise<void> => {
176
- if (container && application) {
177
- const instance = reconciler.getInstance();
178
- await update(instance, null, container);
179
- }
180
- container = null;
181
- setScreenRoot(null);
182
- };
183
-
184
- const handleSignal = (): void => {
185
- try {
186
- stop();
187
- } catch {}
188
- process.exit(0);
203
+ await runCleanup();
189
204
  };
190
-
191
- process.on("SIGTERM", handleSignal);
192
- process.on("SIGINT", handleSignal);
@@ -1,64 +1,59 @@
1
- import * as Gtk from "@gtkx/ffi/gtk";
1
+ import * as Gtk from "@gtkx/gi/gtk";
2
+ import { sortStringsBy } from "@gtkx/utils";
2
3
  import { type Container, traverse } from "./traversal.js";
3
- import { getWidgetAccessibleName } from "./widget-text.js";
4
+ import { getWidgetAccessibleName } from "./widget-accessible-properties.js";
4
5
 
5
- /**
6
- * Information about a widget and its accessible name.
7
- */
8
- export type RoleInfo = {
9
- widget: Gtk.Widget;
10
- name: string | null;
11
- };
6
+ const enumNamesByValue = (enumObject: Record<string, string | number>): Map<number, string> =>
7
+ new Map<number, string>(
8
+ Object.entries(enumObject)
9
+ .filter((entry): entry is [string, number] => typeof entry[1] === "number")
10
+ .map(([name, value]) => [value, name]),
11
+ );
12
+
13
+ const ROLE_NAMES_BY_VALUE = enumNamesByValue(Gtk.AccessibleRole);
12
14
 
13
15
  /**
14
- * Formats a GTK accessible role to a lowercase string.
16
+ * Converts an accessible role enum value into its lowercase name, falling back
17
+ * to the numeric value when the role is unknown.
15
18
  *
16
- * @param role - The GTK accessible role
17
- * @returns Lowercase role name (e.g., "button", "checkbox")
19
+ * @param role The accessible role to format.
18
20
  */
19
- export const formatRole = (role: Gtk.AccessibleRole | undefined): string => {
20
- if (role === undefined) return "unknown";
21
- const name = Gtk.AccessibleRole[role];
21
+ export const formatRole = (role: Gtk.AccessibleRole): string => {
22
+ const name = ROLE_NAMES_BY_VALUE.get(role);
22
23
  if (!name) return String(role);
23
24
  return name.toLowerCase();
24
25
  };
25
26
 
27
+ export const formatRoleList = (roles: Iterable<Gtk.AccessibleRole>): string => {
28
+ const names = [...roles].map((role) => formatRole(role).toUpperCase());
29
+ if (names.length <= 1) {
30
+ return names.join("");
31
+ }
32
+ if (names.length === 2) {
33
+ return `${names[0]} or ${names[1]}`;
34
+ }
35
+ const head = names.slice(0, -1);
36
+ const last = names[names.length - 1];
37
+ return `${head.join(", ")}, or ${last}`;
38
+ };
39
+
26
40
  /**
27
- * Collects all accessible roles and their widgets from a container.
41
+ * Groups every widget in a container's tree by its accessible role name.
28
42
  *
29
- * Returns a Map where keys are role names (lowercase) and values are
30
- * arrays of widgets with that role, including their accessible names.
31
- *
32
- * @param container - The container to scan for roles
33
- * @returns Map of role names to arrays of RoleInfo
34
- *
35
- * @example
36
- * ```tsx
37
- * import { getRoles } from "@gtkx/testing";
38
- *
39
- * const roles = getRoles(container);
40
- * // Map {
41
- * // "button" => [{ widget: ..., name: "Submit" }, { widget: ..., name: "Cancel" }],
42
- * // "checkbox" => [{ widget: ..., name: "Remember me" }]
43
- * // }
44
- * ```
43
+ * @param container The scope to traverse.
44
+ * @returns A map from role name to the widgets that have that role.
45
45
  */
46
- export const getRoles = (container: Container): Map<string, RoleInfo[]> => {
47
- const roles = new Map<string, RoleInfo[]>();
46
+ export const getRoles = (container: Container): Map<string, Gtk.Widget[]> => {
47
+ const roles = new Map<string, Gtk.Widget[]>();
48
48
 
49
49
  for (const widget of traverse(container)) {
50
- const role = widget.getAccessibleRole?.();
51
- if (role === undefined) continue;
52
-
53
- const roleName = formatRole(role);
54
- const name = getWidgetAccessibleName(widget);
55
- const info: RoleInfo = { widget, name };
50
+ const roleName = formatRole(widget.getAccessibleRole());
56
51
 
57
52
  const existing = roles.get(roleName);
58
53
  if (existing) {
59
- existing.push(info);
54
+ existing.push(widget);
60
55
  } else {
61
- roles.set(roleName, [info]);
56
+ roles.set(roleName, [widget]);
62
57
  }
63
58
  }
64
59
 
@@ -67,16 +62,16 @@ export const getRoles = (container: Container): Map<string, RoleInfo[]> => {
67
62
 
68
63
  const formatWidgetPreview = (widget: Gtk.Widget, name: string | null): string => {
69
64
  const tagName = widget.constructor.name;
70
- const roleAttr = formatRole(widget.getAccessibleRole?.());
65
+ const roleAttr = formatRole(widget.getAccessibleRole());
71
66
  const nameDisplay = name ? `Name "${name}"` : 'Name ""';
72
67
  return `${nameDisplay}: <${tagName} role="${roleAttr}">${name ?? ""}</${tagName}>`;
73
68
  };
74
69
 
75
70
  /**
76
- * Formats roles into a readable string for error messages.
71
+ * Formats the accessible roles in a container's tree as a readable string,
72
+ * listing each role together with its widgets and their accessible names.
77
73
  *
78
- * @param container - The container to format roles for
79
- * @returns Formatted string showing all roles and their accessible names
74
+ * @param container The scope to inspect.
80
75
  */
81
76
  export const prettyRoles = (container: Container): string => {
82
77
  const roles = getRoles(container);
@@ -87,12 +82,12 @@ export const prettyRoles = (container: Container): string => {
87
82
 
88
83
  const lines: string[] = [];
89
84
 
90
- const sortedRoles = [...roles.entries()].sort(([a], [b]) => a.localeCompare(b));
85
+ const sortedRoles = sortStringsBy([...roles.entries()], ([roleName]) => roleName);
91
86
 
92
87
  for (const [roleName, widgets] of sortedRoles) {
93
88
  lines.push(`${roleName}:`);
94
- for (const { widget, name } of widgets) {
95
- lines.push(` ${formatWidgetPreview(widget, name)}`);
89
+ for (const widget of widgets) {
90
+ lines.push(` ${formatWidgetPreview(widget, getWidgetAccessibleName(widget))}`);
96
91
  }
97
92
  lines.push("");
98
93
  }
@@ -101,25 +96,10 @@ export const prettyRoles = (container: Container): string => {
101
96
  };
102
97
 
103
98
  /**
104
- * Logs all accessible roles in a container to the console.
105
- *
106
- * Useful for debugging test failures and discovering available roles.
107
- *
108
- * @param container - The container to log roles for
109
- *
110
- * @example
111
- * ```tsx
112
- * import { render, logRoles } from "@gtkx/testing";
99
+ * Prints the accessible roles in a container's tree to the console using
100
+ * {@link prettyRoles}.
113
101
  *
114
- * const { container } = await render(<MyComponent />);
115
- * logRoles(container);
116
- * // Console output:
117
- * // button:
118
- * // Name "Submit": <GtkButton role="button">Submit</GtkButton>
119
- * // Name "Cancel": <GtkButton role="button">Cancel</GtkButton>
120
- * // checkbox:
121
- * // Name "Remember me": <GtkCheckButton role="checkbox">Remember me</GtkCheckButton>
122
- * ```
102
+ * @param container The scope to inspect.
123
103
  */
124
104
  export const logRoles = (container: Container): void => {
125
105
  console.log(prettyRoles(container));