@gtkx/testing 0.21.0 → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/README.md +137 -34
  2. package/dist/act.d.ts +19 -0
  3. package/dist/act.d.ts.map +1 -0
  4. package/dist/act.js +56 -0
  5. package/dist/act.js.map +1 -0
  6. package/dist/bound-queries.d.ts +33 -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 +115 -0
  13. package/dist/build-queries.js.map +1 -0
  14. package/dist/cleanup-registry.d.ts +5 -0
  15. package/dist/cleanup-registry.d.ts.map +1 -0
  16. package/dist/cleanup-registry.js +21 -0
  17. package/dist/cleanup-registry.js.map +1 -0
  18. package/dist/config.d.ts +20 -52
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +17 -46
  21. package/dist/config.js.map +1 -1
  22. package/dist/editable.d.ts +8 -0
  23. package/dist/editable.d.ts.map +1 -0
  24. package/dist/editable.js +26 -0
  25. package/dist/editable.js.map +1 -0
  26. package/dist/errors.d.ts +30 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +135 -0
  29. package/dist/errors.js.map +1 -0
  30. package/dist/fire-event.d.ts +8 -25
  31. package/dist/fire-event.d.ts.map +1 -1
  32. package/dist/fire-event.js +11 -30
  33. package/dist/fire-event.js.map +1 -1
  34. package/dist/frame-sync.d.ts +4 -0
  35. package/dist/frame-sync.d.ts.map +1 -0
  36. package/dist/frame-sync.js +37 -0
  37. package/dist/frame-sync.js.map +1 -0
  38. package/dist/index.d.ts +17 -12
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +11 -7
  41. package/dist/index.js.map +1 -1
  42. package/dist/matchers.d.ts +59 -0
  43. package/dist/matchers.d.ts.map +1 -0
  44. package/dist/matchers.js +117 -0
  45. package/dist/matchers.js.map +1 -0
  46. package/dist/pretty-widget.d.ts +24 -26
  47. package/dist/pretty-widget.d.ts.map +1 -1
  48. package/dist/pretty-widget.js +111 -117
  49. package/dist/pretty-widget.js.map +1 -1
  50. package/dist/queries.d.ts +190 -180
  51. package/dist/queries.d.ts.map +1 -1
  52. package/dist/queries.js +291 -320
  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 +16 -0
  57. package/dist/register-test-hooks.js.map +1 -0
  58. package/dist/render-hook.d.ts +15 -35
  59. package/dist/render-hook.d.ts.map +1 -1
  60. package/dist/render-hook.js +9 -45
  61. package/dist/render-hook.js.map +1 -1
  62. package/dist/render.d.ts +13 -42
  63. package/dist/render.d.ts.map +1 -1
  64. package/dist/render.js +138 -121
  65. package/dist/render.js.map +1 -1
  66. package/dist/role-helpers.d.ts +19 -53
  67. package/dist/role-helpers.d.ts.map +1 -1
  68. package/dist/role-helpers.js +48 -63
  69. package/dist/role-helpers.js.map +1 -1
  70. package/dist/screen.d.ts +7 -63
  71. package/dist/screen.d.ts.map +1 -1
  72. package/dist/screen.js +24 -110
  73. package/dist/screen.js.map +1 -1
  74. package/dist/screenshot.d.ts +21 -23
  75. package/dist/screenshot.d.ts.map +1 -1
  76. package/dist/screenshot.js +98 -50
  77. package/dist/screenshot.js.map +1 -1
  78. package/dist/suggestions.d.ts +27 -0
  79. package/dist/suggestions.d.ts.map +1 -0
  80. package/dist/suggestions.js +61 -0
  81. package/dist/suggestions.js.map +1 -0
  82. package/dist/traversal.d.ts +12 -10
  83. package/dist/traversal.d.ts.map +1 -1
  84. package/dist/traversal.js +36 -15
  85. package/dist/traversal.js.map +1 -1
  86. package/dist/types.d.ts +115 -169
  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 +10 -0
  90. package/dist/user-event/adjustment.d.ts.map +1 -0
  91. package/dist/user-event/adjustment.js +35 -0
  92. package/dist/user-event/adjustment.js.map +1 -0
  93. package/dist/user-event/click.d.ts +8 -0
  94. package/dist/user-event/click.d.ts.map +1 -0
  95. package/dist/user-event/click.js +73 -0
  96. package/dist/user-event/click.js.map +1 -0
  97. package/dist/user-event/controller.d.ts +8 -0
  98. package/dist/user-event/controller.d.ts.map +1 -0
  99. package/dist/user-event/controller.js +31 -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 +11 -0
  104. package/dist/user-event/dispatch.js.map +1 -0
  105. package/dist/user-event/event-wrapper.d.ts +4 -0
  106. package/dist/user-event/event-wrapper.d.ts.map +1 -0
  107. package/dist/user-event/event-wrapper.js +59 -0
  108. package/dist/user-event/event-wrapper.js.map +1 -0
  109. package/dist/user-event/gesture.d.ts +35 -0
  110. package/dist/user-event/gesture.d.ts.map +1 -0
  111. package/dist/user-event/gesture.js +138 -0
  112. package/dist/user-event/gesture.js.map +1 -0
  113. package/dist/user-event/index.d.ts +51 -0
  114. package/dist/user-event/index.d.ts.map +1 -0
  115. package/dist/user-event/index.js +42 -0
  116. package/dist/user-event/index.js.map +1 -0
  117. package/dist/user-event/keyboard.d.ts +10 -0
  118. package/dist/user-event/keyboard.d.ts.map +1 -0
  119. package/dist/user-event/keyboard.js +204 -0
  120. package/dist/user-event/keyboard.js.map +1 -0
  121. package/dist/user-event/pointer.d.ts +10 -0
  122. package/dist/user-event/pointer.d.ts.map +1 -0
  123. package/dist/user-event/pointer.js +30 -0
  124. package/dist/user-event/pointer.js.map +1 -0
  125. package/dist/user-event/selection.d.ts +5 -0
  126. package/dist/user-event/selection.d.ts.map +1 -0
  127. package/dist/user-event/selection.js +105 -0
  128. package/dist/user-event/selection.js.map +1 -0
  129. package/dist/user-event/state.d.ts +7 -0
  130. package/dist/user-event/state.d.ts.map +1 -0
  131. package/dist/user-event/state.js +3 -0
  132. package/dist/user-event/state.js.map +1 -0
  133. package/dist/user-event/text.d.ts +18 -0
  134. package/dist/user-event/text.d.ts.map +1 -0
  135. package/dist/user-event/text.js +113 -0
  136. package/dist/user-event/text.js.map +1 -0
  137. package/dist/wait-for.d.ts +16 -36
  138. package/dist/wait-for.d.ts.map +1 -1
  139. package/dist/wait-for.js +74 -70
  140. package/dist/wait-for.js.map +1 -1
  141. package/dist/widget-accessible-properties.d.ts +34 -0
  142. package/dist/widget-accessible-properties.d.ts.map +1 -0
  143. package/dist/widget-accessible-properties.js +260 -0
  144. package/dist/widget-accessible-properties.js.map +1 -0
  145. package/dist/within.d.ts +10 -26
  146. package/dist/within.d.ts.map +1 -1
  147. package/dist/within.js +16 -25
  148. package/dist/within.js.map +1 -1
  149. package/package.json +23 -12
  150. package/src/act.ts +77 -0
  151. package/src/bound-queries.ts +37 -0
  152. package/src/build-queries.ts +226 -0
  153. package/src/cleanup-registry.ts +27 -0
  154. package/src/config.ts +35 -62
  155. package/src/editable.ts +37 -0
  156. package/src/errors.ts +191 -0
  157. package/src/fire-event.ts +13 -38
  158. package/src/frame-sync.ts +51 -0
  159. package/src/index.ts +58 -15
  160. package/src/matchers.ts +251 -0
  161. package/src/pretty-widget.ts +163 -130
  162. package/src/queries.ts +485 -396
  163. package/src/register-test-hooks.ts +19 -0
  164. package/src/render-hook.tsx +38 -45
  165. package/src/render.tsx +192 -155
  166. package/src/role-helpers.ts +65 -72
  167. package/src/screen.ts +26 -114
  168. package/src/screenshot.ts +128 -56
  169. package/src/suggestions.ts +106 -0
  170. package/src/traversal.ts +56 -22
  171. package/src/types.ts +144 -172
  172. package/src/user-event/adjustment.ts +62 -0
  173. package/src/user-event/click.ts +99 -0
  174. package/src/user-event/controller.ts +58 -0
  175. package/src/user-event/dispatch.ts +25 -0
  176. package/src/user-event/event-wrapper.ts +80 -0
  177. package/src/user-event/gesture.ts +239 -0
  178. package/src/user-event/index.ts +81 -0
  179. package/src/user-event/keyboard.ts +290 -0
  180. package/src/user-event/pointer.ts +46 -0
  181. package/src/user-event/selection.ts +159 -0
  182. package/src/user-event/state.ts +8 -0
  183. package/src/user-event/text.ts +172 -0
  184. package/src/wait-for.ts +117 -78
  185. package/src/widget-accessible-properties.ts +388 -0
  186. package/src/within.ts +29 -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,19 @@
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
+
8
+ if (typeof hook === "function") {
9
+ (hook as (callback: () => unknown) => void)(callback);
10
+ }
11
+ };
12
+
13
+ const registerTestRuntimeHooks = (): void => {
14
+ callRunnerHook("afterEach", cleanup);
15
+ callRunnerHook("afterAll", quit);
16
+ registerMatchers();
17
+ };
18
+
19
+ registerTestRuntimeHooks();
@@ -1,71 +1,64 @@
1
- import { useRef } from "react";
2
- import { render } from "./render.js";
1
+ import * as Gtk from "@gtkx/gi/gtk";
3
2
  import type { RenderHookOptions, RenderHookResult } from "./types.js";
3
+ import { render } from "./render.js";
4
4
 
5
5
  /**
6
- * Renders a React hook for testing.
7
- *
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
- * );
6
+ * Renders a test component that calls the given hook, exposing its latest
7
+ * return value along with rerender and unmount controls.
35
8
  *
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
+ function renderHook<Result>(
14
+ callback: () => Result,
15
+ options?: RenderHookOptions<undefined>,
16
+ ): Promise<RenderHookResult<Result, undefined>>;
17
+
18
+ /**
19
+ * Renders a test component that calls the given hook with props, exposing its
20
+ * latest return value along with rerender and unmount controls.
37
21
  *
38
- * await rerender({ multiplier: 3 });
39
- * expect(result.current).toBe(3);
40
- * });
41
- * ```
22
+ * @param callback The hook to run, invoked with the current props.
23
+ * @param options Initial props and optional wrapper component.
24
+ * @returns A result whose `result.current` holds the hook's latest value.
42
25
  */
43
- export const renderHook = async <Result, Props>(
26
+ function renderHook<Result, Props>(
27
+ callback: (props: Props) => Result,
28
+ options: RenderHookOptions<Props>,
29
+ ): Promise<RenderHookResult<Result, Props>>;
30
+
31
+ async function renderHook<Result, Props>(
44
32
  callback: (props: Props) => Result,
45
33
  options?: RenderHookOptions<Props>,
46
- ): Promise<RenderHookResult<Result, Props>> => {
47
- const resultRef = { current: undefined as Result };
48
- let currentProps = options?.initialProps as Props;
34
+ ): Promise<RenderHookResult<Result, Props>> {
35
+ const initialProps = (options as { initialProps?: Props } | undefined)?.initialProps as Props;
36
+ const resultRef: { current: Result | undefined } = { current: undefined };
37
+ let currentProps: Props = initialProps;
49
38
 
50
39
  const TestComponent = ({ props }: { props: Props }): null => {
51
40
  const result = callback(props);
52
- const ref = useRef(resultRef);
53
- ref.current.current = result;
41
+ resultRef.current = result;
42
+
54
43
  return null;
55
44
  };
56
45
 
57
46
  const renderResult = await render(<TestComponent props={currentProps} />, {
58
- wrapper: options?.wrapper ?? true,
47
+ container: new Gtk.Box(),
48
+ wrapper: options?.wrapper,
59
49
  });
60
50
 
61
51
  return {
62
- result: resultRef,
52
+ result: resultRef as { current: Result },
63
53
  rerender: async (newProps?: Props) => {
64
54
  if (newProps !== undefined) {
65
55
  currentProps = newProps;
66
56
  }
57
+
67
58
  await renderResult.rerender(<TestComponent props={currentProps} />);
68
59
  },
69
60
  unmount: renderResult.unmount,
70
61
  };
71
- };
62
+ }
63
+
64
+ export { renderHook };
package/src/render.tsx CHANGED
@@ -1,192 +1,229 @@
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;
16
- let lastRenderError: Error | null = null;
17
-
18
- type ReconcilerInstance = ReturnType<typeof reconciler.getInstance>;
19
-
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();
28
-
29
- if (lastRenderError) {
30
- const error = lastRenderError;
31
- lastRenderError = null;
32
- throw error;
1
+ import type { RootElement } from "@gtkx/react";
2
+ import * as Gtk from "@gtkx/gi/gtk";
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 type { RenderResult } from "./bound-queries.js";
11
+ import type { QueryMap, RenderOptions, ScreenshotOptions, WindowSelector } from "./types.js";
12
+ import { runInAct } from "./act.js";
13
+ import { addToCleanupQueue, runCleanup } from "./cleanup-registry.js";
14
+ import { scheduleAfterLayout } from "./frame-sync.js";
15
+ import { logWidget, type PrettyWidgetOptions } from "./pretty-widget.js";
16
+ import { logRoles } from "./role-helpers.js";
17
+ import { clearScreen, setScreen } from "./screen.js";
18
+ import { captureAndSaveScreenshot } from "./screenshot.js";
19
+ import { type Container, TOPLEVELS, traverse } from "./traversal.js";
20
+ import { resetClipboard } from "./user-event/index.js";
21
+ import { within } from "./within.js";
22
+
23
+ type ActiveRender = {
24
+ root: ReconcilerRoot;
25
+ window: Gtk.Window | null;
26
+ };
27
+
28
+ type ResolvedContainer = {
29
+ containerInfo: Gtk.Widget | RootElement;
30
+ window: Gtk.Window | null;
31
+ };
32
+
33
+ type ReconcilerErrorState = {
34
+ lastError: Error | null;
35
+ isHandlerInstalled: boolean;
36
+ };
37
+
38
+ const reconcilerErrors: ReconcilerErrorState = { lastError: null, isHandlerInstalled: false };
39
+ const activeRenders: Set<ActiveRender> = new Set();
40
+ const HARNESS_WINDOW_WIDTH = 800;
41
+ const HARNESS_WINDOW_HEIGHT = 600;
42
+
43
+ const flushLayout = (window: Gtk.Window | null): Promise<void> =>
44
+ new Promise((resolve) => {
45
+ scheduleAfterLayout(window, resolve);
46
+ });
47
+
48
+ const update = async (element: ReactNode, root: ReconcilerRoot): Promise<void> => {
49
+ await runInAct(() => {
50
+ root.update(element);
51
+ });
52
+
53
+ if (reconcilerErrors.lastError) {
54
+ const captured = reconcilerErrors.lastError;
55
+ reconcilerErrors.lastError = null;
56
+ throw captured;
33
57
  }
34
58
  };
35
59
 
36
- const handleError = (error: Error): void => {
37
- lastRenderError = error;
60
+ const disposeActiveRender = async (active: ActiveRender): Promise<void> => {
61
+ if (!activeRenders.delete(active)) {
62
+ return;
63
+ }
64
+
65
+ await active.root.unmount(async (root) => {
66
+ await update(null, root);
67
+ active.window?.destroy();
68
+ });
38
69
  };
39
70
 
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
- );
71
+ const disposeAllActiveRenders = async (): Promise<void> => {
72
+ for (const active of activeRenders) {
73
+ await disposeActiveRender(active);
57
74
  }
75
+ };
58
76
 
59
- return { app: application, container };
77
+ const handleError = (error: unknown): void => {
78
+ reconcilerErrors.lastError = error instanceof Error ? error : new Error(String(error));
60
79
  };
61
80
 
62
- const DefaultWrapper: WrapperComponent = ({ children, ref }) => (
63
- <GtkApplicationWindow ref={ref as Ref<Gtk.ApplicationWindow>} defaultWidth={800} defaultHeight={600}>
64
- {children}
65
- </GtkApplicationWindow>
66
- );
81
+ const installErrorHandler = (): void => {
82
+ if (reconcilerErrors.isHandlerInstalled) {
83
+ return;
84
+ }
67
85
 
68
- const findFirstWidget = (root: Container): Gtk.Widget | null => {
69
- for (const widget of traverse(root)) {
70
- if (isApplication(root)) return widget;
71
- return root;
86
+ setReconcilerErrorHandler(handleError);
87
+ reconcilerErrors.isHandlerInstalled = true;
88
+ };
89
+
90
+ const resolveContainer = (container: RenderOptions["container"]): ResolvedContainer => {
91
+ if (isRootElement(container)) {
92
+ return { containerInfo: container, window: null };
93
+ }
94
+
95
+ if (container instanceof Gtk.Widget) {
96
+ return { containerInfo: container, window: null };
72
97
  }
73
- return null;
98
+
99
+ const window = new Gtk.Window({ defaultWidth: HARNESS_WINDOW_WIDTH, defaultHeight: HARNESS_WINDOW_HEIGHT });
100
+ window.setTitlebar(new Gtk.HeaderBar({ showTitleButtons: false }));
101
+
102
+ return { containerInfo: window, window };
74
103
  };
75
104
 
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>;
105
+ const firstToplevelWidget = (baseElement: Container): Gtk.Widget => {
106
+ if (baseElement instanceof Gtk.Widget) {
107
+ return baseElement;
108
+ }
109
+
110
+ const [first] = traverse(baseElement);
111
+
112
+ if (first) {
113
+ return first;
114
+ }
115
+
116
+ throw new Error("render() produced no widgets: ensure the element renders visible content");
84
117
  };
85
118
 
86
- const resolveContainer = (
87
- wrapper: RenderOptions["wrapper"],
88
- wrapperRef: React.RefObject<Gtk.Widget | null>,
119
+ const resolveResultContainer = (
120
+ resolved: ResolvedContainer,
121
+ container: RenderOptions["container"],
89
122
  baseElement: Container,
90
123
  ): Gtk.Widget => {
91
- if (wrapper !== false && wrapper !== undefined && wrapperRef.current) {
92
- return wrapperRef.current;
124
+ if (resolved.window) {
125
+ return resolved.window;
93
126
  }
94
- const firstWidget = findFirstWidget(baseElement);
95
- if (!firstWidget) {
96
- throw new Error("render() produced no widgets: ensure the element renders visible content");
127
+
128
+ if (container instanceof Gtk.Widget) {
129
+ return container;
130
+ }
131
+
132
+ return firstToplevelWidget(baseElement);
133
+ };
134
+
135
+ const renderErrorHandlers = <Q extends QueryMap>(options: RenderOptions<Q> | undefined) => ({
136
+ onUncaughtError: handleError,
137
+ onCaughtError: (error: unknown, errorInfo: ErrorInfo): void => {
138
+ handleError(error);
139
+ options?.onCaughtError?.(error, errorInfo);
140
+ },
141
+ onRecoverableError: (error: unknown, errorInfo: ErrorInfo): void => {
142
+ options?.onRecoverableError?.(error, errorInfo);
143
+ },
144
+ });
145
+
146
+ const applyEnableAnimations = (areAnimationsEnabled: boolean): void => {
147
+ const settings = Gtk.Settings.getDefault();
148
+
149
+ if (settings) {
150
+ settings.gtkEnableAnimations = areAnimationsEnabled;
97
151
  }
98
- return firstWidget;
99
152
  };
100
153
 
101
154
  /**
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.
155
+ * Renders a React element into a GTK4 widget tree and returns queries
156
+ * scoped to it along with controls for rerendering and unmounting. When no
157
+ * container is supplied, a harness window is created and presented.
106
158
  *
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
110
- *
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
159
+ * @param element The React element to render.
160
+ * @param options Optional container, wrapper, custom queries, and other render settings.
161
+ * @returns A render result with bound queries, debug helpers, and lifecycle controls.
124
162
  */
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;
130
-
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);
163
+ const render = async <Q extends QueryMap = Record<never, never>>(
164
+ element: ReactNode,
165
+ options?: RenderOptions<Q>,
166
+ ): Promise<RenderResult<Q>> => {
167
+ installErrorHandler();
168
+ applyEnableAnimations(options?.animations === true);
169
+ const baseElement: Container = options?.baseElement ?? TOPLEVELS;
170
+ const Wrapper = options?.wrapper;
171
+ const resolved = resolveContainer(options?.container);
172
+
173
+ const root = createReconcilerRoot({
174
+ containerInfo: resolved.containerInfo,
175
+ ...renderErrorHandlers(options),
176
+ });
177
+
178
+ const active: ActiveRender = { root, window: resolved.window };
179
+ activeRenders.add(active);
180
+ addToCleanupQueue(disposeAllActiveRenders);
181
+ addToCleanupQueue(clearScreen);
182
+ addToCleanupQueue(resetClipboard);
183
+
184
+ const wrap = (node: ReactNode): ReactNode => {
185
+ const wrapped = Wrapper ? <Wrapper>{node}</Wrapper> : node;
186
+
187
+ return options?.reactStrictMode ? <StrictMode>{wrapped}</StrictMode> : wrapped;
188
+ };
135
189
 
136
- setScreenRoot(application);
190
+ await update(wrap(element), root);
191
+ resolved.window?.present();
192
+ await flushLayout(resolved.window);
193
+ const container = resolveResultContainer(resolved, options?.container, baseElement);
137
194
 
138
- return {
139
- container: resolveContainer(wrapper, wrapperRef, baseElement),
195
+ const result: RenderResult<Q> = {
196
+ ...within(baseElement, options?.queries),
197
+ container,
140
198
  baseElement,
141
- ...bindQueries(baseElement),
142
- unmount: () => update(instance, null, fiberRoot),
199
+ unmount: async () => {
200
+ await disposeActiveRender(active);
201
+ },
143
202
  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);
203
+ await update(wrap(newElement), root);
204
+ await flushLayout(resolved.window);
205
+ },
206
+ debug: (element: Container | Container[] = baseElement, debugOptions?: PrettyWidgetOptions) => {
207
+ logWidget(element, debugOptions);
148
208
  },
149
- debug: () => {
150
- console.log(prettyWidget(application));
209
+ logRoles: () => {
210
+ logRoles(baseElement);
151
211
  },
212
+ screenshot: (selector?: WindowSelector, screenshotOptions?: ScreenshotOptions) =>
213
+ captureAndSaveScreenshot(selector, screenshotOptions),
152
214
  };
215
+
216
+ setScreen(result);
217
+
218
+ return result;
153
219
  };
154
220
 
155
221
  /**
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
- * ```
222
+ * Unmounts every active render and runs all registered cleanup callbacks,
223
+ * resetting the screen and clipboard. Called automatically after each test.
174
224
  */
175
- 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);
225
+ const cleanup = async (): Promise<void> => {
226
+ await runCleanup();
189
227
  };
190
228
 
191
- process.on("SIGTERM", handleSignal);
192
- process.on("SIGINT", handleSignal);
229
+ export { render, cleanup };