@gtkx/testing 0.11.1 → 0.12.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.
package/dist/queries.js CHANGED
@@ -1,7 +1,8 @@
1
- import { getNativeObject } from "@gtkx/ffi";
2
- import * as Gtk from "@gtkx/ffi/gtk";
1
+ import { buildMultipleFoundError, buildNotFoundError, buildTimeoutError } from "./error-builder.js";
3
2
  import { findAll } from "./traversal.js";
4
- import { waitFor } from "./wait-for.js";
3
+ import { getWidgetCheckedState, getWidgetExpandedState, getWidgetTestId, getWidgetText } from "./widget-text.js";
4
+ const DEFAULT_TIMEOUT = 1000;
5
+ const DEFAULT_INTERVAL = 50;
5
6
  const buildNormalizer = (options) => {
6
7
  if (options?.normalizer) {
7
8
  return options.normalizer;
@@ -38,126 +39,6 @@ const matchText = (actual, expected, widget, options) => {
38
39
  const exact = options?.exact ?? true;
39
40
  return exact ? normalizedActual === normalizedExpected : normalizedActual.includes(normalizedExpected);
40
41
  };
41
- const ROLES_WITH_INTERNAL_LABELS = new Set([
42
- Gtk.AccessibleRole.BUTTON,
43
- Gtk.AccessibleRole.TOGGLE_BUTTON,
44
- Gtk.AccessibleRole.CHECKBOX,
45
- Gtk.AccessibleRole.RADIO,
46
- Gtk.AccessibleRole.MENU_ITEM,
47
- Gtk.AccessibleRole.MENU_ITEM_CHECKBOX,
48
- Gtk.AccessibleRole.MENU_ITEM_RADIO,
49
- Gtk.AccessibleRole.TAB,
50
- Gtk.AccessibleRole.LINK,
51
- ]);
52
- const isInternalLabel = (widget) => {
53
- if (widget.getAccessibleRole() !== Gtk.AccessibleRole.LABEL)
54
- return false;
55
- const parent = widget.getParent();
56
- if (!parent)
57
- return false;
58
- if (parent.getAccessibleRole === undefined)
59
- return false;
60
- const parentRole = parent.getAccessibleRole();
61
- if (!parentRole)
62
- return false;
63
- return ROLES_WITH_INTERNAL_LABELS.has(parentRole);
64
- };
65
- const getLabelText = (widget) => {
66
- const asLabel = widget;
67
- const asInscription = widget;
68
- return asLabel.getLabel?.() ?? asInscription.getText?.() ?? null;
69
- };
70
- const collectChildLabels = (widget) => {
71
- const labels = [];
72
- let child = widget.getFirstChild();
73
- while (child) {
74
- if (child.getAccessibleRole() === Gtk.AccessibleRole.LABEL) {
75
- const labelText = getLabelText(child);
76
- if (labelText)
77
- labels.push(labelText);
78
- }
79
- labels.push(...collectChildLabels(child));
80
- child = child.getNextSibling();
81
- }
82
- return labels;
83
- };
84
- const getWidgetText = (widget) => {
85
- if (isInternalLabel(widget))
86
- return null;
87
- const role = widget.getAccessibleRole();
88
- if (role === undefined)
89
- return null;
90
- switch (role) {
91
- case Gtk.AccessibleRole.BUTTON:
92
- case Gtk.AccessibleRole.LINK:
93
- case Gtk.AccessibleRole.TAB: {
94
- const directLabel = widget.getLabel?.() ??
95
- widget.getLabel?.() ??
96
- widget.getLabel?.();
97
- if (directLabel)
98
- return directLabel;
99
- const childLabels = collectChildLabels(widget);
100
- return childLabels.length > 0 ? childLabels.join(" ") : null;
101
- }
102
- case Gtk.AccessibleRole.TOGGLE_BUTTON:
103
- return widget.getLabel?.() ?? null;
104
- case Gtk.AccessibleRole.CHECKBOX:
105
- case Gtk.AccessibleRole.RADIO:
106
- return widget.getLabel?.() ?? null;
107
- case Gtk.AccessibleRole.LABEL:
108
- return getLabelText(widget);
109
- case Gtk.AccessibleRole.TEXT_BOX:
110
- case Gtk.AccessibleRole.SEARCH_BOX:
111
- case Gtk.AccessibleRole.SPIN_BUTTON:
112
- return getNativeObject(widget.id, Gtk.Editable).getText() ?? null;
113
- case Gtk.AccessibleRole.GROUP:
114
- return widget.getLabel?.() ?? null;
115
- case Gtk.AccessibleRole.WINDOW:
116
- case Gtk.AccessibleRole.DIALOG:
117
- case Gtk.AccessibleRole.ALERT_DIALOG:
118
- return widget.getTitle() ?? null;
119
- case Gtk.AccessibleRole.TAB_PANEL: {
120
- const parent = widget.getParent();
121
- if (parent) {
122
- const stack = parent;
123
- const page = stack.getPage?.(widget);
124
- if (page) {
125
- return page.getTitle() ?? null;
126
- }
127
- }
128
- return null;
129
- }
130
- default:
131
- return null;
132
- }
133
- };
134
- const getWidgetTestId = (widget) => {
135
- return widget.getName();
136
- };
137
- const getWidgetCheckedState = (widget) => {
138
- const role = widget.getAccessibleRole();
139
- switch (role) {
140
- case Gtk.AccessibleRole.CHECKBOX:
141
- case Gtk.AccessibleRole.RADIO:
142
- return widget.getActive();
143
- case Gtk.AccessibleRole.TOGGLE_BUTTON:
144
- return widget.getActive();
145
- case Gtk.AccessibleRole.SWITCH:
146
- return widget.getActive();
147
- default:
148
- return null;
149
- }
150
- };
151
- const getWidgetExpandedState = (widget) => {
152
- const role = widget.getAccessibleRole();
153
- if (role === Gtk.AccessibleRole.BUTTON) {
154
- const parent = widget.getParent();
155
- if (!parent)
156
- return null;
157
- return parent.getExpanded?.() ?? null;
158
- }
159
- return null;
160
- };
161
42
  const matchByRoleOptions = (widget, options) => {
162
43
  if (!options)
163
44
  return true;
@@ -178,103 +59,201 @@ const matchByRoleOptions = (widget, options) => {
178
59
  }
179
60
  return true;
180
61
  };
181
- const formatRole = (role) => Gtk.AccessibleRole[role] ?? String(role);
182
- const formatByRoleError = (role, options) => {
183
- const parts = [`role '${formatRole(role)}'`];
184
- if (options?.name)
185
- parts.push(`name '${options.name}'`);
186
- if (options?.checked !== undefined)
187
- parts.push(`checked=${options.checked}`);
188
- if (options?.pressed !== undefined)
189
- parts.push(`pressed=${options.pressed}`);
190
- if (options?.selected !== undefined)
191
- parts.push(`selected=${options.selected}`);
192
- if (options?.expanded !== undefined)
193
- parts.push(`expanded=${options.expanded}`);
194
- if (options?.level !== undefined)
195
- parts.push(`level=${options.level}`);
196
- return parts.join(" and ");
62
+ const waitFor = async (callback, options) => {
63
+ const { timeout = DEFAULT_TIMEOUT, interval = DEFAULT_INTERVAL, onTimeout } = options ?? {};
64
+ const startTime = Date.now();
65
+ let lastError = null;
66
+ while (Date.now() - startTime < timeout) {
67
+ try {
68
+ return await callback();
69
+ }
70
+ catch (error) {
71
+ lastError = error;
72
+ await new Promise((resolve) => setTimeout(resolve, interval));
73
+ }
74
+ }
75
+ const timeoutError = buildTimeoutError(timeout, lastError);
76
+ if (onTimeout) {
77
+ throw onTimeout(timeoutError);
78
+ }
79
+ throw timeoutError;
197
80
  };
198
- const getAllByRole = (container, role, options) => {
199
- const matches = findAll(container, (node) => {
81
+ /**
82
+ * Finds all elements matching a role without throwing.
83
+ *
84
+ * @param container - The container to search within
85
+ * @param role - The GTK accessible role to match
86
+ * @param options - Query options including name and state filters
87
+ * @returns Array of matching widgets (empty if none found)
88
+ */
89
+ export const queryAllByRole = (container, role, options) => {
90
+ return findAll(container, (node) => {
200
91
  if (node.getAccessibleRole() !== role)
201
92
  return false;
202
93
  return matchByRoleOptions(node, options);
203
94
  });
95
+ };
96
+ /**
97
+ * Finds a single element matching a role without throwing.
98
+ *
99
+ * @param container - The container to search within
100
+ * @param role - The GTK accessible role to match
101
+ * @param options - Query options including name and state filters
102
+ * @returns The matching widget or null if not found
103
+ * @throws Error if multiple elements match
104
+ */
105
+ export const queryByRole = (container, role, options) => {
106
+ const matches = queryAllByRole(container, role, options);
107
+ if (matches.length > 1) {
108
+ throw buildMultipleFoundError(container, "role", { role, options }, matches.length);
109
+ }
110
+ return matches[0] ?? null;
111
+ };
112
+ /**
113
+ * Finds all elements matching label text without throwing.
114
+ *
115
+ * @param container - The container to search within
116
+ * @param text - Text to match (string, RegExp, or custom matcher)
117
+ * @param options - Query options including normalization
118
+ * @returns Array of matching widgets (empty if none found)
119
+ */
120
+ export const queryAllByLabelText = (container, text, options) => {
121
+ return findAll(container, (node) => {
122
+ const widgetText = getWidgetText(node);
123
+ return matchText(widgetText, text, node, options);
124
+ });
125
+ };
126
+ /**
127
+ * Finds a single element matching label text without throwing.
128
+ *
129
+ * @param container - The container to search within
130
+ * @param text - Text to match (string, RegExp, or custom matcher)
131
+ * @param options - Query options including normalization
132
+ * @returns The matching widget or null if not found
133
+ * @throws Error if multiple elements match
134
+ */
135
+ export const queryByLabelText = (container, text, options) => {
136
+ const matches = queryAllByLabelText(container, text, options);
137
+ if (matches.length > 1) {
138
+ throw buildMultipleFoundError(container, "labelText", { text, options }, matches.length);
139
+ }
140
+ return matches[0] ?? null;
141
+ };
142
+ /**
143
+ * Finds all elements matching text content without throwing.
144
+ *
145
+ * @param container - The container to search within
146
+ * @param text - Text to match (string, RegExp, or custom matcher)
147
+ * @param options - Query options including normalization
148
+ * @returns Array of matching widgets (empty if none found)
149
+ */
150
+ export const queryAllByText = (container, text, options) => {
151
+ return findAll(container, (node) => {
152
+ const widgetText = getWidgetText(node);
153
+ return matchText(widgetText, text, node, options);
154
+ });
155
+ };
156
+ /**
157
+ * Finds a single element matching text content without throwing.
158
+ *
159
+ * @param container - The container to search within
160
+ * @param text - Text to match (string, RegExp, or custom matcher)
161
+ * @param options - Query options including normalization
162
+ * @returns The matching widget or null if not found
163
+ * @throws Error if multiple elements match
164
+ */
165
+ export const queryByText = (container, text, options) => {
166
+ const matches = queryAllByText(container, text, options);
167
+ if (matches.length > 1) {
168
+ throw buildMultipleFoundError(container, "text", { text, options }, matches.length);
169
+ }
170
+ return matches[0] ?? null;
171
+ };
172
+ /**
173
+ * Finds all elements matching a test ID without throwing.
174
+ *
175
+ * @param container - The container to search within
176
+ * @param testId - Test ID to match (string, RegExp, or custom matcher)
177
+ * @param options - Query options including normalization
178
+ * @returns Array of matching widgets (empty if none found)
179
+ */
180
+ export const queryAllByTestId = (container, testId, options) => {
181
+ return findAll(container, (node) => {
182
+ const widgetTestId = getWidgetTestId(node);
183
+ return matchText(widgetTestId, testId, node, options);
184
+ });
185
+ };
186
+ /**
187
+ * Finds a single element matching a test ID without throwing.
188
+ *
189
+ * @param container - The container to search within
190
+ * @param testId - Test ID to match (string, RegExp, or custom matcher)
191
+ * @param options - Query options including normalization
192
+ * @returns The matching widget or null if not found
193
+ * @throws Error if multiple elements match
194
+ */
195
+ export const queryByTestId = (container, testId, options) => {
196
+ const matches = queryAllByTestId(container, testId, options);
197
+ if (matches.length > 1) {
198
+ throw buildMultipleFoundError(container, "testId", { testId, options }, matches.length);
199
+ }
200
+ return matches[0] ?? null;
201
+ };
202
+ const getAllByRole = (container, role, options) => {
203
+ const matches = queryAllByRole(container, role, options);
204
204
  if (matches.length === 0) {
205
- throw new Error(`Unable to find any elements with ${formatByRoleError(role, options)}`);
205
+ throw buildNotFoundError(container, "role", { role, options });
206
206
  }
207
207
  return matches;
208
208
  };
209
209
  const getByRole = (container, role, options) => {
210
210
  const matches = getAllByRole(container, role, options);
211
211
  if (matches.length > 1) {
212
- throw new Error(`Expected 1 element with ${formatByRoleError(role, options)}, found ${matches.length}`);
212
+ throw buildMultipleFoundError(container, "role", { role, options }, matches.length);
213
213
  }
214
- const [first] = matches;
215
- if (!first)
216
- throw new Error(`Unable to find element with ${formatByRoleError(role, options)}`);
217
- return first;
214
+ return matches[0];
218
215
  };
219
216
  const getAllByLabelText = (container, text, options) => {
220
- const matches = findAll(container, (node) => {
221
- const widgetText = getWidgetText(node);
222
- return matchText(widgetText, text, node, options);
223
- });
217
+ const matches = queryAllByLabelText(container, text, options);
224
218
  if (matches.length === 0) {
225
- throw new Error(`Unable to find any elements with label text '${text}'`);
219
+ throw buildNotFoundError(container, "labelText", { text, options });
226
220
  }
227
221
  return matches;
228
222
  };
229
223
  const getByLabelText = (container, text, options) => {
230
224
  const matches = getAllByLabelText(container, text, options);
231
225
  if (matches.length > 1) {
232
- throw new Error(`Expected 1 element with label text '${text}', found ${matches.length}`);
226
+ throw buildMultipleFoundError(container, "labelText", { text, options }, matches.length);
233
227
  }
234
- const [first] = matches;
235
- if (!first)
236
- throw new Error(`Unable to find element with label text '${text}'`);
237
- return first;
228
+ return matches[0];
238
229
  };
239
230
  const getAllByText = (container, text, options) => {
240
- const matches = findAll(container, (node) => {
241
- const widgetText = getWidgetText(node);
242
- return matchText(widgetText, text, node, options);
243
- });
231
+ const matches = queryAllByText(container, text, options);
244
232
  if (matches.length === 0) {
245
- throw new Error(`Unable to find any elements with text '${text}'`);
233
+ throw buildNotFoundError(container, "text", { text, options });
246
234
  }
247
235
  return matches;
248
236
  };
249
237
  const getByText = (container, text, options) => {
250
238
  const matches = getAllByText(container, text, options);
251
239
  if (matches.length > 1) {
252
- throw new Error(`Expected 1 element with text '${text}', found ${matches.length}`);
240
+ throw buildMultipleFoundError(container, "text", { text, options }, matches.length);
253
241
  }
254
- const [first] = matches;
255
- if (!first)
256
- throw new Error(`Unable to find element with text '${text}'`);
257
- return first;
242
+ return matches[0];
258
243
  };
259
244
  const getAllByTestId = (container, testId, options) => {
260
- const matches = findAll(container, (node) => {
261
- const widgetTestId = getWidgetTestId(node);
262
- return matchText(widgetTestId, testId, node, options);
263
- });
245
+ const matches = queryAllByTestId(container, testId, options);
264
246
  if (matches.length === 0) {
265
- throw new Error(`Unable to find any elements with test id '${testId}'`);
247
+ throw buildNotFoundError(container, "testId", { testId, options });
266
248
  }
267
249
  return matches;
268
250
  };
269
251
  const getByTestId = (container, testId, options) => {
270
252
  const matches = getAllByTestId(container, testId, options);
271
253
  if (matches.length > 1) {
272
- throw new Error(`Expected 1 element with test id '${testId}', found ${matches.length}`);
254
+ throw buildMultipleFoundError(container, "testId", { testId, options }, matches.length);
273
255
  }
274
- const [first] = matches;
275
- if (!first)
276
- throw new Error(`Unable to find element with test id '${testId}'`);
277
- return first;
256
+ return matches[0];
278
257
  };
279
258
  /**
280
259
  * Finds a single element by accessible role.
package/dist/render.js CHANGED
@@ -1,38 +1,14 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { discardAllBatches, start } from "@gtkx/ffi";
3
3
  import * as Gio from "@gtkx/ffi/gio";
4
- import * as Gtk from "@gtkx/ffi/gtk";
5
4
  import { ApplicationContext, GtkApplicationWindow, reconciler } from "@gtkx/react";
6
- import * as queries from "./queries.js";
5
+ import { bindQueries } from "./bind-queries.js";
6
+ import { prettyWidget } from "./pretty-widget.js";
7
7
  import { setScreenRoot } from "./screen.js";
8
8
  import { tick } from "./timing.js";
9
- import { hasLabel } from "./widget.js";
10
9
  let application = null;
11
10
  let container = null;
12
11
  let lastRenderError = null;
13
- const getWidgetLabel = (widget) => {
14
- if (!hasLabel(widget))
15
- return null;
16
- const role = widget.getAccessibleRole();
17
- if (role === Gtk.AccessibleRole.LABEL) {
18
- return widget.getLabel?.() ?? null;
19
- }
20
- return widget.getLabel?.() ?? null;
21
- };
22
- const printWidgetTree = (root, indent = 0) => {
23
- const prefix = " ".repeat(indent);
24
- const role = root.getAccessibleRole();
25
- const roleName = role !== undefined ? (Gtk.AccessibleRole[role] ?? "UNKNOWN") : "UNKNOWN";
26
- const labelText = getWidgetLabel(root);
27
- const label = labelText ? ` label="${labelText}"` : "";
28
- let result = `${prefix}<${root.constructor.name} role=${roleName}${label}>\n`;
29
- let child = root.getFirstChild();
30
- while (child) {
31
- result += printWidgetTree(child, indent + 1);
32
- child = child.getNextSibling();
33
- }
34
- return result;
35
- };
36
12
  const update = async (instance, element, fiberRoot) => {
37
13
  lastRenderError = null;
38
14
  instance.updateContainer(element, fiberRoot, null, () => { });
@@ -97,14 +73,7 @@ export const render = async (element, options) => {
97
73
  setScreenRoot(application);
98
74
  return {
99
75
  container: application,
100
- findByRole: (role, opts) => queries.findByRole(application, role, opts),
101
- findByLabelText: (text, opts) => queries.findByLabelText(application, text, opts),
102
- findByText: (text, opts) => queries.findByText(application, text, opts),
103
- findByTestId: (testId, opts) => queries.findByTestId(application, testId, opts),
104
- findAllByRole: (role, opts) => queries.findAllByRole(application, role, opts),
105
- findAllByLabelText: (text, opts) => queries.findAllByLabelText(application, text, opts),
106
- findAllByText: (text, opts) => queries.findAllByText(application, text, opts),
107
- findAllByTestId: (testId, opts) => queries.findAllByTestId(application, testId, opts),
76
+ ...bindQueries(application),
108
77
  unmount: () => update(instance, null, fiberRoot),
109
78
  rerender: (newElement) => {
110
79
  const wrapped = wrapElement(newElement, options?.wrapper);
@@ -112,10 +81,7 @@ export const render = async (element, options) => {
112
81
  return update(instance, withCtx, fiberRoot);
113
82
  },
114
83
  debug: () => {
115
- const activeWindow = application.getActiveWindow();
116
- if (activeWindow) {
117
- console.log(printWidgetTree(activeWindow));
118
- }
84
+ console.log(prettyWidget(application));
119
85
  },
120
86
  };
121
87
  };
@@ -0,0 +1,66 @@
1
+ import * as Gtk from "@gtkx/ffi/gtk";
2
+ import { type Container } from "./traversal.js";
3
+ /**
4
+ * Information about a widget and its accessible name.
5
+ */
6
+ export type RoleInfo = {
7
+ widget: Gtk.Widget;
8
+ name: string | null;
9
+ };
10
+ /**
11
+ * Formats a GTK accessible role to a lowercase string.
12
+ *
13
+ * @param role - The GTK accessible role
14
+ * @returns Lowercase role name (e.g., "button", "checkbox")
15
+ */
16
+ export declare const formatRole: (role: Gtk.AccessibleRole | undefined) => string;
17
+ /**
18
+ * Collects all accessible roles and their widgets from a container.
19
+ *
20
+ * Returns a Map where keys are role names (lowercase) and values are
21
+ * arrays of widgets with that role, including their accessible names.
22
+ *
23
+ * @param container - The container to scan for roles
24
+ * @returns Map of role names to arrays of RoleInfo
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * import { getRoles } from "@gtkx/testing";
29
+ *
30
+ * const roles = getRoles(container);
31
+ * // Map {
32
+ * // "button" => [{ widget: ..., name: "Submit" }, { widget: ..., name: "Cancel" }],
33
+ * // "checkbox" => [{ widget: ..., name: "Remember me" }]
34
+ * // }
35
+ * ```
36
+ */
37
+ export declare const getRoles: (container: Container) => Map<string, RoleInfo[]>;
38
+ /**
39
+ * Formats roles into a readable string for error messages.
40
+ *
41
+ * @param container - The container to format roles for
42
+ * @returns Formatted string showing all roles and their accessible names
43
+ */
44
+ export declare const prettyRoles: (container: Container) => string;
45
+ /**
46
+ * Logs all accessible roles in a container to the console.
47
+ *
48
+ * Useful for debugging test failures and discovering available roles.
49
+ *
50
+ * @param container - The container to log roles for
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * import { render, logRoles } from "@gtkx/testing";
55
+ *
56
+ * const { container } = await render(<MyComponent />);
57
+ * logRoles(container);
58
+ * // Console output:
59
+ * // button:
60
+ * // Name "Submit": <GtkButton role="button">Submit</GtkButton>
61
+ * // Name "Cancel": <GtkButton role="button">Cancel</GtkButton>
62
+ * // checkbox:
63
+ * // Name "Remember me": <GtkCheckButton role="checkbox">Remember me</GtkCheckButton>
64
+ * ```
65
+ */
66
+ export declare const logRoles: (container: Container) => void;
@@ -0,0 +1,108 @@
1
+ import * as Gtk from "@gtkx/ffi/gtk";
2
+ import { traverse } from "./traversal.js";
3
+ import { getWidgetText } from "./widget-text.js";
4
+ /**
5
+ * Formats a GTK accessible role to a lowercase string.
6
+ *
7
+ * @param role - The GTK accessible role
8
+ * @returns Lowercase role name (e.g., "button", "checkbox")
9
+ */
10
+ export const formatRole = (role) => {
11
+ if (role === undefined)
12
+ return "unknown";
13
+ const name = Gtk.AccessibleRole[role];
14
+ if (!name)
15
+ return String(role);
16
+ return name.toLowerCase();
17
+ };
18
+ /**
19
+ * Collects all accessible roles and their widgets from a container.
20
+ *
21
+ * Returns a Map where keys are role names (lowercase) and values are
22
+ * arrays of widgets with that role, including their accessible names.
23
+ *
24
+ * @param container - The container to scan for roles
25
+ * @returns Map of role names to arrays of RoleInfo
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * import { getRoles } from "@gtkx/testing";
30
+ *
31
+ * const roles = getRoles(container);
32
+ * // Map {
33
+ * // "button" => [{ widget: ..., name: "Submit" }, { widget: ..., name: "Cancel" }],
34
+ * // "checkbox" => [{ widget: ..., name: "Remember me" }]
35
+ * // }
36
+ * ```
37
+ */
38
+ export const getRoles = (container) => {
39
+ const roles = new Map();
40
+ for (const widget of traverse(container)) {
41
+ const role = widget.getAccessibleRole?.();
42
+ if (role === undefined)
43
+ continue;
44
+ const roleName = formatRole(role);
45
+ const name = getWidgetText(widget);
46
+ const info = { widget, name };
47
+ const existing = roles.get(roleName);
48
+ if (existing) {
49
+ existing.push(info);
50
+ }
51
+ else {
52
+ roles.set(roleName, [info]);
53
+ }
54
+ }
55
+ return roles;
56
+ };
57
+ const formatWidgetPreview = (widget, name) => {
58
+ const tagName = widget.constructor.name;
59
+ const roleAttr = formatRole(widget.getAccessibleRole?.());
60
+ const nameDisplay = name ? `Name "${name}"` : 'Name ""';
61
+ return `${nameDisplay}: <${tagName} role="${roleAttr}">${name ?? ""}</${tagName}>`;
62
+ };
63
+ /**
64
+ * Formats roles into a readable string for error messages.
65
+ *
66
+ * @param container - The container to format roles for
67
+ * @returns Formatted string showing all roles and their accessible names
68
+ */
69
+ export const prettyRoles = (container) => {
70
+ const roles = getRoles(container);
71
+ if (roles.size === 0) {
72
+ return "No accessible roles found in the widget tree.";
73
+ }
74
+ const lines = [];
75
+ const sortedRoles = [...roles.entries()].sort(([a], [b]) => a.localeCompare(b));
76
+ for (const [roleName, widgets] of sortedRoles) {
77
+ lines.push(`${roleName}:`);
78
+ for (const { widget, name } of widgets) {
79
+ lines.push(` ${formatWidgetPreview(widget, name)}`);
80
+ }
81
+ lines.push("");
82
+ }
83
+ return lines.join("\n").trimEnd();
84
+ };
85
+ /**
86
+ * Logs all accessible roles in a container to the console.
87
+ *
88
+ * Useful for debugging test failures and discovering available roles.
89
+ *
90
+ * @param container - The container to log roles for
91
+ *
92
+ * @example
93
+ * ```tsx
94
+ * import { render, logRoles } from "@gtkx/testing";
95
+ *
96
+ * const { container } = await render(<MyComponent />);
97
+ * logRoles(container);
98
+ * // Console output:
99
+ * // button:
100
+ * // Name "Submit": <GtkButton role="button">Submit</GtkButton>
101
+ * // Name "Cancel": <GtkButton role="button">Cancel</GtkButton>
102
+ * // checkbox:
103
+ * // Name "Remember me": <GtkCheckButton role="checkbox">Remember me</GtkCheckButton>
104
+ * ```
105
+ */
106
+ export const logRoles = (container) => {
107
+ console.log(prettyRoles(container));
108
+ };