@gtkx/testing 0.18.0 → 0.18.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 (101) hide show
  1. package/dist/bind-queries.d.ts +1 -0
  2. package/dist/bind-queries.d.ts.map +1 -0
  3. package/dist/bind-queries.js +1 -0
  4. package/dist/bind-queries.js.map +1 -0
  5. package/dist/config.d.ts +1 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +1 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/error-builder.d.ts +1 -0
  10. package/dist/error-builder.d.ts.map +1 -0
  11. package/dist/error-builder.js +1 -0
  12. package/dist/error-builder.js.map +1 -0
  13. package/dist/fire-event.d.ts +1 -0
  14. package/dist/fire-event.d.ts.map +1 -0
  15. package/dist/fire-event.js +1 -0
  16. package/dist/fire-event.js.map +1 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/pretty-widget.d.ts +1 -0
  22. package/dist/pretty-widget.d.ts.map +1 -0
  23. package/dist/pretty-widget.js +1 -0
  24. package/dist/pretty-widget.js.map +1 -0
  25. package/dist/queries.d.ts +1 -0
  26. package/dist/queries.d.ts.map +1 -0
  27. package/dist/queries.js +1 -0
  28. package/dist/queries.js.map +1 -0
  29. package/dist/render-hook.d.ts +1 -0
  30. package/dist/render-hook.d.ts.map +1 -0
  31. package/dist/render-hook.js +1 -0
  32. package/dist/render-hook.js.map +1 -0
  33. package/dist/render.d.ts +1 -0
  34. package/dist/render.d.ts.map +1 -0
  35. package/dist/render.js +1 -0
  36. package/dist/render.js.map +1 -0
  37. package/dist/role-helpers.d.ts +1 -0
  38. package/dist/role-helpers.d.ts.map +1 -0
  39. package/dist/role-helpers.js +1 -0
  40. package/dist/role-helpers.js.map +1 -0
  41. package/dist/screen.d.ts +1 -0
  42. package/dist/screen.d.ts.map +1 -0
  43. package/dist/screen.js +1 -0
  44. package/dist/screen.js.map +1 -0
  45. package/dist/screenshot.d.ts +1 -0
  46. package/dist/screenshot.d.ts.map +1 -0
  47. package/dist/screenshot.js +1 -0
  48. package/dist/screenshot.js.map +1 -0
  49. package/dist/timing.d.ts +1 -0
  50. package/dist/timing.d.ts.map +1 -0
  51. package/dist/timing.js +1 -0
  52. package/dist/timing.js.map +1 -0
  53. package/dist/traversal.d.ts +8 -0
  54. package/dist/traversal.d.ts.map +1 -0
  55. package/dist/traversal.js +1 -0
  56. package/dist/traversal.js.map +1 -0
  57. package/dist/types.d.ts +1 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +1 -0
  60. package/dist/types.js.map +1 -0
  61. package/dist/user-event.d.ts +1 -0
  62. package/dist/user-event.d.ts.map +1 -0
  63. package/dist/user-event.js +1 -0
  64. package/dist/user-event.js.map +1 -0
  65. package/dist/wait-for.d.ts +1 -0
  66. package/dist/wait-for.d.ts.map +1 -0
  67. package/dist/wait-for.js +1 -0
  68. package/dist/wait-for.js.map +1 -0
  69. package/dist/widget-text.d.ts +1 -0
  70. package/dist/widget-text.d.ts.map +1 -0
  71. package/dist/widget-text.js +1 -0
  72. package/dist/widget-text.js.map +1 -0
  73. package/dist/widget.d.ts +1 -0
  74. package/dist/widget.d.ts.map +1 -0
  75. package/dist/widget.js +1 -0
  76. package/dist/widget.js.map +1 -0
  77. package/dist/within.d.ts +1 -0
  78. package/dist/within.d.ts.map +1 -0
  79. package/dist/within.js +1 -0
  80. package/dist/within.js.map +1 -0
  81. package/package.json +7 -5
  82. package/src/bind-queries.ts +52 -0
  83. package/src/config.ts +89 -0
  84. package/src/error-builder.ts +102 -0
  85. package/src/fire-event.ts +43 -0
  86. package/src/index.ts +51 -0
  87. package/src/pretty-widget.ts +205 -0
  88. package/src/queries.ts +511 -0
  89. package/src/render-hook.tsx +71 -0
  90. package/src/render.tsx +192 -0
  91. package/src/role-helpers.ts +126 -0
  92. package/src/screen.ts +125 -0
  93. package/src/screenshot.ts +105 -0
  94. package/src/timing.ts +17 -0
  95. package/src/traversal.ts +48 -0
  96. package/src/types.ts +210 -0
  97. package/src/user-event.ts +492 -0
  98. package/src/wait-for.ts +115 -0
  99. package/src/widget-text.ts +206 -0
  100. package/src/widget.ts +15 -0
  101. package/src/within.ts +31 -0
package/src/render.tsx ADDED
@@ -0,0 +1,192 @@
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;
33
+ }
34
+ };
35
+
36
+ const handleError = (error: Error): void => {
37
+ lastRenderError = error;
38
+ };
39
+
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
+ );
57
+ }
58
+
59
+ return { app: application, container };
60
+ };
61
+
62
+ const DefaultWrapper: WrapperComponent = ({ children, ref }) => (
63
+ <GtkApplicationWindow ref={ref as Ref<Gtk.ApplicationWindow>} defaultWidth={800} defaultHeight={600}>
64
+ {children}
65
+ </GtkApplicationWindow>
66
+ );
67
+
68
+ const findFirstWidget = (root: Container): Gtk.Widget | null => {
69
+ for (const widget of traverse(root)) {
70
+ if (isApplication(root)) return widget;
71
+ return root;
72
+ }
73
+ return null;
74
+ };
75
+
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>;
84
+ };
85
+
86
+ const resolveContainer = (
87
+ wrapper: RenderOptions["wrapper"],
88
+ wrapperRef: React.RefObject<Gtk.Widget | null>,
89
+ baseElement: Container,
90
+ ): 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.");
97
+ }
98
+ return firstWidget;
99
+ };
100
+
101
+ /**
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
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
124
+ */
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);
135
+
136
+ setScreenRoot(application);
137
+
138
+ return {
139
+ container: resolveContainer(wrapper, wrapperRef, baseElement),
140
+ baseElement,
141
+ ...bindQueries(baseElement),
142
+ unmount: () => update(instance, null, fiberRoot),
143
+ 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);
148
+ },
149
+ debug: () => {
150
+ console.log(prettyWidget(application));
151
+ },
152
+ };
153
+ };
154
+
155
+ /**
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
+ * ```
174
+ */
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);
189
+ };
190
+
191
+ process.on("SIGTERM", handleSignal);
192
+ process.on("SIGINT", handleSignal);
@@ -0,0 +1,126 @@
1
+ import * as Gtk from "@gtkx/ffi/gtk";
2
+ import { type Container, traverse } from "./traversal.js";
3
+ import { getWidgetText } from "./widget-text.js";
4
+
5
+ /**
6
+ * Information about a widget and its accessible name.
7
+ */
8
+ export type RoleInfo = {
9
+ widget: Gtk.Widget;
10
+ name: string | null;
11
+ };
12
+
13
+ /**
14
+ * Formats a GTK accessible role to a lowercase string.
15
+ *
16
+ * @param role - The GTK accessible role
17
+ * @returns Lowercase role name (e.g., "button", "checkbox")
18
+ */
19
+ export const formatRole = (role: Gtk.AccessibleRole | undefined): string => {
20
+ if (role === undefined) return "unknown";
21
+ const name = Gtk.AccessibleRole[role];
22
+ if (!name) return String(role);
23
+ return name.toLowerCase();
24
+ };
25
+
26
+ /**
27
+ * Collects all accessible roles and their widgets from a container.
28
+ *
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
+ * ```
45
+ */
46
+ export const getRoles = (container: Container): Map<string, RoleInfo[]> => {
47
+ const roles = new Map<string, RoleInfo[]>();
48
+
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 = getWidgetText(widget);
55
+ const info: RoleInfo = { widget, name };
56
+
57
+ const existing = roles.get(roleName);
58
+ if (existing) {
59
+ existing.push(info);
60
+ } else {
61
+ roles.set(roleName, [info]);
62
+ }
63
+ }
64
+
65
+ return roles;
66
+ };
67
+
68
+ const formatWidgetPreview = (widget: Gtk.Widget, name: string | null): string => {
69
+ const tagName = widget.constructor.name;
70
+ const roleAttr = formatRole(widget.getAccessibleRole?.());
71
+ const nameDisplay = name ? `Name "${name}"` : 'Name ""';
72
+ return `${nameDisplay}: <${tagName} role="${roleAttr}">${name ?? ""}</${tagName}>`;
73
+ };
74
+
75
+ /**
76
+ * Formats roles into a readable string for error messages.
77
+ *
78
+ * @param container - The container to format roles for
79
+ * @returns Formatted string showing all roles and their accessible names
80
+ */
81
+ export const prettyRoles = (container: Container): string => {
82
+ const roles = getRoles(container);
83
+
84
+ if (roles.size === 0) {
85
+ return "No accessible roles found in the widget tree.";
86
+ }
87
+
88
+ const lines: string[] = [];
89
+
90
+ const sortedRoles = [...roles.entries()].sort(([a], [b]) => a.localeCompare(b));
91
+
92
+ for (const [roleName, widgets] of sortedRoles) {
93
+ lines.push(`${roleName}:`);
94
+ for (const { widget, name } of widgets) {
95
+ lines.push(` ${formatWidgetPreview(widget, name)}`);
96
+ }
97
+ lines.push("");
98
+ }
99
+
100
+ return lines.join("\n").trimEnd();
101
+ };
102
+
103
+ /**
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";
113
+ *
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
+ * ```
123
+ */
124
+ export const logRoles = (container: Container): void => {
125
+ console.log(prettyRoles(container));
126
+ };
package/src/screen.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import * as Gtk from "@gtkx/ffi/gtk";
5
+ import { bindQueries } from "./bind-queries.js";
6
+ import { prettyWidget } from "./pretty-widget.js";
7
+ import { logRoles } from "./role-helpers.js";
8
+ import { type ScreenshotOptions, screenshot as screenshotWidget } from "./screenshot.js";
9
+ import type { ScreenshotResult } from "./types.js";
10
+
11
+ const getScreenshotDir = (): string => {
12
+ const dir = join(tmpdir(), "gtkx-screenshots");
13
+ if (!existsSync(dir)) {
14
+ mkdirSync(dir, { recursive: true });
15
+ }
16
+ return dir;
17
+ };
18
+
19
+ const saveAndLogScreenshot = (result: ScreenshotResult): void => {
20
+ const dir = getScreenshotDir();
21
+ const filename = `${Date.now()}-screenshot.png`;
22
+ const filepath = join(dir, filename);
23
+ const buffer = Buffer.from(result.data, "base64");
24
+ writeFileSync(filepath, buffer);
25
+ console.log(`Screenshot saved: file://${filepath}`);
26
+ };
27
+
28
+ let currentRoot: Gtk.Application | null = null;
29
+
30
+ /** @internal */
31
+ export const setScreenRoot = (root: Gtk.Application | null): void => {
32
+ currentRoot = root;
33
+ };
34
+
35
+ const getRoot = (): Gtk.Application => {
36
+ if (!currentRoot) {
37
+ throw new Error("No render has been performed: call render() before using screen queries");
38
+ }
39
+
40
+ return currentRoot;
41
+ };
42
+
43
+ const boundQueries = bindQueries(getRoot);
44
+
45
+ /**
46
+ * Global query object for accessing rendered components.
47
+ *
48
+ * Provides the same query methods as render result, but automatically
49
+ * uses the most recently rendered application as the container.
50
+ *
51
+ * @example
52
+ * ```tsx
53
+ * import { render, screen } from "@gtkx/testing";
54
+ *
55
+ * test("finds button", async () => {
56
+ * await render(<MyComponent />);
57
+ * const button = await screen.findByRole(Gtk.AccessibleRole.BUTTON);
58
+ * expect(button).toBeDefined();
59
+ * });
60
+ * ```
61
+ *
62
+ * @see {@link render} for rendering components
63
+ * @see {@link within} for scoped queries
64
+ */
65
+ export const screen = {
66
+ ...boundQueries,
67
+ /** Print the widget tree to console for debugging */
68
+ debug: () => {
69
+ console.log(prettyWidget(getRoot()));
70
+ },
71
+ /** Log all accessible roles to console for debugging */
72
+ logRoles: () => {
73
+ logRoles(getRoot());
74
+ },
75
+ /**
76
+ * Capture a screenshot of the application window, saving it to a temporary file and logging the file path.
77
+ *
78
+ * @param selector - Window selector: index (number), title substring (string), or title pattern (RegExp).
79
+ * If omitted, captures the first window.
80
+ * @param options - Optional timeout and interval configuration for waiting on widget rendering.
81
+ * @returns Screenshot result containing base64-encoded PNG data
82
+ * @throws Error if no windows are available or no matching window is found
83
+ *
84
+ * @example
85
+ * ```tsx
86
+ * await screen.screenshot(); // First window
87
+ * await screen.screenshot(0); // Window at index 0
88
+ * await screen.screenshot("Settings"); // Window with title containing "Settings"
89
+ * await screen.screenshot(/^My App/); // Window with title matching regex
90
+ * ```
91
+ */
92
+ screenshot: async (selector?: number | string | RegExp, options?: ScreenshotOptions): Promise<ScreenshotResult> => {
93
+ const windows = Gtk.Window.listToplevels();
94
+
95
+ if (windows.length === 0) {
96
+ throw new Error("No windows available for screenshot");
97
+ }
98
+
99
+ let targetWindow: Gtk.Window | undefined;
100
+
101
+ if (selector === undefined) {
102
+ targetWindow = windows[0] as Gtk.Window;
103
+ } else if (typeof selector === "number") {
104
+ targetWindow = windows[selector] as Gtk.Window | undefined;
105
+ if (!targetWindow) {
106
+ throw new Error(`Window at index ${selector} not found`);
107
+ }
108
+ } else {
109
+ const isRegex = selector instanceof RegExp;
110
+ targetWindow = windows.find((w) => {
111
+ const title = (w as Gtk.Window).getTitle() ?? "";
112
+ return isRegex ? selector.test(title) : title.includes(selector);
113
+ }) as Gtk.Window | undefined;
114
+
115
+ if (!targetWindow) {
116
+ const pattern = isRegex ? selector.toString() : `"${selector}"`;
117
+ throw new Error(`No window found with title matching ${pattern}`);
118
+ }
119
+ }
120
+
121
+ const result = await screenshotWidget(targetWindow, options);
122
+ saveAndLogScreenshot(result);
123
+ return result;
124
+ },
125
+ };
@@ -0,0 +1,105 @@
1
+ import { createRef } from "@gtkx/ffi";
2
+ import * as Gsk from "@gtkx/ffi/gsk";
3
+ import * as Gtk from "@gtkx/ffi/gtk";
4
+ import { tick } from "./timing.js";
5
+ import type { ScreenshotResult, WaitForOptions } from "./types.js";
6
+ import { waitFor } from "./wait-for.js";
7
+
8
+ const bytesToBase64 = (bytes: number[]): string => {
9
+ return Buffer.from(bytes).toString("base64");
10
+ };
11
+
12
+ const DEFAULT_SCREENSHOT_TIMEOUT = 100;
13
+ const DEFAULT_SCREENSHOT_INTERVAL = 10;
14
+
15
+ const captureSnapshot = (widget: Gtk.Widget): ScreenshotResult => {
16
+ const paintable = new Gtk.WidgetPaintable(widget);
17
+ const width = paintable.getIntrinsicWidth();
18
+ const height = paintable.getIntrinsicHeight();
19
+
20
+ if (width <= 0 || height <= 0) {
21
+ throw new Error("Widget has no size - ensure it is realized and visible");
22
+ }
23
+
24
+ const snapshot = new Gtk.Snapshot();
25
+ paintable.snapshot(snapshot, width, height);
26
+ const renderNode = snapshot.toNode();
27
+
28
+ if (!renderNode) {
29
+ throw new Error("Widget produced no render content");
30
+ }
31
+
32
+ const display = widget.getDisplay();
33
+ if (!display) {
34
+ throw new Error("Widget has no display - ensure it is realized");
35
+ }
36
+
37
+ const renderer = new Gsk.CairoRenderer();
38
+ renderer.realizeForDisplay(display);
39
+
40
+ try {
41
+ const texture = renderer.renderTexture(renderNode);
42
+ const pngBytes = texture.saveToPngBytes();
43
+ const sizeRef = createRef(0);
44
+ const data = pngBytes.getData(sizeRef);
45
+
46
+ if (!data) {
47
+ throw new Error("Failed to serialize screenshot to PNG");
48
+ }
49
+
50
+ return {
51
+ data: bytesToBase64(data),
52
+ mimeType: "image/png",
53
+ width,
54
+ height,
55
+ };
56
+ } finally {
57
+ renderer.unrealize();
58
+ }
59
+ };
60
+
61
+ /**
62
+ * Options for capturing widget screenshots.
63
+ */
64
+ export type ScreenshotOptions = Pick<WaitForOptions, "timeout" | "interval">;
65
+
66
+ /**
67
+ * Captures a screenshot of a GTK widget as a PNG image.
68
+ *
69
+ * This function will retry multiple times if the widget hasn't finished
70
+ * rendering, waiting for GTK to complete its paint cycle.
71
+ *
72
+ * @param widget - The widget to capture (typically a Window)
73
+ * @param options - Optional timeout and interval configuration
74
+ * @returns Screenshot result containing base64-encoded PNG data and dimensions
75
+ * @throws Error if widget has no size, is not realized, or rendering fails after timeout
76
+ *
77
+ * @example
78
+ * ```tsx
79
+ * import { render, screenshot } from "@gtkx/testing";
80
+ * import * as Gtk from "@gtkx/ffi/gtk";
81
+ *
82
+ * const { container } = await render(<MyApp />);
83
+ * const window = container.getWindows()[0];
84
+ * const result = await screenshot(window);
85
+ * console.log(result.mimeType); // "image/png"
86
+ * ```
87
+ */
88
+ export const screenshot = async (widget: Gtk.Widget, options?: ScreenshotOptions): Promise<ScreenshotResult> => {
89
+ await tick();
90
+
91
+ return waitFor(() => captureSnapshot(widget), {
92
+ timeout: options?.timeout ?? DEFAULT_SCREENSHOT_TIMEOUT,
93
+ interval: options?.interval ?? DEFAULT_SCREENSHOT_INTERVAL,
94
+ onTimeout: (error) => {
95
+ const paintable = new Gtk.WidgetPaintable(widget);
96
+ const width = paintable.getIntrinsicWidth();
97
+ const height = paintable.getIntrinsicHeight();
98
+
99
+ if (width <= 0 || height <= 0) {
100
+ return new Error("Widget has no size - ensure it is realized and visible");
101
+ }
102
+ return new Error(`Widget produced no render content after waiting for paint cycle. ${error.message}`);
103
+ },
104
+ });
105
+ };
package/src/timing.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Yields to the event loop, allowing pending GTK events to process.
3
+ *
4
+ * Use this after actions that trigger async widget updates.
5
+ *
6
+ * @returns Promise that resolves on the next event loop tick
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * import { tick } from "@gtkx/testing";
11
+ *
12
+ * widget.setSensitive(false);
13
+ * await tick(); // Wait for GTK to process the change
14
+ * expect(widget.getSensitive()).toBe(false);
15
+ * ```
16
+ */
17
+ export const tick = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
@@ -0,0 +1,48 @@
1
+ import * as Gtk from "@gtkx/ffi/gtk";
2
+
3
+ /**
4
+ * Root element for scoping queries.
5
+ *
6
+ * When a `Gtk.Application` is provided, queries search across all toplevel
7
+ * windows. When a `Gtk.Widget` is provided, queries are scoped to that
8
+ * widget's subtree.
9
+ */
10
+ export type Container = Gtk.Application | Gtk.Widget;
11
+
12
+ export const isApplication = (container: Container): container is Gtk.Application =>
13
+ "getWindows" in container && typeof container.getWindows === "function";
14
+
15
+ const traverseWidgetTree = function* (root: Gtk.Widget): Generator<Gtk.Widget> {
16
+ yield root;
17
+
18
+ let child = root.getFirstChild();
19
+ while (child) {
20
+ yield* traverseWidgetTree(child);
21
+ child = child.getNextSibling();
22
+ }
23
+ };
24
+
25
+ const traverseWindows = function* (): Generator<Gtk.Widget> {
26
+ const windows = Gtk.Window.listToplevels();
27
+ for (const window of windows) {
28
+ yield* traverseWidgetTree(window);
29
+ }
30
+ };
31
+
32
+ export const traverse = function* (container: Container): Generator<Gtk.Widget> {
33
+ if (isApplication(container)) {
34
+ yield* traverseWindows();
35
+ } else {
36
+ yield* traverseWidgetTree(container);
37
+ }
38
+ };
39
+
40
+ export const findAll = (container: Container, predicate: (node: Gtk.Widget) => boolean): Gtk.Widget[] => {
41
+ const results: Gtk.Widget[] = [];
42
+ for (const node of traverse(container)) {
43
+ if (predicate(node)) {
44
+ results.push(node);
45
+ }
46
+ }
47
+ return results;
48
+ };