@gtkx/testing 0.20.0 → 1.0.0-rc.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.
Files changed (216) hide show
  1. package/README.md +122 -55
  2. package/dist/act.d.ts +19 -0
  3. package/dist/act.d.ts.map +1 -0
  4. package/dist/act.js +48 -0
  5. package/dist/act.js.map +1 -0
  6. package/dist/bound-queries.d.ts +32 -0
  7. package/dist/bound-queries.d.ts.map +1 -0
  8. package/dist/bound-queries.js +2 -0
  9. package/dist/bound-queries.js.map +1 -0
  10. package/dist/build-queries.d.ts +16 -0
  11. package/dist/build-queries.d.ts.map +1 -0
  12. package/dist/build-queries.js +103 -0
  13. package/dist/build-queries.js.map +1 -0
  14. package/dist/cleanup-registry.d.ts +4 -0
  15. package/dist/cleanup-registry.d.ts.map +1 -0
  16. package/dist/cleanup-registry.js +11 -0
  17. package/dist/cleanup-registry.js.map +1 -0
  18. package/dist/config.d.ts +18 -51
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +9 -40
  21. package/dist/config.js.map +1 -1
  22. package/dist/editable.d.ts +7 -0
  23. package/dist/editable.d.ts.map +1 -0
  24. package/dist/editable.js +24 -0
  25. package/dist/editable.js.map +1 -0
  26. package/dist/errors.d.ts +29 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +119 -0
  29. package/dist/errors.js.map +1 -0
  30. package/dist/fire-event.d.ts +7 -25
  31. package/dist/fire-event.d.ts.map +1 -1
  32. package/dist/fire-event.js +9 -29
  33. package/dist/fire-event.js.map +1 -1
  34. package/dist/frame-sync.d.ts +3 -0
  35. package/dist/frame-sync.d.ts.map +1 -0
  36. package/dist/frame-sync.js +30 -0
  37. package/dist/frame-sync.js.map +1 -0
  38. package/dist/index.d.ts +16 -11
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +10 -6
  41. package/dist/index.js.map +1 -1
  42. package/dist/matchers.d.ts +55 -0
  43. package/dist/matchers.d.ts.map +1 -0
  44. package/dist/matchers.js +103 -0
  45. package/dist/matchers.js.map +1 -0
  46. package/dist/pretty-widget.d.ts +21 -25
  47. package/dist/pretty-widget.d.ts.map +1 -1
  48. package/dist/pretty-widget.js +69 -115
  49. package/dist/pretty-widget.js.map +1 -1
  50. package/dist/queries.d.ts +130 -177
  51. package/dist/queries.d.ts.map +1 -1
  52. package/dist/queries.js +212 -325
  53. package/dist/queries.js.map +1 -1
  54. package/dist/register-test-hooks.d.ts +2 -0
  55. package/dist/register-test-hooks.d.ts.map +1 -0
  56. package/dist/register-test-hooks.js +15 -0
  57. package/dist/register-test-hooks.js.map +1 -0
  58. package/dist/render-hook.d.ts +14 -35
  59. package/dist/render-hook.d.ts.map +1 -1
  60. package/dist/render-hook.js +8 -45
  61. package/dist/render-hook.js.map +1 -1
  62. package/dist/render.d.ts +11 -41
  63. package/dist/render.d.ts.map +1 -1
  64. package/dist/render.js +129 -118
  65. package/dist/render.js.map +1 -1
  66. package/dist/role-helpers.d.ts +16 -51
  67. package/dist/role-helpers.d.ts.map +1 -1
  68. package/dist/role-helpers.js +39 -58
  69. package/dist/role-helpers.js.map +1 -1
  70. package/dist/screen.d.ts +6 -63
  71. package/dist/screen.d.ts.map +1 -1
  72. package/dist/screen.js +21 -109
  73. package/dist/screen.js.map +1 -1
  74. package/dist/screenshot.d.ts +20 -23
  75. package/dist/screenshot.d.ts.map +1 -1
  76. package/dist/screenshot.js +89 -48
  77. package/dist/screenshot.js.map +1 -1
  78. package/dist/suggestions.d.ts +22 -0
  79. package/dist/suggestions.d.ts.map +1 -0
  80. package/dist/suggestions.js +56 -0
  81. package/dist/suggestions.js.map +1 -0
  82. package/dist/traversal.d.ts +10 -8
  83. package/dist/traversal.d.ts.map +1 -1
  84. package/dist/traversal.js +28 -13
  85. package/dist/traversal.js.map +1 -1
  86. package/dist/types.d.ts +103 -172
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/types.js.map +1 -1
  89. package/dist/user-event/adjustment.d.ts +8 -0
  90. package/dist/user-event/adjustment.d.ts.map +1 -0
  91. package/dist/user-event/adjustment.js +33 -0
  92. package/dist/user-event/adjustment.js.map +1 -0
  93. package/dist/user-event/click.d.ts +7 -0
  94. package/dist/user-event/click.d.ts.map +1 -0
  95. package/dist/user-event/click.js +65 -0
  96. package/dist/user-event/click.js.map +1 -0
  97. package/dist/user-event/controller.d.ts +7 -0
  98. package/dist/user-event/controller.d.ts.map +1 -0
  99. package/dist/user-event/controller.js +28 -0
  100. package/dist/user-event/controller.js.map +1 -0
  101. package/dist/user-event/dispatch.d.ts +7 -0
  102. package/dist/user-event/dispatch.d.ts.map +1 -0
  103. package/dist/user-event/dispatch.js +10 -0
  104. package/dist/user-event/dispatch.js.map +1 -0
  105. package/dist/user-event/event-wrapper.d.ts +3 -0
  106. package/dist/user-event/event-wrapper.d.ts.map +1 -0
  107. package/dist/user-event/event-wrapper.js +48 -0
  108. package/dist/user-event/event-wrapper.js.map +1 -0
  109. package/dist/user-event/gesture.d.ts +31 -0
  110. package/dist/user-event/gesture.d.ts.map +1 -0
  111. package/dist/user-event/gesture.js +105 -0
  112. package/dist/user-event/gesture.js.map +1 -0
  113. package/dist/user-event/index.d.ts +44 -0
  114. package/dist/user-event/index.d.ts.map +1 -0
  115. package/dist/user-event/index.js +38 -0
  116. package/dist/user-event/index.js.map +1 -0
  117. package/dist/user-event/keyboard.d.ts +9 -0
  118. package/dist/user-event/keyboard.d.ts.map +1 -0
  119. package/dist/user-event/keyboard.js +173 -0
  120. package/dist/user-event/keyboard.js.map +1 -0
  121. package/dist/user-event/pointer.d.ts +6 -0
  122. package/dist/user-event/pointer.d.ts.map +1 -0
  123. package/dist/user-event/pointer.js +27 -0
  124. package/dist/user-event/pointer.js.map +1 -0
  125. package/dist/user-event/selection.d.ts +4 -0
  126. package/dist/user-event/selection.d.ts.map +1 -0
  127. package/dist/user-event/selection.js +106 -0
  128. package/dist/user-event/selection.js.map +1 -0
  129. package/dist/user-event/state.d.ts +6 -0
  130. package/dist/user-event/state.d.ts.map +1 -0
  131. package/dist/user-event/state.js +2 -0
  132. package/dist/user-event/state.js.map +1 -0
  133. package/dist/user-event/text.d.ts +17 -0
  134. package/dist/user-event/text.d.ts.map +1 -0
  135. package/dist/user-event/text.js +107 -0
  136. package/dist/user-event/text.js.map +1 -0
  137. package/dist/wait-for.d.ts +13 -32
  138. package/dist/wait-for.d.ts.map +1 -1
  139. package/dist/wait-for.js +62 -73
  140. package/dist/wait-for.js.map +1 -1
  141. package/dist/widget-accessible-properties.d.ts +33 -0
  142. package/dist/widget-accessible-properties.d.ts.map +1 -0
  143. package/dist/widget-accessible-properties.js +215 -0
  144. package/dist/widget-accessible-properties.js.map +1 -0
  145. package/dist/within.d.ts +9 -26
  146. package/dist/within.d.ts.map +1 -1
  147. package/dist/within.js +15 -25
  148. package/dist/within.js.map +1 -1
  149. package/package.json +21 -12
  150. package/src/act.ts +68 -0
  151. package/src/bound-queries.ts +36 -0
  152. package/src/build-queries.ts +185 -0
  153. package/src/cleanup-registry.ts +14 -0
  154. package/src/config.ts +28 -59
  155. package/src/editable.ts +30 -0
  156. package/src/errors.ts +130 -0
  157. package/src/fire-event.ts +11 -38
  158. package/src/frame-sync.ts +34 -0
  159. package/src/index.ts +60 -18
  160. package/src/matchers.ts +200 -0
  161. package/src/pretty-widget.ts +99 -138
  162. package/src/queries.ts +321 -425
  163. package/src/register-test-hooks.ts +16 -0
  164. package/src/render-hook.tsx +31 -44
  165. package/src/render.tsx +161 -149
  166. package/src/role-helpers.ts +47 -67
  167. package/src/screen.ts +21 -114
  168. package/src/screenshot.ts +104 -55
  169. package/src/suggestions.ts +87 -0
  170. package/src/traversal.ts +32 -18
  171. package/src/types.ts +115 -176
  172. package/src/user-event/adjustment.ts +49 -0
  173. package/src/user-event/click.ts +70 -0
  174. package/src/user-event/controller.ts +44 -0
  175. package/src/user-event/dispatch.ts +23 -0
  176. package/src/user-event/event-wrapper.ts +54 -0
  177. package/src/user-event/gesture.ts +154 -0
  178. package/src/user-event/index.ts +74 -0
  179. package/src/user-event/keyboard.ts +204 -0
  180. package/src/user-event/pointer.ts +34 -0
  181. package/src/user-event/selection.ts +122 -0
  182. package/src/user-event/state.ts +6 -0
  183. package/src/user-event/text.ts +137 -0
  184. package/src/wait-for.ts +79 -80
  185. package/src/widget-accessible-properties.ts +244 -0
  186. package/src/within.ts +26 -27
  187. package/dist/bind-queries.d.ts +0 -14
  188. package/dist/bind-queries.d.ts.map +0 -1
  189. package/dist/bind-queries.js +0 -29
  190. package/dist/bind-queries.js.map +0 -1
  191. package/dist/error-builder.d.ts +0 -28
  192. package/dist/error-builder.d.ts.map +0 -1
  193. package/dist/error-builder.js +0 -81
  194. package/dist/error-builder.js.map +0 -1
  195. package/dist/timing.d.ts +0 -18
  196. package/dist/timing.d.ts.map +0 -1
  197. package/dist/timing.js +0 -18
  198. package/dist/timing.js.map +0 -1
  199. package/dist/user-event.d.ts +0 -138
  200. package/dist/user-event.d.ts.map +0 -1
  201. package/dist/user-event.js +0 -411
  202. package/dist/user-event.js.map +0 -1
  203. package/dist/widget-text.d.ts +0 -69
  204. package/dist/widget-text.d.ts.map +0 -1
  205. package/dist/widget-text.js +0 -177
  206. package/dist/widget-text.js.map +0 -1
  207. package/dist/widget.d.ts +0 -3
  208. package/dist/widget.d.ts.map +0 -1
  209. package/dist/widget.js +0 -13
  210. package/dist/widget.js.map +0 -1
  211. package/src/bind-queries.ts +0 -52
  212. package/src/error-builder.ts +0 -102
  213. package/src/timing.ts +0 -17
  214. package/src/user-event.ts +0 -492
  215. package/src/widget-text.ts +0 -202
  216. package/src/widget.ts +0 -15
@@ -0,0 +1,200 @@
1
+ /// <reference types="@vitest/expect" />
2
+ import * as Gtk from "@gtkx/gi/gtk";
3
+ import {
4
+ getWidgetAccessibleName,
5
+ getWidgetCheckedState,
6
+ getWidgetDisplayValue,
7
+ getWidgetExpandedState,
8
+ getWidgetNodeText,
9
+ getWidgetPlaceholderText,
10
+ getWidgetPressedState,
11
+ getWidgetSelectedState,
12
+ getWidgetValue,
13
+ } from "./widget-accessible-properties.js";
14
+
15
+ /** The expected value for a text matcher: an exact string or a regular expression. */
16
+ export type TextExpectation = string | RegExp;
17
+
18
+ type MatcherResult = { pass: boolean; message: () => string };
19
+
20
+ interface MatcherContext {
21
+ isNot: boolean;
22
+ }
23
+
24
+ type TextMatcher = (this: MatcherContext, received: unknown, expected?: TextExpectation) => MatcherResult;
25
+ type StateMatcher = (this: MatcherContext, received: unknown) => MatcherResult;
26
+ type ValueMatcher = (this: MatcherContext, received: unknown, expected: number) => MatcherResult;
27
+
28
+ const asWidget = (received: unknown, matcherName: string): Gtk.Widget => {
29
+ if (!(received instanceof Gtk.Widget)) {
30
+ throw new TypeError(`${matcherName}: received value must be a Gtk.Widget, got ${typeof received}`);
31
+ }
32
+ return received;
33
+ };
34
+
35
+ const describeWidget = (widget: Gtk.Widget): string => {
36
+ const role = Gtk.AccessibleRole[widget.getAccessibleRole()];
37
+ const name = getWidgetAccessibleName(widget);
38
+ return name !== null ? `<${role} name=${JSON.stringify(name)}>` : `<${role}>`;
39
+ };
40
+
41
+ const matchesText = (actual: string, expected: TextExpectation, mode: "exact" | "substring"): boolean => {
42
+ if (expected instanceof RegExp) {
43
+ expected.lastIndex = 0;
44
+ return expected.test(actual);
45
+ }
46
+ return mode === "exact" ? actual === expected : actual.includes(expected);
47
+ };
48
+
49
+ const describeExpected = (expected: TextExpectation): string =>
50
+ expected instanceof RegExp ? String(expected) : JSON.stringify(expected);
51
+
52
+ const textMatcher = (
53
+ matcherName: string,
54
+ read: (widget: Gtk.Widget) => string | null,
55
+ mode: "exact" | "substring",
56
+ ): TextMatcher =>
57
+ function (this: MatcherContext, received: unknown, expected?: TextExpectation): MatcherResult {
58
+ const widget = asWidget(received, matcherName);
59
+ const actual = read(widget);
60
+
61
+ if (expected === undefined) {
62
+ const pass = actual !== null && actual !== "";
63
+ return {
64
+ pass,
65
+ message: () =>
66
+ `expected widget ${this.isNot ? "not " : ""}to have a non-empty value for ${matcherName}, ` +
67
+ `but got ${JSON.stringify(actual)}\n${describeWidget(widget)}`,
68
+ };
69
+ }
70
+
71
+ const pass = actual !== null && matchesText(actual, expected, mode);
72
+ return {
73
+ pass,
74
+ message: () =>
75
+ `expected widget ${this.isNot ? "not " : ""}${matcherName} ${describeExpected(expected)}, ` +
76
+ `but received ${JSON.stringify(actual)}\n${describeWidget(widget)}`,
77
+ };
78
+ };
79
+
80
+ const booleanStateMatcher = (
81
+ matcherName: string,
82
+ stateName: string,
83
+ read: (widget: Gtk.Widget) => boolean | null,
84
+ ): StateMatcher =>
85
+ function (this: MatcherContext, received: unknown): MatcherResult {
86
+ const widget = asWidget(received, matcherName);
87
+ const state = read(widget);
88
+ if (state === null) {
89
+ throw new Error(
90
+ `${matcherName}: widget does not expose a ${stateName} state ` +
91
+ `(role ${Gtk.AccessibleRole[widget.getAccessibleRole()]})\n${describeWidget(widget)}`,
92
+ );
93
+ }
94
+ return {
95
+ pass: state,
96
+ message: () => `expected widget ${this.isNot ? "not " : ""}to be ${stateName}\n${describeWidget(widget)}`,
97
+ };
98
+ };
99
+
100
+ export const toHaveDisplayValue: TextMatcher = textMatcher("toHaveDisplayValue", getWidgetDisplayValue, "exact");
101
+ export const toHaveTextContent: TextMatcher = textMatcher(
102
+ "toHaveTextContent",
103
+ (widget) => getWidgetNodeText(widget) ?? getWidgetAccessibleName(widget),
104
+ "substring",
105
+ );
106
+ export const toHaveAccessibleName: TextMatcher = textMatcher("toHaveAccessibleName", getWidgetAccessibleName, "exact");
107
+ export const toHavePlaceholderText: TextMatcher = textMatcher(
108
+ "toHavePlaceholderText",
109
+ getWidgetPlaceholderText,
110
+ "exact",
111
+ );
112
+
113
+ export const toBeChecked: StateMatcher = booleanStateMatcher("toBeChecked", "checked", getWidgetCheckedState);
114
+ export const toBePressed: StateMatcher = booleanStateMatcher("toBePressed", "pressed", getWidgetPressedState);
115
+ export const toBeExpanded: StateMatcher = booleanStateMatcher("toBeExpanded", "expanded", getWidgetExpandedState);
116
+ export const toBeSelected: StateMatcher = booleanStateMatcher("toBeSelected", "selected", getWidgetSelectedState);
117
+
118
+ export const toHaveValue: ValueMatcher = function (
119
+ this: MatcherContext,
120
+ received: unknown,
121
+ expected: number,
122
+ ): MatcherResult {
123
+ const widget = asWidget(received, "toHaveValue");
124
+ const actual = getWidgetValue(widget).now;
125
+ if (actual === null) {
126
+ throw new Error(
127
+ `toHaveValue: widget does not expose a numeric value ` +
128
+ `(role ${Gtk.AccessibleRole[widget.getAccessibleRole()]})\n${describeWidget(widget)}`,
129
+ );
130
+ }
131
+ return {
132
+ pass: actual === expected,
133
+ message: () =>
134
+ `expected widget ${this.isNot ? "not " : ""}to have value ${expected}, but received ${actual}\n${describeWidget(widget)}`,
135
+ };
136
+ };
137
+
138
+ interface MatcherImplementations {
139
+ toHaveDisplayValue: TextMatcher;
140
+ toHaveTextContent: TextMatcher;
141
+ toHaveAccessibleName: TextMatcher;
142
+ toHavePlaceholderText: TextMatcher;
143
+ toBeChecked: StateMatcher;
144
+ toBePressed: StateMatcher;
145
+ toBeExpanded: StateMatcher;
146
+ toBeSelected: StateMatcher;
147
+ toHaveValue: ValueMatcher;
148
+ }
149
+
150
+ /** The widget assertion matchers keyed by name, suitable for passing to `expect.extend`. */
151
+ export const matchers: MatcherImplementations = {
152
+ toHaveDisplayValue,
153
+ toHaveTextContent,
154
+ toHaveAccessibleName,
155
+ toHavePlaceholderText,
156
+ toBeChecked,
157
+ toBePressed,
158
+ toBeExpanded,
159
+ toBeSelected,
160
+ toHaveValue,
161
+ };
162
+
163
+ type ExpectExtend = { extend: (m: MatcherImplementations) => void };
164
+
165
+ const globalExpect = (): ExpectExtend | null => {
166
+ const candidate: unknown = Reflect.get(globalThis, "expect");
167
+ if (candidate && typeof (candidate as ExpectExtend).extend === "function") {
168
+ return candidate as ExpectExtend;
169
+ }
170
+ return null;
171
+ };
172
+
173
+ let registered = false;
174
+
175
+ /** Registers the widget matchers on the global `expect`, when one is available. Safe to call more than once. */
176
+ export const registerMatchers = (): void => {
177
+ if (registered) return;
178
+ const expect = globalExpect();
179
+ if (!expect) return;
180
+ expect.extend(matchers);
181
+ registered = true;
182
+ };
183
+
184
+ interface WidgetMatchers<R = void> {
185
+ toHaveDisplayValue(expected?: TextExpectation): R;
186
+ toHaveTextContent(expected?: TextExpectation): R;
187
+ toHaveAccessibleName(expected?: TextExpectation): R;
188
+ toHavePlaceholderText(expected?: TextExpectation): R;
189
+ toBeChecked(): R;
190
+ toBePressed(): R;
191
+ toBeExpanded(): R;
192
+ toBeSelected(): R;
193
+ toHaveValue(expected: number): R;
194
+ }
195
+
196
+ declare module "@vitest/expect" {
197
+ // biome-ignore lint/suspicious/noExplicitAny: must match the `Assertion<T>` signature it augments for declaration merging
198
+ interface Assertion<T = any> extends WidgetMatchers {}
199
+ interface AsymmetricMatchersContaining extends WidgetMatchers {}
200
+ }
@@ -1,48 +1,61 @@
1
- import * as Gtk from "@gtkx/ffi/gtk";
1
+ import type * as Gtk from "@gtkx/gi/gtk";
2
+ import { sortStringsBy } from "@gtkx/utils";
2
3
  import { formatRole } from "./role-helpers.js";
3
- import { type Container, isApplication } from "./traversal.js";
4
- import { getWidgetPropertyText } from "./widget-text.js";
4
+ import { type Container, roots } from "./traversal.js";
5
+ import { getWidgetNodeText } from "./widget-accessible-properties.js";
5
6
 
6
7
  const DEFAULT_MAX_LENGTH = 7000;
7
8
  const INDENT = " ";
8
9
 
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
- };
10
+ type WidgetIdResolver = (widget: Gtk.Widget) => string;
20
11
 
21
12
  /**
22
- * Options for {@link prettyWidget}.
13
+ * Options controlling how a widget tree is rendered to a string by
14
+ * {@link prettyWidget} and {@link logWidget}.
23
15
  */
24
16
  export type PrettyWidgetOptions = {
25
- /** Maximum output length before truncation (default: 7000) */
17
+ /** Truncates the output once it exceeds this many characters. */
26
18
  maxLength?: number;
27
- /** Enable ANSI color highlighting (default: auto-detect) */
19
+ /** Whether to apply ANSI color highlighting; defaults to the terminal capabilities. */
28
20
  highlight?: boolean;
29
- /** Include widget IDs for MCP/agentic interactions (default: false) */
30
- includeIds?: boolean;
21
+ /** Resolves an `id` attribute to show for each widget. */
22
+ getId?: WidgetIdResolver;
31
23
  };
32
24
 
33
- type HighlightColors = {
25
+ const buildAttrs = (widget: Gtk.Widget, getId: WidgetIdResolver | undefined): [string, string][] => {
26
+ const attrs: [string, string][] = [];
27
+
28
+ if (getId) {
29
+ attrs.push(["id", getId(widget)]);
30
+ }
31
+
32
+ const name = widget.getName();
33
+ if (name) {
34
+ attrs.push(["name", name]);
35
+ }
36
+
37
+ attrs.push(["role", formatRole(widget.getAccessibleRole())]);
38
+
39
+ if (!widget.getSensitive()) {
40
+ attrs.push(["accessible-disabled", "true"]);
41
+ }
42
+
43
+ if (!widget.getVisible()) {
44
+ attrs.push(["accessible-hidden", "true"]);
45
+ }
46
+
47
+ const idAttrs = attrs.filter(([key]) => key === "id");
48
+ const otherAttrs = sortStringsBy(
49
+ attrs.filter(([key]) => key !== "id"),
50
+ ([key]) => key,
51
+ );
52
+ return [...idAttrs, ...otherAttrs];
53
+ };
54
+
55
+ type Colors = {
34
56
  tag: (s: string) => string;
35
57
  attr: (s: string) => string;
36
58
  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
59
  };
47
60
 
48
61
  const ansi = {
@@ -52,150 +65,85 @@ const ansi = {
52
65
  reset: "\x1b[0m",
53
66
  };
54
67
 
55
- const createColors = (enabled: boolean): HighlightColors => {
68
+ const shouldHighlight = (): boolean => {
69
+ if (typeof process === "undefined") return false;
70
+ if (process.env.COLORS === "false" || process.env.NO_COLOR) return false;
71
+ if (process.env.COLORS === "true" || process.env.FORCE_COLOR) return true;
72
+ return process.stdout.isTTY;
73
+ };
74
+
75
+ const createColors = (enabled: boolean): Colors => {
56
76
  if (!enabled) {
57
77
  const identity = (s: string): string => s;
58
- return { tag: identity, attr: identity, value: identity, text: identity, reset: "" };
78
+ return { tag: identity, attr: identity, value: identity };
59
79
  }
60
80
  return {
61
81
  tag: (s) => `${ansi.cyan}${s}${ansi.reset}`,
62
82
  attr: (s) => `${ansi.yellow}${s}${ansi.reset}`,
63
83
  value: (s) => `${ansi.green}${s}${ansi.reset}`,
64
- text: (s) => s,
65
- reset: ansi.reset,
66
84
  };
67
85
  };
68
86
 
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][] = [];
87
+ const escapeAttrValue = (value: string): string => value.replaceAll('"', "&quot;");
79
88
 
80
- if (includeIds) {
81
- attrs.push(["id", getWidgetDebugId(widget)]);
82
- }
89
+ const formatAttrs = (attrs: [string, string][], colors: Colors): string =>
90
+ attrs.map(([key, value]) => ` ${colors.attr(key)}=${colors.value(`"${escapeAttrValue(value)}"`)}`).join("");
83
91
 
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 => {
92
+ const formatWidget = (
93
+ widget: Gtk.Widget,
94
+ depth: number,
95
+ getId: WidgetIdResolver | undefined,
96
+ colors: Colors,
97
+ ): string => {
119
98
  const indent = INDENT.repeat(depth);
120
- const tagName = formatTagName(widget);
121
- const attributes = formatAttributes(widget, colors, includeIds);
122
- const text = getWidgetPropertyText(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
- }
99
+ const tag = widget.constructor.name;
100
+ const attrs = formatAttrs(buildAttrs(widget, getId), colors);
101
+ const openTag = `${colors.tag("<")}${colors.tag(tag)}${attrs}${colors.tag(">")}`;
102
+ const closeTag = `${colors.tag("</")}${colors.tag(tag)}${colors.tag(">")}`;
130
103
 
131
- const openTag = `${colors.tag("<")}${colors.tag(tagName)}${attributes}${colors.tag(">")}`;
104
+ const text = getWidgetNodeText(widget);
105
+ const firstChild = widget.getFirstChild();
132
106
 
133
- if (!hasChildren(widget) && !text) {
107
+ if (!text && !firstChild) {
134
108
  return `${indent}${openTag}\n`;
135
109
  }
136
110
 
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`;
111
+ let output = `${indent}${openTag}\n`;
145
112
  if (text) {
146
- result += `${indent}${INDENT}${colors.text(text)}\n`;
147
- }
148
- for (const childOutput of children) {
149
- result += childOutput;
113
+ output += `${indent}${INDENT}${text}\n`;
150
114
  }
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("");
115
+ let child = firstChild;
116
+ while (child) {
117
+ output += formatWidget(child, depth + 1, getId, colors);
118
+ child = child.getNextSibling();
160
119
  }
161
- return printWidget(container, colors, 0, includeIds);
120
+ output += `${indent}${closeTag}\n`;
121
+ return output;
162
122
  };
163
123
 
164
124
  /**
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";
125
+ * Renders a widget tree as an indented, HTML-like string showing each widget's
126
+ * tag, accessible attributes, and text content.
177
127
  *
178
- * console.log(prettyWidget(application));
179
- * // Output:
180
- * // <GtkApplicationWindow role="window">
181
- * // <GtkButton role="button">
182
- * // Click me
183
- * // </GtkButton>
184
- * // </GtkApplicationWindow>
185
- * ```
128
+ * @param container The scope whose widget tree is formatted.
129
+ * @param options Formatting options such as truncation length and highlighting.
130
+ * @returns The formatted representation of the tree.
186
131
  */
187
132
  export const prettyWidget = (container: Container, options: PrettyWidgetOptions = {}): string => {
188
133
  const envLimit = process.env.DEBUG_PRINT_LIMIT ? Number(process.env.DEBUG_PRINT_LIMIT) : DEFAULT_MAX_LENGTH;
189
134
  const maxLength = options.maxLength ?? envLimit;
190
- const highlight = options.highlight ?? shouldHighlight();
191
- const includeIds = options.includeIds ?? false;
192
135
 
193
136
  if (maxLength === 0) {
194
137
  return "";
195
138
  }
196
139
 
140
+ const highlight = options.highlight ?? shouldHighlight();
197
141
  const colors = createColors(highlight);
198
- const output = printContainer(container, colors, includeIds);
142
+
143
+ let output = "";
144
+ for (const root of roots(container)) {
145
+ output += formatWidget(root, 0, options.getId, colors);
146
+ }
199
147
 
200
148
  if (output.length > maxLength) {
201
149
  return `${output.slice(0, maxLength)}...`;
@@ -203,3 +151,16 @@ export const prettyWidget = (container: Container, options: PrettyWidgetOptions
203
151
 
204
152
  return output.trimEnd();
205
153
  };
154
+
155
+ /**
156
+ * Prints one or more widget trees to the console using {@link prettyWidget}.
157
+ *
158
+ * @param container A single scope or an array of scopes to format and print.
159
+ * @param options Formatting options passed through to {@link prettyWidget}.
160
+ */
161
+ export const logWidget = (container: Container | Container[], options?: PrettyWidgetOptions): void => {
162
+ const containers: Container[] = Array.isArray(container) ? container : [container];
163
+ for (const target of containers) {
164
+ console.log(prettyWidget(target, options));
165
+ }
166
+ };