@gtkx/testing 0.11.1 → 0.12.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/dist/screen.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type * as Gtk from "@gtkx/ffi/gtk";
2
- import type { ByRoleOptions, TextMatch, TextMatchOptions } from "./types.js";
2
+ import { type ScreenshotOptions } from "./screenshot.js";
3
+ import type { ScreenshotResult } from "./types.js";
3
4
  /** @internal */
4
5
  export declare const setScreenRoot: (root: Gtk.Application | null) => void;
5
6
  /**
@@ -23,22 +24,42 @@ export declare const setScreenRoot: (root: Gtk.Application | null) => void;
23
24
  * @see {@link within} for scoped queries
24
25
  */
25
26
  export declare const screen: {
26
- /** Find single element by accessible role */
27
- findByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) => Promise<Gtk.Widget>;
28
- /** Find single element by label/text content */
29
- findByLabelText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
30
- /** Find single element by visible text */
31
- findByText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
32
- /** Find single element by test ID (widget name) */
33
- findByTestId: (testId: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
34
- /** Find all elements by accessible role */
35
- findAllByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) => Promise<Gtk.Widget[]>;
36
- /** Find all elements by label/text content */
37
- findAllByLabelText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
38
- /** Find all elements by visible text */
39
- findAllByText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
40
- /** Find all elements by test ID (widget name) */
41
- findAllByTestId: (testId: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
42
- /** Print debug info to console */
27
+ /** Print the widget tree to console for debugging */
43
28
  debug: () => void;
29
+ /** Log all accessible roles to console for debugging */
30
+ logRoles: () => void;
31
+ /**
32
+ * Capture a screenshot of the application window, saving it to a temporary file and logging the file path.
33
+ *
34
+ * @param selector - Window selector: index (number), title substring (string), or title pattern (RegExp).
35
+ * If omitted, captures the first window.
36
+ * @param options - Optional timeout and interval configuration for waiting on widget rendering.
37
+ * @returns Screenshot result containing base64-encoded PNG data
38
+ * @throws Error if no windows are available or no matching window is found
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * await screen.screenshot(); // First window
43
+ * await screen.screenshot(0); // Window at index 0
44
+ * await screen.screenshot("Settings"); // Window with title containing "Settings"
45
+ * await screen.screenshot(/^My App/); // Window with title matching regex
46
+ * ```
47
+ */
48
+ screenshot: (selector?: number | string | RegExp, options?: ScreenshotOptions) => Promise<ScreenshotResult>;
49
+ queryByRole: (role: Gtk.AccessibleRole, options?: import("./types.js").ByRoleOptions) => Gtk.Widget | null;
50
+ queryByLabelText: (text: import("./types.js").TextMatch, options?: import("./types.js").TextMatchOptions) => Gtk.Widget | null;
51
+ queryByText: (text: import("./types.js").TextMatch, options?: import("./types.js").TextMatchOptions) => Gtk.Widget | null;
52
+ queryByTestId: (testId: import("./types.js").TextMatch, options?: import("./types.js").TextMatchOptions) => Gtk.Widget | null;
53
+ queryAllByRole: (role: Gtk.AccessibleRole, options?: import("./types.js").ByRoleOptions) => Gtk.Widget[];
54
+ queryAllByLabelText: (text: import("./types.js").TextMatch, options?: import("./types.js").TextMatchOptions) => Gtk.Widget[];
55
+ queryAllByText: (text: import("./types.js").TextMatch, options?: import("./types.js").TextMatchOptions) => Gtk.Widget[];
56
+ queryAllByTestId: (testId: import("./types.js").TextMatch, options?: import("./types.js").TextMatchOptions) => Gtk.Widget[];
57
+ findByRole: (role: Gtk.AccessibleRole, options?: import("./types.js").ByRoleOptions) => Promise<Gtk.Widget>;
58
+ findByLabelText: (text: import("./types.js").TextMatch, options?: import("./types.js").TextMatchOptions) => Promise<Gtk.Widget>;
59
+ findByText: (text: import("./types.js").TextMatch, options?: import("./types.js").TextMatchOptions) => Promise<Gtk.Widget>;
60
+ findByTestId: (testId: import("./types.js").TextMatch, options?: import("./types.js").TextMatchOptions) => Promise<Gtk.Widget>;
61
+ findAllByRole: (role: Gtk.AccessibleRole, options?: import("./types.js").ByRoleOptions) => Promise<Gtk.Widget[]>;
62
+ findAllByLabelText: (text: import("./types.js").TextMatch, options?: import("./types.js").TextMatchOptions) => Promise<Gtk.Widget[]>;
63
+ findAllByText: (text: import("./types.js").TextMatch, options?: import("./types.js").TextMatchOptions) => Promise<Gtk.Widget[]>;
64
+ findAllByTestId: (testId: import("./types.js").TextMatch, options?: import("./types.js").TextMatchOptions) => Promise<Gtk.Widget[]>;
44
65
  };
package/dist/screen.js CHANGED
@@ -1,4 +1,25 @@
1
- import * as queries from "./queries.js";
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { bindQueries } from "./bind-queries.js";
5
+ import { prettyWidget } from "./pretty-widget.js";
6
+ import { logRoles } from "./role-helpers.js";
7
+ import { screenshot as screenshotWidget } from "./screenshot.js";
8
+ const getScreenshotDir = () => {
9
+ const dir = join(tmpdir(), "gtkx-screenshots");
10
+ if (!existsSync(dir)) {
11
+ mkdirSync(dir, { recursive: true });
12
+ }
13
+ return dir;
14
+ };
15
+ const saveAndLogScreenshot = (result) => {
16
+ const dir = getScreenshotDir();
17
+ const filename = `${Date.now()}-screenshot.png`;
18
+ const filepath = join(dir, filename);
19
+ const buffer = Buffer.from(result.data, "base64");
20
+ writeFileSync(filepath, buffer);
21
+ console.log(`Screenshot saved: file://${filepath}`);
22
+ };
2
23
  let currentRoot = null;
3
24
  /** @internal */
4
25
  export const setScreenRoot = (root) => {
@@ -10,6 +31,7 @@ const getRoot = () => {
10
31
  }
11
32
  return currentRoot;
12
33
  };
34
+ const boundQueries = bindQueries(getRoot);
13
35
  /**
14
36
  * Global query object for accessing rendered components.
15
37
  *
@@ -31,24 +53,61 @@ const getRoot = () => {
31
53
  * @see {@link within} for scoped queries
32
54
  */
33
55
  export const screen = {
34
- /** Find single element by accessible role */
35
- findByRole: (role, options) => queries.findByRole(getRoot(), role, options),
36
- /** Find single element by label/text content */
37
- findByLabelText: (text, options) => queries.findByLabelText(getRoot(), text, options),
38
- /** Find single element by visible text */
39
- findByText: (text, options) => queries.findByText(getRoot(), text, options),
40
- /** Find single element by test ID (widget name) */
41
- findByTestId: (testId, options) => queries.findByTestId(getRoot(), testId, options),
42
- /** Find all elements by accessible role */
43
- findAllByRole: (role, options) => queries.findAllByRole(getRoot(), role, options),
44
- /** Find all elements by label/text content */
45
- findAllByLabelText: (text, options) => queries.findAllByLabelText(getRoot(), text, options),
46
- /** Find all elements by visible text */
47
- findAllByText: (text, options) => queries.findAllByText(getRoot(), text, options),
48
- /** Find all elements by test ID (widget name) */
49
- findAllByTestId: (testId, options) => queries.findAllByTestId(getRoot(), testId, options),
50
- /** Print debug info to console */
56
+ ...boundQueries,
57
+ /** Print the widget tree to console for debugging */
51
58
  debug: () => {
52
- console.log("Screen debug - root:", getRoot());
59
+ console.log(prettyWidget(getRoot()));
60
+ },
61
+ /** Log all accessible roles to console for debugging */
62
+ logRoles: () => {
63
+ logRoles(getRoot());
64
+ },
65
+ /**
66
+ * Capture a screenshot of the application window, saving it to a temporary file and logging the file path.
67
+ *
68
+ * @param selector - Window selector: index (number), title substring (string), or title pattern (RegExp).
69
+ * If omitted, captures the first window.
70
+ * @param options - Optional timeout and interval configuration for waiting on widget rendering.
71
+ * @returns Screenshot result containing base64-encoded PNG data
72
+ * @throws Error if no windows are available or no matching window is found
73
+ *
74
+ * @example
75
+ * ```tsx
76
+ * await screen.screenshot(); // First window
77
+ * await screen.screenshot(0); // Window at index 0
78
+ * await screen.screenshot("Settings"); // Window with title containing "Settings"
79
+ * await screen.screenshot(/^My App/); // Window with title matching regex
80
+ * ```
81
+ */
82
+ screenshot: async (selector, options) => {
83
+ const root = getRoot();
84
+ const windows = root.getWindows();
85
+ if (windows.length === 0) {
86
+ throw new Error("No windows available for screenshot");
87
+ }
88
+ let targetWindow;
89
+ if (selector === undefined) {
90
+ targetWindow = windows[0];
91
+ }
92
+ else if (typeof selector === "number") {
93
+ targetWindow = windows[selector];
94
+ if (!targetWindow) {
95
+ throw new Error(`Window at index ${selector} not found`);
96
+ }
97
+ }
98
+ else {
99
+ const isRegex = selector instanceof RegExp;
100
+ targetWindow = windows.find((w) => {
101
+ const title = w.getTitle() ?? "";
102
+ return isRegex ? selector.test(title) : title.includes(selector);
103
+ });
104
+ if (!targetWindow) {
105
+ const pattern = isRegex ? selector.toString() : `"${selector}"`;
106
+ throw new Error(`No window found with title matching ${pattern}`);
107
+ }
108
+ }
109
+ const result = await screenshotWidget(targetWindow, options);
110
+ saveAndLogScreenshot(result);
111
+ return result;
53
112
  },
54
113
  };
@@ -0,0 +1,26 @@
1
+ import * as Gtk from "@gtkx/ffi/gtk";
2
+ import type { ScreenshotResult, WaitForOptions } from "./types.js";
3
+ export type ScreenshotOptions = Pick<WaitForOptions, "timeout" | "interval">;
4
+ /**
5
+ * Captures a screenshot of a GTK widget as a PNG image.
6
+ *
7
+ * This function will retry multiple times if the widget hasn't finished
8
+ * rendering, waiting for GTK to complete its paint cycle.
9
+ *
10
+ * @param widget - The widget to capture (typically a Window)
11
+ * @param options - Optional timeout and interval configuration
12
+ * @returns Screenshot result containing base64-encoded PNG data and dimensions
13
+ * @throws Error if widget has no size, is not realized, or rendering fails after timeout
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * import { render, screenshot } from "@gtkx/testing";
18
+ * import * as Gtk from "@gtkx/ffi/gtk";
19
+ *
20
+ * const { container } = await render(<MyApp />);
21
+ * const window = container.getWindows()[0];
22
+ * const result = await screenshot(window);
23
+ * console.log(result.mimeType); // "image/png"
24
+ * ```
25
+ */
26
+ export declare const screenshot: (widget: Gtk.Widget, options?: ScreenshotOptions) => Promise<ScreenshotResult>;
@@ -0,0 +1,86 @@
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 { waitFor } from "./wait-for.js";
6
+ const bytesToBase64 = (bytes) => {
7
+ return Buffer.from(bytes).toString("base64");
8
+ };
9
+ const DEFAULT_SCREENSHOT_TIMEOUT = 100;
10
+ const DEFAULT_SCREENSHOT_INTERVAL = 10;
11
+ const captureSnapshot = (widget) => {
12
+ const paintable = new Gtk.WidgetPaintable(widget);
13
+ const width = paintable.getIntrinsicWidth();
14
+ const height = paintable.getIntrinsicHeight();
15
+ if (width <= 0 || height <= 0) {
16
+ throw new Error("Widget has no size - ensure it is realized and visible");
17
+ }
18
+ const snapshot = new Gtk.Snapshot();
19
+ paintable.snapshot(snapshot, width, height);
20
+ const renderNode = snapshot.toNode();
21
+ if (!renderNode) {
22
+ throw new Error("Widget produced no render content");
23
+ }
24
+ const display = widget.getDisplay();
25
+ if (!display) {
26
+ throw new Error("Widget has no display - ensure it is realized");
27
+ }
28
+ const renderer = new Gsk.CairoRenderer();
29
+ renderer.realizeForDisplay(display);
30
+ try {
31
+ const texture = renderer.renderTexture(renderNode);
32
+ const pngBytes = texture.saveToPngBytes();
33
+ const sizeRef = createRef(0);
34
+ const data = pngBytes.getData(sizeRef);
35
+ if (!data) {
36
+ throw new Error("Failed to serialize screenshot to PNG");
37
+ }
38
+ return {
39
+ data: bytesToBase64(data),
40
+ mimeType: "image/png",
41
+ width,
42
+ height,
43
+ };
44
+ }
45
+ finally {
46
+ renderer.unrealize();
47
+ }
48
+ };
49
+ /**
50
+ * Captures a screenshot of a GTK widget as a PNG image.
51
+ *
52
+ * This function will retry multiple times if the widget hasn't finished
53
+ * rendering, waiting for GTK to complete its paint cycle.
54
+ *
55
+ * @param widget - The widget to capture (typically a Window)
56
+ * @param options - Optional timeout and interval configuration
57
+ * @returns Screenshot result containing base64-encoded PNG data and dimensions
58
+ * @throws Error if widget has no size, is not realized, or rendering fails after timeout
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * import { render, screenshot } from "@gtkx/testing";
63
+ * import * as Gtk from "@gtkx/ffi/gtk";
64
+ *
65
+ * const { container } = await render(<MyApp />);
66
+ * const window = container.getWindows()[0];
67
+ * const result = await screenshot(window);
68
+ * console.log(result.mimeType); // "image/png"
69
+ * ```
70
+ */
71
+ export const screenshot = async (widget, options) => {
72
+ await tick();
73
+ return waitFor(() => captureSnapshot(widget), {
74
+ timeout: options?.timeout ?? DEFAULT_SCREENSHOT_TIMEOUT,
75
+ interval: options?.interval ?? DEFAULT_SCREENSHOT_INTERVAL,
76
+ onTimeout: (error) => {
77
+ const paintable = new Gtk.WidgetPaintable(widget);
78
+ const width = paintable.getIntrinsicWidth();
79
+ const height = paintable.getIntrinsicHeight();
80
+ if (width <= 0 || height <= 0) {
81
+ return new Error("Widget has no size - ensure it is realized and visible");
82
+ }
83
+ return new Error(`Widget produced no render content after waiting for paint cycle. ${error.message}`);
84
+ },
85
+ });
86
+ };
@@ -1,4 +1,5 @@
1
1
  import type * as Gtk from "@gtkx/ffi/gtk";
2
- type Container = Gtk.Application | Gtk.Widget;
2
+ export type Container = Gtk.Application | Gtk.Widget;
3
+ export declare const isApplication: (container: Container) => container is Gtk.Application;
4
+ export declare const traverse: (container: Container) => Generator<Gtk.Widget>;
3
5
  export declare const findAll: (container: Container, predicate: (node: Gtk.Widget) => boolean) => Gtk.Widget[];
4
- export {};
package/dist/traversal.js CHANGED
@@ -1,4 +1,4 @@
1
- const isApplication = (container) => "getWindows" in container && typeof container.getWindows === "function";
1
+ export const isApplication = (container) => "getWindows" in container && typeof container.getWindows === "function";
2
2
  const traverseWidgetTree = function* (root) {
3
3
  yield root;
4
4
  let child = root.getFirstChild();
@@ -13,7 +13,7 @@ const traverseApplication = function* (app) {
13
13
  yield* traverseWidgetTree(window);
14
14
  }
15
15
  };
16
- const traverse = function* (container) {
16
+ export const traverse = function* (container) {
17
17
  if (isApplication(container)) {
18
18
  yield* traverseApplication(container);
19
19
  }
package/dist/types.d.ts CHANGED
@@ -90,21 +90,37 @@ export type RenderOptions = {
90
90
  * @see {@link within} for scoped queries
91
91
  */
92
92
  export type BoundQueries = {
93
- /** Find single element by accessible role */
93
+ /** Query single element by accessible role (returns null if not found) */
94
+ queryByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) => Gtk.Widget | null;
95
+ /** Query single element by label/text content (returns null if not found) */
96
+ queryByLabelText: (text: TextMatch, options?: TextMatchOptions) => Gtk.Widget | null;
97
+ /** Query single element by visible text (returns null if not found) */
98
+ queryByText: (text: TextMatch, options?: TextMatchOptions) => Gtk.Widget | null;
99
+ /** Query single element by test ID (returns null if not found) */
100
+ queryByTestId: (testId: TextMatch, options?: TextMatchOptions) => Gtk.Widget | null;
101
+ /** Query all elements by accessible role (returns empty array if none found) */
102
+ queryAllByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) => Gtk.Widget[];
103
+ /** Query all elements by label/text content (returns empty array if none found) */
104
+ queryAllByLabelText: (text: TextMatch, options?: TextMatchOptions) => Gtk.Widget[];
105
+ /** Query all elements by visible text (returns empty array if none found) */
106
+ queryAllByText: (text: TextMatch, options?: TextMatchOptions) => Gtk.Widget[];
107
+ /** Query all elements by test ID (returns empty array if none found) */
108
+ queryAllByTestId: (testId: TextMatch, options?: TextMatchOptions) => Gtk.Widget[];
109
+ /** Find single element by accessible role (waits and throws if not found) */
94
110
  findByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) => Promise<Gtk.Widget>;
95
- /** Find single element by label/text content */
111
+ /** Find single element by label/text content (waits and throws if not found) */
96
112
  findByLabelText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
97
- /** Find single element by visible text */
113
+ /** Find single element by visible text (waits and throws if not found) */
98
114
  findByText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
99
- /** Find single element by test ID (widget name) */
115
+ /** Find single element by test ID (waits and throws if not found) */
100
116
  findByTestId: (testId: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget>;
101
- /** Find all elements by accessible role */
117
+ /** Find all elements by accessible role (waits and throws if none found) */
102
118
  findAllByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) => Promise<Gtk.Widget[]>;
103
- /** Find all elements by label/text content */
119
+ /** Find all elements by label/text content (waits and throws if none found) */
104
120
  findAllByLabelText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
105
- /** Find all elements by visible text */
121
+ /** Find all elements by visible text (waits and throws if none found) */
106
122
  findAllByText: (text: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
107
- /** Find all elements by test ID (widget name) */
123
+ /** Find all elements by test ID (waits and throws if none found) */
108
124
  findAllByTestId: (testId: TextMatch, options?: TextMatchOptions) => Promise<Gtk.Widget[]>;
109
125
  };
110
126
  /**
@@ -122,3 +138,16 @@ export type RenderResult = BoundQueries & {
122
138
  /** Print the widget tree to console for debugging */
123
139
  debug: () => void;
124
140
  };
141
+ /**
142
+ * Result returned by {@link screenshot} and screen.screenshot.
143
+ */
144
+ export type ScreenshotResult = {
145
+ /** Base64-encoded PNG image data */
146
+ data: string;
147
+ /** MIME type of the image (always "image/png") */
148
+ mimeType: string;
149
+ /** Width of the captured image in pixels */
150
+ width: number;
151
+ /** Height of the captured image in pixels */
152
+ height: number;
153
+ };
@@ -6,6 +6,7 @@ export type TabOptions = {
6
6
  /** Navigate backwards (Shift+Tab) instead of forwards */
7
7
  shift?: boolean;
8
8
  };
9
+ export type PointerInput = "click" | "down" | "up" | "[MouseLeft]" | "[MouseLeft>]" | "[/MouseLeft]";
9
10
  /**
10
11
  * User interaction utilities for testing.
11
12
  *
@@ -47,12 +48,6 @@ export declare const userEvent: {
47
48
  * Emits three consecutive clicked signals. Useful for text selection.
48
49
  */
49
50
  tripleClick: (element: Gtk.Widget) => Promise<void>;
50
- /**
51
- * Activates a widget.
52
- *
53
- * Calls the widget's activate method.
54
- */
55
- activate: (element: Gtk.Widget) => Promise<void>;
56
51
  /**
57
52
  * Simulates Tab key navigation.
58
53
  *
@@ -94,4 +89,42 @@ export declare const userEvent: {
94
89
  * @param values - Index or array of indices to deselect
95
90
  */
96
91
  deselectOptions: (element: Gtk.Widget, values: number | number[]) => Promise<void>;
92
+ /**
93
+ * Simulates mouse entering a widget (hover).
94
+ *
95
+ * Triggers the "enter" signal on the widget's EventControllerMotion.
96
+ */
97
+ hover: (element: Gtk.Widget) => Promise<void>;
98
+ /**
99
+ * Simulates mouse leaving a widget (unhover).
100
+ *
101
+ * Triggers the "leave" signal on the widget's EventControllerMotion.
102
+ */
103
+ unhover: (element: Gtk.Widget) => Promise<void>;
104
+ /**
105
+ * Simulates keyboard input.
106
+ *
107
+ * Supports special keys in braces: `{Enter}`, `{Tab}`, `{Escape}`, etc.
108
+ * Use `{Key>}` to hold a key down, `{/Key}` to release.
109
+ *
110
+ * @example
111
+ * ```tsx
112
+ * await userEvent.keyboard(element, "hello");
113
+ * await userEvent.keyboard(element, "{Enter}");
114
+ * await userEvent.keyboard(element, "{Shift>}A{/Shift}");
115
+ * ```
116
+ */
117
+ keyboard: (element: Gtk.Widget, input: string) => Promise<void>;
118
+ /**
119
+ * Simulates pointer (mouse) input.
120
+ *
121
+ * Supports: `"click"`, `"[MouseLeft]"`, `"down"`, `"up"`.
122
+ *
123
+ * @example
124
+ * ```tsx
125
+ * await userEvent.pointer(element, "click");
126
+ * await userEvent.pointer(element, "[MouseLeft]");
127
+ * ```
128
+ */
129
+ pointer: (element: Gtk.Widget, input: PointerInput) => Promise<void>;
97
130
  };
@@ -1,5 +1,7 @@
1
1
  import { getNativeObject } from "@gtkx/ffi";
2
+ import * as Gdk from "@gtkx/ffi/gdk";
2
3
  import * as Gtk from "@gtkx/ffi/gtk";
4
+ import { call } from "@gtkx/native";
3
5
  import { fireEvent } from "./fire-event.js";
4
6
  import { tick } from "./timing.js";
5
7
  import { isEditable } from "./widget.js";
@@ -42,15 +44,11 @@ const tripleClick = async (element) => {
42
44
  await fireEvent(element, "clicked");
43
45
  await fireEvent(element, "clicked");
44
46
  };
45
- const activate = async (element) => {
46
- element.activate();
47
- await tick();
48
- };
49
47
  const tab = async (element, options) => {
50
48
  const direction = options?.shift ? Gtk.DirectionType.TAB_BACKWARD : Gtk.DirectionType.TAB_FORWARD;
51
49
  const root = element.getRoot();
52
50
  if (root) {
53
- getNativeObject(root.id).childFocus(direction);
51
+ getNativeObject(root.handle).childFocus(direction);
54
52
  }
55
53
  await tick();
56
54
  };
@@ -58,7 +56,7 @@ const type = async (element, text) => {
58
56
  if (!isEditable(element)) {
59
57
  throw new Error("Cannot type into element: expected editable widget (TEXT_BOX, SEARCH_BOX, or SPIN_BUTTON)");
60
58
  }
61
- const editable = getNativeObject(element.id, Gtk.Editable);
59
+ const editable = getNativeObject(element.handle, Gtk.Editable);
62
60
  const currentText = editable.getText();
63
61
  editable.setText(currentText + text);
64
62
  await tick();
@@ -67,7 +65,7 @@ const clear = async (element) => {
67
65
  if (!isEditable(element)) {
68
66
  throw new Error("Cannot clear element: expected editable widget (TEXT_BOX, SEARCH_BOX, or SPIN_BUTTON)");
69
67
  }
70
- getNativeObject(element.id, Gtk.Editable)?.setText("");
68
+ getNativeObject(element.handle, Gtk.Editable)?.setText("");
71
69
  await tick();
72
70
  };
73
71
  const SELECTABLE_ROLES = new Set([Gtk.AccessibleRole.COMBO_BOX, Gtk.AccessibleRole.LIST]);
@@ -155,6 +153,123 @@ const deselectOptions = async (element, values) => {
155
153
  }
156
154
  await tick();
157
155
  };
156
+ const getOrCreateController = (element, controllerType) => {
157
+ const controllers = element.observeControllers();
158
+ const nItems = controllers.getNItems();
159
+ for (let i = 0; i < nItems; i++) {
160
+ const controller = controllers.getObject(i);
161
+ if (controller instanceof controllerType) {
162
+ return controller;
163
+ }
164
+ }
165
+ const controller = new controllerType();
166
+ element.addController(controller);
167
+ return controller;
168
+ };
169
+ const emitSignal = (target, signalName, ...args) => {
170
+ const signalArgs = args.map((arg) => {
171
+ if (arg.type === "float") {
172
+ return { type: { type: "float", size: 64 }, value: arg.value };
173
+ }
174
+ return { type: { type: "int", size: 32, unsigned: true }, value: arg.value };
175
+ });
176
+ call("libgobject-2.0.so.0", "g_signal_emit_by_name", [
177
+ { type: { type: "gobject", ownership: "borrowed" }, value: target.handle },
178
+ { type: { type: "string", ownership: "borrowed" }, value: signalName },
179
+ ...signalArgs,
180
+ ], { type: "undefined" });
181
+ };
182
+ const hover = async (element) => {
183
+ const controller = getOrCreateController(element, Gtk.EventControllerMotion);
184
+ emitSignal(controller, "enter", { type: "float", value: 0 }, { type: "float", value: 0 });
185
+ await tick();
186
+ };
187
+ const unhover = async (element) => {
188
+ const controller = getOrCreateController(element, Gtk.EventControllerMotion);
189
+ emitSignal(controller, "leave");
190
+ await tick();
191
+ };
192
+ const KEY_MAP = {
193
+ Enter: Gdk.KEY_Return,
194
+ Tab: Gdk.KEY_Tab,
195
+ Escape: Gdk.KEY_Escape,
196
+ Backspace: Gdk.KEY_BackSpace,
197
+ Delete: Gdk.KEY_Delete,
198
+ ArrowUp: Gdk.KEY_Up,
199
+ ArrowDown: Gdk.KEY_Down,
200
+ ArrowLeft: Gdk.KEY_Left,
201
+ ArrowRight: Gdk.KEY_Right,
202
+ Home: Gdk.KEY_Home,
203
+ End: Gdk.KEY_End,
204
+ PageUp: Gdk.KEY_Page_Up,
205
+ PageDown: Gdk.KEY_Page_Down,
206
+ Space: Gdk.KEY_space,
207
+ Shift: Gdk.KEY_Shift_L,
208
+ Control: Gdk.KEY_Control_L,
209
+ Alt: Gdk.KEY_Alt_L,
210
+ Meta: Gdk.KEY_Meta_L,
211
+ };
212
+ const parseKeyboardInput = (input) => {
213
+ const actions = [];
214
+ let i = 0;
215
+ while (i < input.length) {
216
+ if (input[i] === "{") {
217
+ const endBrace = input.indexOf("}", i);
218
+ if (endBrace === -1)
219
+ break;
220
+ let keyName = input.slice(i + 1, endBrace);
221
+ let press = true;
222
+ let release = true;
223
+ if (keyName.startsWith("/")) {
224
+ keyName = keyName.slice(1);
225
+ press = false;
226
+ }
227
+ else if (keyName.endsWith(">")) {
228
+ keyName = keyName.slice(0, -1);
229
+ release = false;
230
+ }
231
+ const keyval = KEY_MAP[keyName];
232
+ if (keyval === undefined) {
233
+ throw new Error(`Unknown key: {${keyName}}`);
234
+ }
235
+ if (press)
236
+ actions.push({ keyval, press: true });
237
+ if (release)
238
+ actions.push({ keyval, press: false });
239
+ i = endBrace + 1;
240
+ }
241
+ else {
242
+ const keyval = input.charCodeAt(i);
243
+ actions.push({ keyval, press: true });
244
+ actions.push({ keyval, press: false });
245
+ i++;
246
+ }
247
+ }
248
+ return actions;
249
+ };
250
+ const keyboard = async (element, input) => {
251
+ const controller = getOrCreateController(element, Gtk.EventControllerKey);
252
+ const actions = parseKeyboardInput(input);
253
+ for (const action of actions) {
254
+ const signalName = action.press ? "key-pressed" : "key-released";
255
+ emitSignal(controller, signalName, { type: "int", value: action.keyval }, { type: "int", value: 0 }, { type: "int", value: 0 });
256
+ }
257
+ await tick();
258
+ };
259
+ const pointer = async (element, input) => {
260
+ const controller = getOrCreateController(element, Gtk.GestureClick);
261
+ if (input === "[MouseLeft]" || input === "click") {
262
+ emitSignal(controller, "pressed", { type: "int", value: 1 }, { type: "float", value: 0 }, { type: "float", value: 0 });
263
+ emitSignal(controller, "released", { type: "int", value: 1 }, { type: "float", value: 0 }, { type: "float", value: 0 });
264
+ }
265
+ else if (input === "[MouseLeft>]" || input === "down") {
266
+ emitSignal(controller, "pressed", { type: "int", value: 1 }, { type: "float", value: 0 }, { type: "float", value: 0 });
267
+ }
268
+ else if (input === "[/MouseLeft]" || input === "up") {
269
+ emitSignal(controller, "released", { type: "int", value: 1 }, { type: "float", value: 0 }, { type: "float", value: 0 });
270
+ }
271
+ await tick();
272
+ };
158
273
  /**
159
274
  * User interaction utilities for testing.
160
275
  *
@@ -196,12 +311,6 @@ export const userEvent = {
196
311
  * Emits three consecutive clicked signals. Useful for text selection.
197
312
  */
198
313
  tripleClick,
199
- /**
200
- * Activates a widget.
201
- *
202
- * Calls the widget's activate method.
203
- */
204
- activate,
205
314
  /**
206
315
  * Simulates Tab key navigation.
207
316
  *
@@ -243,4 +352,42 @@ export const userEvent = {
243
352
  * @param values - Index or array of indices to deselect
244
353
  */
245
354
  deselectOptions,
355
+ /**
356
+ * Simulates mouse entering a widget (hover).
357
+ *
358
+ * Triggers the "enter" signal on the widget's EventControllerMotion.
359
+ */
360
+ hover,
361
+ /**
362
+ * Simulates mouse leaving a widget (unhover).
363
+ *
364
+ * Triggers the "leave" signal on the widget's EventControllerMotion.
365
+ */
366
+ unhover,
367
+ /**
368
+ * Simulates keyboard input.
369
+ *
370
+ * Supports special keys in braces: `{Enter}`, `{Tab}`, `{Escape}`, etc.
371
+ * Use `{Key>}` to hold a key down, `{/Key}` to release.
372
+ *
373
+ * @example
374
+ * ```tsx
375
+ * await userEvent.keyboard(element, "hello");
376
+ * await userEvent.keyboard(element, "{Enter}");
377
+ * await userEvent.keyboard(element, "{Shift>}A{/Shift}");
378
+ * ```
379
+ */
380
+ keyboard,
381
+ /**
382
+ * Simulates pointer (mouse) input.
383
+ *
384
+ * Supports: `"click"`, `"[MouseLeft]"`, `"down"`, `"up"`.
385
+ *
386
+ * @example
387
+ * ```tsx
388
+ * await userEvent.pointer(element, "click");
389
+ * await userEvent.pointer(element, "[MouseLeft]");
390
+ * ```
391
+ */
392
+ pointer,
246
393
  };