@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtkx/testing",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "description": "Testing utilities for GTKX applications",
5
5
  "keywords": [
6
6
  "gtkx",
@@ -32,17 +32,19 @@
32
32
  "default": "./dist/index.js"
33
33
  }
34
34
  },
35
+ "sideEffects": false,
35
36
  "files": [
36
- "dist"
37
+ "dist",
38
+ "src"
37
39
  ],
38
40
  "dependencies": {
39
- "@gtkx/ffi": "0.18.0",
40
- "@gtkx/react": "0.18.0"
41
+ "@gtkx/ffi": "0.18.2",
42
+ "@gtkx/react": "0.18.2"
41
43
  },
42
44
  "devDependencies": {
43
45
  "@types/react-reconciler": "^0.33.0",
44
46
  "react-reconciler": "^0.33.0",
45
- "@gtkx/vitest": "0.18.0"
47
+ "@gtkx/vitest": "0.18.2"
46
48
  },
47
49
  "scripts": {
48
50
  "build": "tsc -b && cp ../../README.md .",
@@ -0,0 +1,52 @@
1
+ import type * as Gtk from "@gtkx/ffi/gtk";
2
+ import * as queries from "./queries.js";
3
+ import type { Container } from "./traversal.js";
4
+ import type { BoundQueries, ByRoleOptions, TextMatch, TextMatchOptions } from "./types.js";
5
+
6
+ type ContainerOrGetter = Container | (() => Container);
7
+
8
+ const resolveContainer = (containerOrGetter: ContainerOrGetter): Container =>
9
+ typeof containerOrGetter === "function" ? containerOrGetter() : containerOrGetter;
10
+
11
+ /**
12
+ * Binds all query functions to a container.
13
+ *
14
+ * @param containerOrGetter - The container to bind queries to, or a function that returns it
15
+ * @returns Object with all query methods bound to the container
16
+ *
17
+ * @internal
18
+ */
19
+ export const bindQueries = (containerOrGetter: ContainerOrGetter): BoundQueries => ({
20
+ queryByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) =>
21
+ queries.queryByRole(resolveContainer(containerOrGetter), role, options),
22
+ queryByLabelText: (text: TextMatch, options?: TextMatchOptions) =>
23
+ queries.queryByLabelText(resolveContainer(containerOrGetter), text, options),
24
+ queryByText: (text: TextMatch, options?: TextMatchOptions) =>
25
+ queries.queryByText(resolveContainer(containerOrGetter), text, options),
26
+ queryByTestId: (testId: TextMatch, options?: TextMatchOptions) =>
27
+ queries.queryByTestId(resolveContainer(containerOrGetter), testId, options),
28
+ queryAllByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) =>
29
+ queries.queryAllByRole(resolveContainer(containerOrGetter), role, options),
30
+ queryAllByLabelText: (text: TextMatch, options?: TextMatchOptions) =>
31
+ queries.queryAllByLabelText(resolveContainer(containerOrGetter), text, options),
32
+ queryAllByText: (text: TextMatch, options?: TextMatchOptions) =>
33
+ queries.queryAllByText(resolveContainer(containerOrGetter), text, options),
34
+ queryAllByTestId: (testId: TextMatch, options?: TextMatchOptions) =>
35
+ queries.queryAllByTestId(resolveContainer(containerOrGetter), testId, options),
36
+ findByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) =>
37
+ queries.findByRole(resolveContainer(containerOrGetter), role, options),
38
+ findByLabelText: (text: TextMatch, options?: TextMatchOptions) =>
39
+ queries.findByLabelText(resolveContainer(containerOrGetter), text, options),
40
+ findByText: (text: TextMatch, options?: TextMatchOptions) =>
41
+ queries.findByText(resolveContainer(containerOrGetter), text, options),
42
+ findByTestId: (testId: TextMatch, options?: TextMatchOptions) =>
43
+ queries.findByTestId(resolveContainer(containerOrGetter), testId, options),
44
+ findAllByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) =>
45
+ queries.findAllByRole(resolveContainer(containerOrGetter), role, options),
46
+ findAllByLabelText: (text: TextMatch, options?: TextMatchOptions) =>
47
+ queries.findAllByLabelText(resolveContainer(containerOrGetter), text, options),
48
+ findAllByText: (text: TextMatch, options?: TextMatchOptions) =>
49
+ queries.findAllByText(resolveContainer(containerOrGetter), text, options),
50
+ findAllByTestId: (testId: TextMatch, options?: TextMatchOptions) =>
51
+ queries.findAllByTestId(resolveContainer(containerOrGetter), testId, options),
52
+ });
package/src/config.ts ADDED
@@ -0,0 +1,89 @@
1
+ import type { Container } from "./traversal.js";
2
+
3
+ /**
4
+ * Configuration options for the testing library.
5
+ */
6
+ export type Config = {
7
+ /**
8
+ * Whether to show role suggestions in error messages when elements are not found.
9
+ * @default true
10
+ */
11
+ showSuggestions: boolean;
12
+
13
+ /**
14
+ * Custom error factory for query failures.
15
+ * Allows customizing how errors are constructed.
16
+ */
17
+ getElementError: (message: string, container: Container) => Error;
18
+
19
+ /**
20
+ * Default timeout in milliseconds for async utilities (waitFor, findBy* queries).
21
+ * @default 1000
22
+ */
23
+ asyncUtilTimeout: number;
24
+ };
25
+
26
+ const defaultGetElementError = (message: string, _container: Container): Error => {
27
+ return new Error(message);
28
+ };
29
+
30
+ const defaultConfig: Config = {
31
+ showSuggestions: true,
32
+ getElementError: defaultGetElementError,
33
+ asyncUtilTimeout: 1000,
34
+ };
35
+
36
+ let currentConfig: Config = { ...defaultConfig };
37
+
38
+ /**
39
+ * Returns the current testing library configuration.
40
+ *
41
+ * @returns The current configuration object
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * import { getConfig } from "@gtkx/testing";
46
+ *
47
+ * const config = getConfig();
48
+ * console.log(config.showSuggestions);
49
+ * ```
50
+ */
51
+ export const getConfig = (): Readonly<Config> => {
52
+ return currentConfig;
53
+ };
54
+
55
+ /**
56
+ * Configures the testing library behavior.
57
+ *
58
+ * Accepts either a partial configuration object or a function that receives
59
+ * the current configuration and returns updates.
60
+ *
61
+ * @param newConfig - Partial configuration or updater function
62
+ *
63
+ * @example
64
+ * ```tsx
65
+ * import { configure } from "@gtkx/testing";
66
+ *
67
+ * // Disable role suggestions
68
+ * configure({ showSuggestions: false });
69
+ *
70
+ * // Use updater function
71
+ * configure((current) => ({
72
+ * showSuggestions: !current.showSuggestions,
73
+ * }));
74
+ * ```
75
+ */
76
+ export const configure = (newConfig: Partial<Config> | ((current: Config) => Partial<Config>)): void => {
77
+ const updates = typeof newConfig === "function" ? newConfig(currentConfig) : newConfig;
78
+ currentConfig = { ...currentConfig, ...updates };
79
+ };
80
+
81
+ /**
82
+ * Resets configuration to defaults.
83
+ * Primarily used for testing.
84
+ *
85
+ * @internal
86
+ */
87
+ export const resetConfig = (): void => {
88
+ currentConfig = { ...defaultConfig };
89
+ };
@@ -0,0 +1,102 @@
1
+ import type * as Gtk from "@gtkx/ffi/gtk";
2
+ import { getConfig } from "./config.js";
3
+ import { prettyWidget } from "./pretty-widget.js";
4
+ import { formatRole, prettyRoles } from "./role-helpers.js";
5
+ import type { Container } from "./traversal.js";
6
+ import type { ByRoleOptions, TextMatch } from "./types.js";
7
+
8
+ type QueryType = "role" | "text" | "labelText" | "testId";
9
+
10
+ const formatTextMatcher = (text: TextMatch): string => {
11
+ if (typeof text === "function") {
12
+ return "custom function";
13
+ }
14
+ if (text instanceof RegExp) {
15
+ return text.toString();
16
+ }
17
+ return `'${text}'`;
18
+ };
19
+
20
+ const formatByRoleDescription = (role: Gtk.AccessibleRole, options?: ByRoleOptions): string => {
21
+ const parts = [`role '${formatRole(role).toUpperCase()}'`];
22
+ if (options?.name) parts.push(`name ${formatTextMatcher(options.name)}`);
23
+ if (options?.checked !== undefined) parts.push(`checked=${options.checked}`);
24
+ if (options?.pressed !== undefined) parts.push(`pressed=${options.pressed}`);
25
+ if (options?.selected !== undefined) parts.push(`selected=${options.selected}`);
26
+ if (options?.expanded !== undefined) parts.push(`expanded=${options.expanded}`);
27
+ if (options?.level !== undefined) parts.push(`level=${options.level}`);
28
+ return parts.join(" and ");
29
+ };
30
+
31
+ const formatQueryDescription = (
32
+ queryType: QueryType,
33
+ args: { role?: Gtk.AccessibleRole; text?: TextMatch; testId?: TextMatch; options?: ByRoleOptions },
34
+ ): string => {
35
+ switch (queryType) {
36
+ case "role":
37
+ return formatByRoleDescription(args.role as Gtk.AccessibleRole, args.options);
38
+ case "text":
39
+ return `text ${formatTextMatcher(args.text as TextMatch)}`;
40
+ case "labelText":
41
+ return `label text ${formatTextMatcher(args.text as TextMatch)}`;
42
+ case "testId":
43
+ return `test id ${formatTextMatcher(args.testId as TextMatch)}`;
44
+ }
45
+ };
46
+
47
+ /**
48
+ * Builds an error for when no elements match a query.
49
+ */
50
+ export const buildNotFoundError = (
51
+ container: Container,
52
+ queryType: QueryType,
53
+ args: { role?: Gtk.AccessibleRole; text?: TextMatch; testId?: TextMatch; options?: ByRoleOptions },
54
+ ): Error => {
55
+ const config = getConfig();
56
+ const description = formatQueryDescription(queryType, args);
57
+ const lines: string[] = [`Unable to find an element with ${description}`];
58
+
59
+ if (config.showSuggestions && queryType === "role") {
60
+ lines.push("");
61
+ lines.push("Here are the accessible roles:");
62
+ lines.push("");
63
+ lines.push(prettyRoles(container));
64
+ }
65
+
66
+ lines.push("");
67
+ lines.push(prettyWidget(container, { highlight: false }));
68
+
69
+ const message = lines.join("\n");
70
+ return config.getElementError(message, container);
71
+ };
72
+
73
+ /**
74
+ * Builds an error for when multiple elements match a query but only one was expected.
75
+ */
76
+ export const buildMultipleFoundError = (
77
+ container: Container,
78
+ queryType: QueryType,
79
+ args: { role?: Gtk.AccessibleRole; text?: TextMatch; testId?: TextMatch; options?: ByRoleOptions },
80
+ count: number,
81
+ ): Error => {
82
+ const config = getConfig();
83
+ const description = formatQueryDescription(queryType, args);
84
+ const lines: string[] = [`Found ${count} elements with ${description}, but expected only one`];
85
+
86
+ lines.push("");
87
+ lines.push(prettyWidget(container, { highlight: false }));
88
+
89
+ const message = lines.join("\n");
90
+ return config.getElementError(message, container);
91
+ };
92
+
93
+ /**
94
+ * Builds a timeout error with the last query error message.
95
+ */
96
+ export const buildTimeoutError = (timeout: number, lastError: Error | null): Error => {
97
+ const baseMessage = `Timed out after ${timeout}ms`;
98
+ if (lastError) {
99
+ return new Error(`${baseMessage}.\n\n${lastError.message}`);
100
+ }
101
+ return new Error(baseMessage);
102
+ };
@@ -0,0 +1,43 @@
1
+ import { type Object as GObject, signalEmitv, signalLookup, typeFromName, Value } from "@gtkx/ffi/gobject";
2
+ import type * as Gtk from "@gtkx/ffi/gtk";
3
+ import { tick } from "./timing.js";
4
+
5
+ /**
6
+ * Emits a GTK signal on a widget or event controller.
7
+ *
8
+ * Low-level utility for triggering signals directly. Prefer {@link userEvent}
9
+ * for common interactions like clicking and typing.
10
+ *
11
+ * @param element - The widget or event controller to emit the signal on
12
+ * @param signalName - GTK signal name (e.g., "clicked", "activate", "drag-begin")
13
+ * @param args - Additional signal arguments as GValues
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * import { fireEvent } from "@gtkx/testing";
18
+ * import { Value } from "@gtkx/ffi/gobject";
19
+ *
20
+ * // Emit signal on widget
21
+ * await fireEvent(widget, "clicked");
22
+ *
23
+ * // Emit signal on gesture controller
24
+ * const gesture = widget.observeControllers().getObject(0) as Gtk.GestureDrag;
25
+ * await fireEvent(gesture, "drag-begin", Value.newFromDouble(100), Value.newFromDouble(100));
26
+ * ```
27
+ *
28
+ * @see {@link userEvent} for high-level user interactions
29
+ */
30
+ export const fireEvent = async (
31
+ element: Gtk.Widget | Gtk.EventController,
32
+ signalName: string,
33
+ ...args: Value[]
34
+ ): Promise<void> => {
35
+ const gtype = typeFromName((element.constructor as typeof GObject).glibTypeName);
36
+ const signalId = signalLookup(signalName, gtype);
37
+
38
+ const instanceValue = Value.newFromObject(element as GObject);
39
+
40
+ signalEmitv([instanceValue, ...args], signalId, 0);
41
+
42
+ await tick();
43
+ };
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ export type { Config } from "./config.js";
2
+ export { configure, getConfig } from "./config.js";
3
+ export { fireEvent } from "./fire-event.js";
4
+ export type { PrettyWidgetOptions } from "./pretty-widget.js";
5
+ export { prettyWidget } from "./pretty-widget.js";
6
+ export {
7
+ findAllByLabelText,
8
+ findAllByRole,
9
+ findAllByTestId,
10
+ findAllByText,
11
+ findByLabelText,
12
+ findByRole,
13
+ findByTestId,
14
+ findByText,
15
+ queryAllByLabelText,
16
+ queryAllByRole,
17
+ queryAllByTestId,
18
+ queryAllByText,
19
+ queryByLabelText,
20
+ queryByRole,
21
+ queryByTestId,
22
+ queryByText,
23
+ } from "./queries.js";
24
+ export { cleanup, render } from "./render.js";
25
+ export { renderHook } from "./render-hook.js";
26
+ export type { RoleInfo } from "./role-helpers.js";
27
+ export { getRoles, logRoles, prettyRoles } from "./role-helpers.js";
28
+ export { screen } from "./screen.js";
29
+ export type { ScreenshotOptions } from "./screenshot.js";
30
+ export { screenshot } from "./screenshot.js";
31
+ export { tick } from "./timing.js";
32
+ export type { Container } from "./traversal.js";
33
+ export type {
34
+ BoundQueries,
35
+ ByRoleOptions,
36
+ NormalizerOptions,
37
+ RenderHookOptions,
38
+ RenderHookResult,
39
+ RenderOptions,
40
+ RenderResult,
41
+ ScreenshotResult,
42
+ TextMatch,
43
+ TextMatchFunction,
44
+ TextMatchOptions,
45
+ WaitForOptions,
46
+ WrapperComponent,
47
+ } from "./types.js";
48
+ export type { PointerInput, TabOptions } from "./user-event.js";
49
+ export { userEvent } from "./user-event.js";
50
+ export { waitFor, waitForElementToBeRemoved } from "./wait-for.js";
51
+ export { within } from "./within.js";
@@ -0,0 +1,205 @@
1
+ import * as Gtk from "@gtkx/ffi/gtk";
2
+ import { formatRole } from "./role-helpers.js";
3
+ import { type Container, isApplication } from "./traversal.js";
4
+ import { getWidgetText } from "./widget-text.js";
5
+
6
+ const DEFAULT_MAX_LENGTH = 7000;
7
+ const INDENT = " ";
8
+
9
+ const debugIdMap = new WeakMap<Gtk.Widget, string>();
10
+ let nextDebugId = 0;
11
+
12
+ const getWidgetDebugId = (widget: Gtk.Widget): string => {
13
+ let id = debugIdMap.get(widget);
14
+ if (!id) {
15
+ id = String(nextDebugId++);
16
+ debugIdMap.set(widget, id);
17
+ }
18
+ return id;
19
+ };
20
+
21
+ /**
22
+ * Options for {@link prettyWidget}.
23
+ */
24
+ export type PrettyWidgetOptions = {
25
+ /** Maximum output length before truncation (default: 7000) */
26
+ maxLength?: number;
27
+ /** Enable ANSI color highlighting (default: auto-detect) */
28
+ highlight?: boolean;
29
+ /** Include widget IDs for MCP/agentic interactions (default: false) */
30
+ includeIds?: boolean;
31
+ };
32
+
33
+ type HighlightColors = {
34
+ tag: (s: string) => string;
35
+ attr: (s: string) => string;
36
+ value: (s: string) => string;
37
+ text: (s: string) => string;
38
+ reset: string;
39
+ };
40
+
41
+ const shouldHighlight = (): boolean => {
42
+ if (typeof process === "undefined") return false;
43
+ if (process.env.COLORS === "false" || process.env.NO_COLOR) return false;
44
+ if (process.env.COLORS === "true" || process.env.FORCE_COLOR) return true;
45
+ return process.stdout?.isTTY ?? false;
46
+ };
47
+
48
+ const ansi = {
49
+ cyan: "\x1b[36m",
50
+ yellow: "\x1b[33m",
51
+ green: "\x1b[32m",
52
+ reset: "\x1b[0m",
53
+ };
54
+
55
+ const createColors = (enabled: boolean): HighlightColors => {
56
+ if (!enabled) {
57
+ const identity = (s: string): string => s;
58
+ return { tag: identity, attr: identity, value: identity, text: identity, reset: "" };
59
+ }
60
+ return {
61
+ tag: (s) => `${ansi.cyan}${s}${ansi.reset}`,
62
+ attr: (s) => `${ansi.yellow}${s}${ansi.reset}`,
63
+ value: (s) => `${ansi.green}${s}${ansi.reset}`,
64
+ text: (s) => s,
65
+ reset: ansi.reset,
66
+ };
67
+ };
68
+
69
+ const formatTagName = (widget: Gtk.Widget): string => {
70
+ return widget.constructor.name;
71
+ };
72
+
73
+ const escapeAttrValue = (value: string): string => {
74
+ return value.replace(/"/g, "&quot;");
75
+ };
76
+
77
+ const formatAttributes = (widget: Gtk.Widget, colors: HighlightColors, includeIds: boolean): string => {
78
+ const attrs: [string, string][] = [];
79
+
80
+ if (includeIds) {
81
+ attrs.push(["id", getWidgetDebugId(widget)]);
82
+ }
83
+
84
+ const name = widget.getName();
85
+ if (name) {
86
+ attrs.push(["data-testid", name]);
87
+ }
88
+
89
+ const role = widget.getAccessibleRole();
90
+ if (role !== undefined) {
91
+ attrs.push(["role", formatRole(role)]);
92
+ }
93
+
94
+ if (!widget.getSensitive()) {
95
+ attrs.push(["aria-disabled", "true"]);
96
+ }
97
+
98
+ if (!widget.getVisible()) {
99
+ attrs.push(["aria-hidden", "true"]);
100
+ }
101
+
102
+ if (attrs.length === 0) return "";
103
+
104
+ return attrs
105
+ .sort(([a], [b]) => {
106
+ if (a === "id") return -1;
107
+ if (b === "id") return 1;
108
+ return a.localeCompare(b);
109
+ })
110
+ .map(([key, value]) => ` ${colors.attr(key)}=${colors.value(`"${escapeAttrValue(value)}"`)}`)
111
+ .join("");
112
+ };
113
+
114
+ const hasChildren = (widget: Gtk.Widget): boolean => {
115
+ return widget.getFirstChild() !== null;
116
+ };
117
+
118
+ const printWidget = (widget: Gtk.Widget, colors: HighlightColors, depth: number, includeIds: boolean): string => {
119
+ const indent = INDENT.repeat(depth);
120
+ const tagName = formatTagName(widget);
121
+ const attributes = formatAttributes(widget, colors, includeIds);
122
+ const text = getWidgetText(widget);
123
+ const children: string[] = [];
124
+
125
+ let child = widget.getFirstChild();
126
+ while (child) {
127
+ children.push(printWidget(child, colors, depth + 1, includeIds));
128
+ child = child.getNextSibling();
129
+ }
130
+
131
+ const openTag = `${colors.tag("<")}${colors.tag(tagName)}${attributes}${colors.tag(">")}`;
132
+
133
+ if (!hasChildren(widget) && !text) {
134
+ return `${indent}${openTag}\n`;
135
+ }
136
+
137
+ const closeTag = `${colors.tag("</")}${colors.tag(tagName)}${colors.tag(">")}`;
138
+
139
+ if (text && !hasChildren(widget)) {
140
+ const textContent = colors.text(text);
141
+ return `${indent}${openTag}\n${indent}${INDENT}${textContent}\n${indent}${closeTag}\n`;
142
+ }
143
+
144
+ let result = `${indent}${openTag}\n`;
145
+ if (text) {
146
+ result += `${indent}${INDENT}${colors.text(text)}\n`;
147
+ }
148
+ for (const childOutput of children) {
149
+ result += childOutput;
150
+ }
151
+ result += `${indent}${closeTag}\n`;
152
+
153
+ return result;
154
+ };
155
+
156
+ const printContainer = (container: Container, colors: HighlightColors, includeIds: boolean): string => {
157
+ if (isApplication(container)) {
158
+ const windows = Gtk.Window.listToplevels();
159
+ return windows.map((window) => printWidget(window, colors, 0, includeIds)).join("");
160
+ }
161
+ return printWidget(container, colors, 0, includeIds);
162
+ };
163
+
164
+ /**
165
+ * Formats a widget tree as a readable string for debugging.
166
+ *
167
+ * Renders the widget hierarchy in an HTML-like format with accessibility
168
+ * attributes like role, data-testid, and text content.
169
+ *
170
+ * @param container - The container widget or application to format
171
+ * @param options - Formatting options for length and highlighting
172
+ * @returns Formatted string representation of the widget tree
173
+ *
174
+ * @example
175
+ * ```tsx
176
+ * import { prettyWidget } from "@gtkx/testing";
177
+ *
178
+ * console.log(prettyWidget(application));
179
+ * // Output:
180
+ * // <GtkApplicationWindow role="window">
181
+ * // <GtkButton role="button">
182
+ * // Click me
183
+ * // </GtkButton>
184
+ * // </GtkApplicationWindow>
185
+ * ```
186
+ */
187
+ export const prettyWidget = (container: Container, options: PrettyWidgetOptions = {}): string => {
188
+ const envLimit = process.env.DEBUG_PRINT_LIMIT ? Number(process.env.DEBUG_PRINT_LIMIT) : DEFAULT_MAX_LENGTH;
189
+ const maxLength = options.maxLength ?? envLimit;
190
+ const highlight = options.highlight ?? shouldHighlight();
191
+ const includeIds = options.includeIds ?? false;
192
+
193
+ if (maxLength === 0) {
194
+ return "";
195
+ }
196
+
197
+ const colors = createColors(highlight);
198
+ const output = printContainer(container, colors, includeIds);
199
+
200
+ if (output.length > maxLength) {
201
+ return `${output.slice(0, maxLength)}...`;
202
+ }
203
+
204
+ return output.trimEnd();
205
+ };