@gtkx/testing 1.0.0-rc.1 → 1.0.0-rc.3

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 (187) hide show
  1. package/dist/act.d.ts +6 -6
  2. package/dist/act.d.ts.map +1 -1
  3. package/dist/act.js +34 -26
  4. package/dist/act.js.map +1 -1
  5. package/dist/bound-queries.d.ts +5 -5
  6. package/dist/bound-queries.d.ts.map +1 -1
  7. package/dist/bound-queries.js.map +1 -1
  8. package/dist/build-queries.d.ts +5 -5
  9. package/dist/build-queries.d.ts.map +1 -1
  10. package/dist/build-queries.js +21 -9
  11. package/dist/build-queries.js.map +1 -1
  12. package/dist/cleanup-registry.d.ts +4 -3
  13. package/dist/cleanup-registry.d.ts.map +1 -1
  14. package/dist/cleanup-registry.js +13 -3
  15. package/dist/cleanup-registry.js.map +1 -1
  16. package/dist/config.d.ts +5 -4
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +11 -9
  19. package/dist/config.js.map +1 -1
  20. package/dist/editable.d.ts +6 -5
  21. package/dist/editable.d.ts.map +1 -1
  22. package/dist/editable.js +7 -5
  23. package/dist/editable.js.map +1 -1
  24. package/dist/errors.d.ts +7 -6
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +64 -48
  27. package/dist/errors.js.map +1 -1
  28. package/dist/fire-event.d.ts +2 -1
  29. package/dist/fire-event.d.ts.map +1 -1
  30. package/dist/fire-event.js +2 -1
  31. package/dist/fire-event.js.map +1 -1
  32. package/dist/frame-sync.d.ts +2 -1
  33. package/dist/frame-sync.d.ts.map +1 -1
  34. package/dist/frame-sync.js +28 -21
  35. package/dist/frame-sync.js.map +1 -1
  36. package/dist/index.d.ts +9 -6
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +5 -3
  39. package/dist/index.js.map +1 -1
  40. package/dist/matchers.d.ts +99 -35
  41. package/dist/matchers.d.ts.map +1 -1
  42. package/dist/matchers.js +314 -63
  43. package/dist/matchers.js.map +1 -1
  44. package/dist/normalize.d.ts +10 -0
  45. package/dist/normalize.d.ts.map +1 -0
  46. package/dist/normalize.js +20 -0
  47. package/dist/normalize.js.map +1 -0
  48. package/dist/pretty-widget.d.ts +6 -4
  49. package/dist/pretty-widget.d.ts.map +1 -1
  50. package/dist/pretty-widget.js +64 -24
  51. package/dist/pretty-widget.js.map +1 -1
  52. package/dist/queries.d.ts +129 -90
  53. package/dist/queries.d.ts.map +1 -1
  54. package/dist/queries.js +235 -164
  55. package/dist/queries.js.map +1 -1
  56. package/dist/register-test-hooks.js +2 -1
  57. package/dist/register-test-hooks.js.map +1 -1
  58. package/dist/render-hook.d.ts +3 -2
  59. package/dist/render-hook.d.ts.map +1 -1
  60. package/dist/render-hook.js +2 -1
  61. package/dist/render-hook.js.map +1 -1
  62. package/dist/render.d.ts +3 -2
  63. package/dist/render.d.ts.map +1 -1
  64. package/dist/render.js +25 -19
  65. package/dist/render.js.map +1 -1
  66. package/dist/role-helpers.d.ts +6 -5
  67. package/dist/role-helpers.d.ts.map +1 -1
  68. package/dist/role-helpers.js +16 -12
  69. package/dist/role-helpers.js.map +1 -1
  70. package/dist/screen.d.ts +4 -3
  71. package/dist/screen.d.ts.map +1 -1
  72. package/dist/screen.js +13 -11
  73. package/dist/screen.js.map +1 -1
  74. package/dist/screenshot.d.ts +4 -3
  75. package/dist/screenshot.d.ts.map +1 -1
  76. package/dist/screenshot.js +43 -36
  77. package/dist/screenshot.js.map +1 -1
  78. package/dist/suggestions.d.ts +11 -6
  79. package/dist/suggestions.d.ts.map +1 -1
  80. package/dist/suggestions.js +10 -5
  81. package/dist/suggestions.js.map +1 -1
  82. package/dist/traversal.d.ts +7 -7
  83. package/dist/traversal.d.ts.map +1 -1
  84. package/dist/traversal.js +17 -11
  85. package/dist/traversal.js.map +1 -1
  86. package/dist/types.d.ts +65 -24
  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 +5 -3
  90. package/dist/user-event/adjustment.d.ts.map +1 -1
  91. package/dist/user-event/adjustment.js +6 -4
  92. package/dist/user-event/adjustment.js.map +1 -1
  93. package/dist/user-event/click.d.ts +6 -5
  94. package/dist/user-event/click.d.ts.map +1 -1
  95. package/dist/user-event/click.js +29 -21
  96. package/dist/user-event/click.js.map +1 -1
  97. package/dist/user-event/controller.d.ts +7 -5
  98. package/dist/user-event/controller.d.ts.map +1 -1
  99. package/dist/user-event/controller.js +16 -6
  100. package/dist/user-event/controller.js.map +1 -1
  101. package/dist/user-event/dispatch.d.ts +3 -3
  102. package/dist/user-event/dispatch.d.ts.map +1 -1
  103. package/dist/user-event/dispatch.js +3 -2
  104. package/dist/user-event/dispatch.js.map +1 -1
  105. package/dist/user-event/event-wrapper.d.ts +2 -1
  106. package/dist/user-event/event-wrapper.d.ts.map +1 -1
  107. package/dist/user-event/event-wrapper.js +27 -16
  108. package/dist/user-event/event-wrapper.js.map +1 -1
  109. package/dist/user-event/gesture.d.ts +18 -14
  110. package/dist/user-event/gesture.d.ts.map +1 -1
  111. package/dist/user-event/gesture.js +70 -37
  112. package/dist/user-event/gesture.js.map +1 -1
  113. package/dist/user-event/index.d.ts +17 -10
  114. package/dist/user-event/index.d.ts.map +1 -1
  115. package/dist/user-event/index.js +7 -3
  116. package/dist/user-event/index.js.map +1 -1
  117. package/dist/user-event/keyboard.d.ts +4 -3
  118. package/dist/user-event/keyboard.d.ts.map +1 -1
  119. package/dist/user-event/keyboard.js +89 -58
  120. package/dist/user-event/keyboard.js.map +1 -1
  121. package/dist/user-event/pointer.d.ts +7 -3
  122. package/dist/user-event/pointer.d.ts.map +1 -1
  123. package/dist/user-event/pointer.js +4 -1
  124. package/dist/user-event/pointer.js.map +1 -1
  125. package/dist/user-event/selection.d.ts +3 -2
  126. package/dist/user-event/selection.d.ts.map +1 -1
  127. package/dist/user-event/selection.js +26 -27
  128. package/dist/user-event/selection.js.map +1 -1
  129. package/dist/user-event/state.d.ts +3 -2
  130. package/dist/user-event/state.d.ts.map +1 -1
  131. package/dist/user-event/state.js +2 -1
  132. package/dist/user-event/state.js.map +1 -1
  133. package/dist/user-event/text.d.ts +8 -7
  134. package/dist/user-event/text.d.ts.map +1 -1
  135. package/dist/user-event/text.js +39 -33
  136. package/dist/user-event/text.js.map +1 -1
  137. package/dist/wait-for.d.ts +5 -5
  138. package/dist/wait-for.d.ts.map +1 -1
  139. package/dist/wait-for.js +45 -30
  140. package/dist/wait-for.js.map +1 -1
  141. package/dist/widget-accessible-properties.d.ts +36 -25
  142. package/dist/widget-accessible-properties.d.ts.map +1 -1
  143. package/dist/widget-accessible-properties.js +215 -68
  144. package/dist/widget-accessible-properties.js.map +1 -1
  145. package/dist/within.d.ts +2 -1
  146. package/dist/within.d.ts.map +1 -1
  147. package/dist/within.js +2 -1
  148. package/dist/within.js.map +1 -1
  149. package/package.json +11 -9
  150. package/src/act.ts +44 -35
  151. package/src/bound-queries.ts +6 -6
  152. package/src/build-queries.ts +84 -43
  153. package/src/cleanup-registry.ts +18 -5
  154. package/src/config.ts +16 -12
  155. package/src/editable.ts +13 -6
  156. package/src/errors.ts +106 -45
  157. package/src/fire-event.ts +3 -1
  158. package/src/frame-sync.ts +38 -21
  159. package/src/index.ts +13 -3
  160. package/src/matchers.ts +568 -114
  161. package/src/normalize.ts +28 -0
  162. package/src/pretty-widget.ts +114 -42
  163. package/src/queries.ts +418 -256
  164. package/src/register-test-hooks.ts +4 -1
  165. package/src/render-hook.tsx +10 -4
  166. package/src/render.tsx +59 -34
  167. package/src/role-helpers.ts +29 -16
  168. package/src/screen.ts +17 -12
  169. package/src/screenshot.ts +61 -38
  170. package/src/suggestions.ts +29 -10
  171. package/src/traversal.ts +35 -15
  172. package/src/types.ts +111 -32
  173. package/src/user-event/adjustment.ts +23 -10
  174. package/src/user-event/click.ts +49 -20
  175. package/src/user-event/controller.ts +41 -7
  176. package/src/user-event/dispatch.ts +4 -2
  177. package/src/user-event/event-wrapper.ts +42 -16
  178. package/src/user-event/gesture.ts +135 -50
  179. package/src/user-event/index.ts +19 -12
  180. package/src/user-event/keyboard.ts +147 -61
  181. package/src/user-event/pointer.ts +20 -8
  182. package/src/user-event/selection.ts +69 -32
  183. package/src/user-event/state.ts +4 -2
  184. package/src/user-event/text.ts +73 -38
  185. package/src/wait-for.ts +77 -36
  186. package/src/widget-accessible-properties.ts +385 -79
  187. package/src/within.ts +8 -5
package/src/matchers.ts CHANGED
@@ -1,200 +1,654 @@
1
1
  /// <reference types="@vitest/expect" />
2
+ import * as GObject from "@gtkx/gi/gobject";
2
3
  import * as Gtk from "@gtkx/gi/gtk";
4
+ import { getDefaultNormalizer } from "./normalize.js";
5
+ import { formatRole } from "./role-helpers.js";
3
6
  import {
7
+ type CheckedState,
4
8
  getWidgetAccessibleName,
5
9
  getWidgetCheckedState,
10
+ getWidgetDescribedByText,
6
11
  getWidgetDisplayValue,
12
+ getWidgetErrorMessage,
7
13
  getWidgetExpandedState,
8
- getWidgetNodeText,
14
+ getWidgetInvalidState,
15
+ getWidgetLabelText,
9
16
  getWidgetPlaceholderText,
10
17
  getWidgetPressedState,
18
+ getWidgetRequiredState,
11
19
  getWidgetSelectedState,
20
+ getWidgetSelection,
21
+ getWidgetTextContent,
12
22
  getWidgetValue,
23
+ hasDisplayValue,
24
+ isWidgetDisabled,
25
+ isWidgetVisible,
13
26
  } from "./widget-accessible-properties.js";
14
27
 
15
28
  /** The expected value for a text matcher: an exact string or a regular expression. */
16
- export type TextExpectation = string | RegExp;
29
+ type TextExpectation = string | RegExp;
17
30
 
31
+ /** Options controlling how `toHaveTextContent` normalizes the text it reads. */
32
+ type TextContentOptions = {
33
+ /**
34
+ * When true (the default), trim the text and collapse runs of whitespace into single spaces
35
+ * before comparing. When false, only replace non-breaking spaces with regular ones.
36
+ */
37
+ normalizeWhitespace?: boolean | undefined;
38
+ };
39
+
40
+ /** The expected value for a style class: an exact class name or a regular expression. */
41
+ type ClassExpectation = string | RegExp;
18
42
  type MatcherResult = { pass: boolean; message: () => string };
43
+ type MatcherContext = { equals: (actual: unknown, expected: unknown) => boolean };
44
+ type TextMatcher = (received: unknown, expected?: TextExpectation) => MatcherResult;
45
+ type StateMatcher = (received: unknown) => MatcherResult;
46
+ type TextMatcherContext = { matcherName: string; widget: Gtk.Widget; actual: string | null };
47
+ type ClassArguments = { expected: ClassExpectation[]; isExact: boolean };
19
48
 
20
- interface MatcherContext {
21
- isNot: boolean;
22
- }
49
+ type TextContentMatcher = (
50
+ received: unknown,
51
+ expected?: TextExpectation,
52
+ options?: TextContentOptions,
53
+ ) => MatcherResult;
23
54
 
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;
55
+ type MatcherImplementations = {
56
+ toHaveDisplayValue: TextMatcher;
57
+ toHaveTextContent: TextContentMatcher;
58
+ toHaveAccessibleName: TextMatcher;
59
+ toHaveAccessibleDescription: TextMatcher;
60
+ toHaveAccessibleErrorMessage: TextMatcher;
61
+ toHavePlaceholderText: TextMatcher;
62
+ toHaveSelection: TextMatcher;
63
+ toBeChecked: StateMatcher;
64
+ toBePartiallyChecked: StateMatcher;
65
+ toBePressed: StateMatcher;
66
+ toBeExpanded: StateMatcher;
67
+ toBeSelected: StateMatcher;
68
+ toBeDisabled: StateMatcher;
69
+ toBeEnabled: StateMatcher;
70
+ toBeVisible: StateMatcher;
71
+ toBeRooted: StateMatcher;
72
+ toBeEmpty: StateMatcher;
73
+ toBeInvalid: StateMatcher;
74
+ toBeValid: StateMatcher;
75
+ toBeRequired: StateMatcher;
76
+ toHaveFocus: StateMatcher;
77
+ toHaveValue: (received: unknown, expected?: number | string) => MatcherResult;
78
+ toHaveRole: (received: unknown, expected: Gtk.AccessibleRole) => MatcherResult;
79
+ toContainElement: (received: unknown, descendant: Gtk.Widget | null) => MatcherResult;
80
+ toHaveClass: (received: unknown, ...args: unknown[]) => MatcherResult;
81
+ toHaveObjectProperty: (this: MatcherContext, received: unknown, ...args: unknown[]) => MatcherResult;
82
+ };
83
+
84
+ type ExpectExtend = { extend: (m: MatcherImplementations) => void };
85
+
86
+ const registration = { isRegistered: false };
87
+ const displayValueMatcher: TextMatcher = textMatcher("toHaveDisplayValue", getWidgetDisplayValue, "exact");
88
+ const toHaveAccessibleName: TextMatcher = textMatcher("toHaveAccessibleName", getWidgetAccessibleName, "exact");
89
+
90
+ const toHaveAccessibleDescription: TextMatcher = textMatcher(
91
+ "toHaveAccessibleDescription",
92
+ getWidgetDescribedByText,
93
+ "exact",
94
+ );
95
+
96
+ const toHaveAccessibleErrorMessage: TextMatcher = textMatcher(
97
+ "toHaveAccessibleErrorMessage",
98
+ readErrorMessageText,
99
+ "exact",
100
+ );
101
+
102
+ const toHavePlaceholderText: TextMatcher = textMatcher("toHavePlaceholderText", getWidgetPlaceholderText, "exact");
103
+ const toHaveSelection: TextMatcher = textMatcher("toHaveSelection", getWidgetSelection, "exact");
104
+ const toBePressed: StateMatcher = booleanStateMatcher("toBePressed", "pressed", getWidgetPressedState);
105
+ const toBeExpanded: StateMatcher = booleanStateMatcher("toBeExpanded", "expanded", getWidgetExpandedState);
106
+ const toBeSelected: StateMatcher = booleanStateMatcher("toBeSelected", "selected", getWidgetSelectedState);
107
+ const toBeRequired: StateMatcher = booleanStateMatcher("toBeRequired", "required", getWidgetRequiredState);
108
+
109
+ /** The widget assertion matchers keyed by name, suitable for passing to `expect.extend`. */
110
+ const matchers: MatcherImplementations = {
111
+ toHaveDisplayValue,
112
+ toHaveTextContent,
113
+ toHaveAccessibleName,
114
+ toHaveAccessibleDescription,
115
+ toHaveAccessibleErrorMessage,
116
+ toHavePlaceholderText,
117
+ toHaveSelection,
118
+ toBeChecked,
119
+ toBePartiallyChecked,
120
+ toBePressed,
121
+ toBeExpanded,
122
+ toBeSelected,
123
+ toBeDisabled,
124
+ toBeEnabled,
125
+ toBeVisible,
126
+ toBeRooted,
127
+ toBeEmpty,
128
+ toBeInvalid,
129
+ toBeValid,
130
+ toBeRequired,
131
+ toHaveFocus,
132
+ toHaveValue,
133
+ toHaveRole,
134
+ toContainElement,
135
+ toHaveClass,
136
+ toHaveObjectProperty,
137
+ };
27
138
 
28
139
  const asWidget = (received: unknown, matcherName: string): Gtk.Widget => {
29
140
  if (!(received instanceof Gtk.Widget)) {
30
141
  throw new TypeError(`${matcherName}: received value must be a Gtk.Widget, got ${typeof received}`);
31
142
  }
143
+
144
+ return received;
145
+ };
146
+
147
+ const asObject = (received: unknown, matcherName: string): GObject.Object => {
148
+ if (!(received instanceof GObject.Object)) {
149
+ throw new TypeError(`${matcherName}: received value must be a GObject, got ${typeof received}`);
150
+ }
151
+
32
152
  return received;
33
153
  };
34
154
 
35
155
  const describeWidget = (widget: Gtk.Widget): string => {
36
156
  const role = Gtk.AccessibleRole[widget.getAccessibleRole()];
37
157
  const name = getWidgetAccessibleName(widget);
38
- return name !== null ? `<${role} name=${JSON.stringify(name)}>` : `<${role}>`;
158
+
159
+ return name === null ? `<${role}>` : `<${role} name=${JSON.stringify(name)}>`;
39
160
  };
40
161
 
41
- const matchesText = (actual: string, expected: TextExpectation, mode: "exact" | "substring"): boolean => {
162
+ const describeObject = (object: GObject.Object): string =>
163
+ object instanceof Gtk.Widget ? describeWidget(object) : `<${object.constructor.name}>`;
164
+
165
+ const isTextMatch = (actual: string, expected: TextExpectation, mode: "exact" | "substring"): boolean => {
42
166
  if (expected instanceof RegExp) {
43
167
  expected.lastIndex = 0;
168
+
44
169
  return expected.test(actual);
45
170
  }
171
+
46
172
  return mode === "exact" ? actual === expected : actual.includes(expected);
47
173
  };
48
174
 
49
175
  const describeExpected = (expected: TextExpectation): string =>
50
176
  expected instanceof RegExp ? String(expected) : JSON.stringify(expected);
51
177
 
52
- const textMatcher = (
178
+ const negationPrefix = (isPass: boolean): string => (isPass ? "not " : "");
179
+
180
+ const nonEmptyResult = ({ matcherName, widget, actual }: TextMatcherContext): MatcherResult => {
181
+ const isPass = actual !== null && actual !== "";
182
+
183
+ return {
184
+ pass: isPass,
185
+ message: () =>
186
+ `expected widget ${negationPrefix(isPass)}to have a non-empty value for ${matcherName}, ` +
187
+ `but got ${JSON.stringify(actual)}\n${describeWidget(widget)}`,
188
+ };
189
+ };
190
+
191
+ const matchedResult = (context: TextMatcherContext, expected: TextExpectation, isPass: boolean): MatcherResult => ({
192
+ pass: isPass,
193
+ message: () =>
194
+ `expected widget ${negationPrefix(isPass)}${context.matcherName} ${describeExpected(expected)}, ` +
195
+ `but received ${JSON.stringify(context.actual)}\n${describeWidget(context.widget)}`,
196
+ });
197
+
198
+ const stateResult = (widget: Gtk.Widget, stateName: string, isPass: boolean): MatcherResult => ({
199
+ pass: isPass,
200
+ message: () => `expected widget ${negationPrefix(isPass)}to be ${stateName}\n${describeWidget(widget)}`,
201
+ });
202
+
203
+ const camelCase = (name: string): string =>
204
+ name.replaceAll(/-([a-z])/g, (_match, letter: string) => letter.toUpperCase());
205
+
206
+ const isAsymmetricMatcher = (value: unknown): boolean =>
207
+ typeof value === "object" && value !== null && typeof Reflect.get(value, "asymmetricMatch") === "function";
208
+
209
+ const isEqualValue = (context: MatcherContext, actual: unknown, expected: unknown): boolean => {
210
+ if (isAsymmetricMatcher(expected)) {
211
+ return context.equals(actual, expected);
212
+ }
213
+
214
+ if (actual instanceof GObject.Object || expected instanceof GObject.Object) {
215
+ return Object.is(actual, expected);
216
+ }
217
+
218
+ return context.equals(actual, expected);
219
+ };
220
+
221
+ const isClassMatch = (actual: string[], expected: ClassExpectation): boolean =>
222
+ expected instanceof RegExp ? actual.some((name) => expected.test(name)) : actual.includes(expected);
223
+
224
+ const isWidgetRooted = (widget: Gtk.Widget): boolean => {
225
+ const root = widget.getRoot();
226
+
227
+ if (root === null) {
228
+ return false;
229
+ }
230
+
231
+ if (root instanceof Gtk.Window) {
232
+ return Gtk.Window.listToplevels().includes(root);
233
+ }
234
+
235
+ return true;
236
+ };
237
+
238
+ const globalExpect = (): ExpectExtend | null => {
239
+ const candidate: unknown = Reflect.get(globalThis, "expect");
240
+
241
+ if (candidate && typeof (candidate as ExpectExtend).extend === "function") {
242
+ return candidate as ExpectExtend;
243
+ }
244
+
245
+ return null;
246
+ };
247
+
248
+ function readErrorMessageText(widget: Gtk.Widget): string | null {
249
+ const state = getWidgetInvalidState(widget);
250
+
251
+ if (state === null || state === Gtk.AccessibleInvalidState.FALSE) {
252
+ return null;
253
+ }
254
+
255
+ const targets = getWidgetErrorMessage(widget);
256
+
257
+ if (targets === null) {
258
+ return null;
259
+ }
260
+
261
+ const texts = targets.map((target) => getWidgetAccessibleName(target)).filter((text) => text !== null);
262
+
263
+ return texts.length > 0 ? texts.join(" ") : null;
264
+ }
265
+
266
+ function assertReadableProperty(object: GObject.Object, name: string, property: string, value: unknown): void {
267
+ if (typeof value === "function") {
268
+ throw new TypeError(
269
+ `toHaveObjectProperty: "${name}" is shadowed by a method of the same name; ` +
270
+ `call ${property}() and assert on its result instead`,
271
+ );
272
+ }
273
+
274
+ if (value === undefined && !Reflect.has(object, property)) {
275
+ throw new TypeError(
276
+ `toHaveObjectProperty: no readable property "${name}" on ${describeObject(object)}; ` +
277
+ "construct-only and write-only properties cannot be read back",
278
+ );
279
+ }
280
+ }
281
+
282
+ function readObjectProperty(object: GObject.Object, name: string): unknown {
283
+ const property = camelCase(name);
284
+ const value: unknown = Reflect.get(object, property);
285
+ assertReadableProperty(object, name, property, value);
286
+
287
+ return value;
288
+ }
289
+
290
+ function classEntries(entry: unknown): ClassExpectation[] {
291
+ return entry instanceof RegExp ? [entry] : String(entry).split(/\s+/).filter(Boolean);
292
+ }
293
+
294
+ function parseClassArguments(args: unknown[]): ClassArguments {
295
+ const last = args.at(-1);
296
+ const isOptions = typeof last === "object" && last !== null && !(last instanceof RegExp);
297
+ const isExact = isOptions && Reflect.get(last, "exact") === true;
298
+ const entries = isOptions ? args.slice(0, -1) : args;
299
+
300
+ return { expected: entries.flatMap((entry) => classEntries(entry)), isExact };
301
+ }
302
+
303
+ function exactClassResult(widget: Gtk.Widget, actual: string[], expected: ClassExpectation[]): MatcherResult {
304
+ if (expected.some((entry) => entry instanceof RegExp)) {
305
+ throw new TypeError("toHaveClass: the exact option cannot be combined with a regular expression");
306
+ }
307
+
308
+ const names = expected.map(String);
309
+ const isPass = names.length === actual.length && names.every((name) => actual.includes(name));
310
+
311
+ return {
312
+ pass: isPass,
313
+ message: () =>
314
+ `expected widget ${negationPrefix(isPass)}to have exactly the classes ${JSON.stringify(names)}, ` +
315
+ `but received ${JSON.stringify(actual)}\n${describeWidget(widget)}`,
316
+ };
317
+ }
318
+
319
+ function textMatcher(
53
320
  matcherName: string,
54
321
  read: (widget: Gtk.Widget) => string | null,
55
322
  mode: "exact" | "substring",
56
- ): TextMatcher =>
57
- function (this: MatcherContext, received: unknown, expected?: TextExpectation): MatcherResult {
323
+ ): TextMatcher {
324
+ return (received: unknown, expected?: TextExpectation): MatcherResult => {
58
325
  const widget = asWidget(received, matcherName);
59
326
  const actual = read(widget);
327
+ const context: TextMatcherContext = { matcherName, widget, actual };
60
328
 
61
329
  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
- };
330
+ return nonEmptyResult(context);
69
331
  }
70
332
 
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
- };
333
+ return matchedResult(context, expected, actual !== null && isTextMatch(actual, expected, mode));
78
334
  };
335
+ }
79
336
 
80
- const booleanStateMatcher = (
337
+ function normalizeTextContent(text: string, options?: TextContentOptions): string {
338
+ if (options?.normalizeWhitespace === false) {
339
+ return text.replaceAll("\u{A0}", " ");
340
+ }
341
+
342
+ return getDefaultNormalizer()(text);
343
+ }
344
+
345
+ function readTextContent(widget: Gtk.Widget, options?: TextContentOptions): string | null {
346
+ const text = getWidgetTextContent(widget);
347
+
348
+ return text === null ? null : normalizeTextContent(text, options);
349
+ }
350
+
351
+ function toHaveTextContent(
352
+ received: unknown,
353
+ expected?: TextExpectation,
354
+ options?: TextContentOptions,
355
+ ): MatcherResult {
356
+ const read = (widget: Gtk.Widget): string | null => readTextContent(widget, options);
357
+
358
+ return textMatcher("toHaveTextContent", read, "substring")(received, expected);
359
+ }
360
+
361
+ function notApplicable(matcherName: string, stateName: string, widget: Gtk.Widget): Error {
362
+ return new Error(
363
+ `${matcherName}: widget does not expose a ${stateName} ` +
364
+ `(role ${Gtk.AccessibleRole[widget.getAccessibleRole()]})\n${describeWidget(widget)}`,
365
+ );
366
+ }
367
+
368
+ function booleanStateMatcher(
81
369
  matcherName: string,
82
370
  stateName: string,
83
371
  read: (widget: Gtk.Widget) => boolean | null,
84
- ): StateMatcher =>
85
- function (this: MatcherContext, received: unknown): MatcherResult {
372
+ ): StateMatcher {
373
+ return (received: unknown): MatcherResult => {
86
374
  const widget = asWidget(received, matcherName);
87
375
  const state = read(widget);
376
+
88
377
  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
- );
378
+ throw notApplicable(matcherName, `${stateName} state`, widget);
93
379
  }
94
- return {
95
- pass: state,
96
- message: () => `expected widget ${this.isNot ? "not " : ""}to be ${stateName}\n${describeWidget(widget)}`,
97
- };
380
+
381
+ return stateResult(widget, stateName, state);
98
382
  };
383
+ }
99
384
 
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
- );
385
+ function readCheckedState(widget: Gtk.Widget, matcherName: string): CheckedState {
386
+ const state = getWidgetCheckedState(widget);
112
387
 
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);
388
+ if (state === null) {
389
+ throw notApplicable(matcherName, "checked state", widget);
390
+ }
117
391
 
118
- export const toHaveValue: ValueMatcher = function (
119
- this: MatcherContext,
120
- received: unknown,
121
- expected: number,
122
- ): MatcherResult {
392
+ return state;
393
+ }
394
+
395
+ function toBeChecked(received: unknown): MatcherResult {
396
+ const widget = asWidget(received, "toBeChecked");
397
+
398
+ return stateResult(widget, "checked", readCheckedState(widget, "toBeChecked") === "checked");
399
+ }
400
+
401
+ function toBePartiallyChecked(received: unknown): MatcherResult {
402
+ const widget = asWidget(received, "toBePartiallyChecked");
403
+
404
+ return stateResult(widget, "partially checked", readCheckedState(widget, "toBePartiallyChecked") === "mixed");
405
+ }
406
+
407
+ function toHaveDisplayValue(received: unknown, expected?: TextExpectation): MatcherResult {
408
+ const widget = asWidget(received, "toHaveDisplayValue");
409
+
410
+ if (!hasDisplayValue(widget)) {
411
+ throw notApplicable("toHaveDisplayValue", "display value", widget);
412
+ }
413
+
414
+ return displayValueMatcher(received, expected);
415
+ }
416
+
417
+ function toHaveValue(received: unknown, expected?: number | string): MatcherResult {
123
418
  const widget = asWidget(received, "toHaveValue");
419
+
420
+ if (typeof expected === "string") {
421
+ return displayValueMatcher(received, expected);
422
+ }
423
+
124
424
  const actual = getWidgetValue(widget).now;
425
+
125
426
  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
- );
427
+ throw notApplicable("toHaveValue", "numeric value", widget);
130
428
  }
429
+
430
+ const isPass = expected === undefined || actual === expected;
431
+
131
432
  return {
132
- pass: actual === expected,
433
+ pass: isPass,
133
434
  message: () =>
134
- `expected widget ${this.isNot ? "not " : ""}to have value ${expected}, but received ${actual}\n${describeWidget(widget)}`,
435
+ `expected widget ${negationPrefix(isPass)}to have value ${String(expected)}, ` +
436
+ `but received ${String(actual)}\n${describeWidget(widget)}`,
135
437
  };
136
- };
438
+ }
137
439
 
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;
440
+ function toBeDisabled(received: unknown): MatcherResult {
441
+ const widget = asWidget(received, "toBeDisabled");
442
+
443
+ return stateResult(widget, "disabled", isWidgetDisabled(widget));
148
444
  }
149
445
 
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
- };
446
+ function toBeEnabled(received: unknown): MatcherResult {
447
+ const widget = asWidget(received, "toBeEnabled");
162
448
 
163
- type ExpectExtend = { extend: (m: MatcherImplementations) => void };
449
+ return stateResult(widget, "enabled", !isWidgetDisabled(widget));
450
+ }
164
451
 
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;
452
+ function toBeVisible(received: unknown): MatcherResult {
453
+ const widget = asWidget(received, "toBeVisible");
454
+
455
+ return stateResult(widget, "visible", isWidgetVisible(widget));
456
+ }
457
+
458
+ function toBeRooted(received: unknown): MatcherResult {
459
+ if (received === null) {
460
+ return { pass: false, message: () => "expected a widget to be rooted in a window, but received null" };
169
461
  }
170
- return null;
171
- };
172
462
 
173
- let registered = false;
463
+ const widget = asWidget(received, "toBeRooted");
464
+
465
+ return stateResult(widget, "rooted in a window", isWidgetRooted(widget));
466
+ }
467
+
468
+ function toBeEmpty(received: unknown): MatcherResult {
469
+ const widget = asWidget(received, "toBeEmpty");
470
+
471
+ return stateResult(widget, "empty", widget.getFirstChild() === null && getWidgetLabelText(widget) === null);
472
+ }
473
+
474
+ function toBeInvalid(received: unknown): MatcherResult {
475
+ const widget = asWidget(received, "toBeInvalid");
476
+ const state = getWidgetInvalidState(widget);
477
+
478
+ return stateResult(widget, "invalid", state !== null && state !== Gtk.AccessibleInvalidState.FALSE);
479
+ }
480
+
481
+ function toBeValid(received: unknown): MatcherResult {
482
+ const widget = asWidget(received, "toBeValid");
483
+ const state = getWidgetInvalidState(widget);
484
+
485
+ return stateResult(widget, "valid", state === null || state === Gtk.AccessibleInvalidState.FALSE);
486
+ }
487
+
488
+ function toHaveFocus(received: unknown): MatcherResult {
489
+ const widget = asWidget(received, "toHaveFocus");
490
+
491
+ return stateResult(widget, "focused", widget.getPlatformState(Gtk.AccessiblePlatformState.FOCUSED));
492
+ }
493
+
494
+ function toHaveRole(received: unknown, expected: Gtk.AccessibleRole): MatcherResult {
495
+ const widget = asWidget(received, "toHaveRole");
496
+ const actual = widget.getAccessibleRole();
497
+ const isPass = actual === expected;
498
+
499
+ return {
500
+ pass: isPass,
501
+ message: () =>
502
+ `expected widget ${negationPrefix(isPass)}to have role '${formatRole(expected)}', ` +
503
+ `but received '${formatRole(actual)}'\n${describeWidget(widget)}`,
504
+ };
505
+ }
506
+
507
+ function toContainElement(received: unknown, descendant: Gtk.Widget | null): MatcherResult {
508
+ const widget = asWidget(received, "toContainElement");
509
+ const isPass = descendant !== null && (descendant === widget || descendant.isAncestor(widget));
510
+
511
+ return {
512
+ pass: isPass,
513
+ message: () =>
514
+ `expected widget ${negationPrefix(isPass)}to contain ` +
515
+ `${descendant === null ? "null" : describeWidget(descendant)}\n${describeWidget(widget)}`,
516
+ };
517
+ }
518
+
519
+ function toHaveClass(received: unknown, ...args: unknown[]): MatcherResult {
520
+ const widget = asWidget(received, "toHaveClass");
521
+ const actual = widget.getCssClasses();
522
+ const { expected, isExact } = parseClassArguments(args);
523
+
524
+ if (isExact) {
525
+ return exactClassResult(widget, actual, expected);
526
+ }
527
+
528
+ if (expected.length === 0) {
529
+ return stateResult(widget, "given any style class", actual.length > 0);
530
+ }
531
+
532
+ const isPass = expected.every((entry) => isClassMatch(actual, entry));
533
+
534
+ return {
535
+ pass: isPass,
536
+ message: () =>
537
+ `expected widget ${negationPrefix(isPass)}to have class ${expected.map(String).join(", ")}, ` +
538
+ `but received ${JSON.stringify(actual)}\n${describeWidget(widget)}`,
539
+ };
540
+ }
541
+
542
+ function toHaveObjectProperty(this: MatcherContext, received: unknown, ...args: unknown[]): MatcherResult {
543
+ const object = asObject(received, "toHaveObjectProperty");
544
+ const [name, expected] = args;
545
+ const actual = readObjectProperty(object, String(name));
546
+
547
+ if (args.length < 2) {
548
+ const isSet = actual !== null && actual !== undefined;
549
+
550
+ return {
551
+ pass: isSet,
552
+ message: () =>
553
+ `expected ${describeObject(object)} ${negationPrefix(isSet)}to have a value for property ` +
554
+ `"${String(name)}", but received ${JSON.stringify(actual)}`,
555
+ };
556
+ }
557
+
558
+ /* eslint-disable-next-line unicorn/no-this-outside-of-class --
559
+ expect.extend invokes matchers with the matcher state as `this` */
560
+ const isPass = isEqualValue(this, actual, expected);
561
+
562
+ return {
563
+ pass: isPass,
564
+ message: () =>
565
+ `expected ${describeObject(object)} ${negationPrefix(isPass)}to have property "${String(name)}" ` +
566
+ `equal to ${JSON.stringify(expected)}, but received ${JSON.stringify(actual)}`,
567
+ };
568
+ }
174
569
 
175
570
  /** 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;
571
+ const registerMatchers = (): void => {
572
+ if (registration.isRegistered) {
573
+ return;
574
+ }
575
+
178
576
  const expect = globalExpect();
179
- if (!expect) return;
577
+
578
+ if (!expect) {
579
+ return;
580
+ }
581
+
180
582
  expect.extend(matchers);
181
- registered = true;
583
+ registration.isRegistered = true;
182
584
  };
183
585
 
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
586
  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 {}
587
+ /* eslint-disable @typescript-eslint/consistent-type-definitions -- declaration merging requires interfaces */
588
+ interface WidgetMatchers {
589
+ toHaveDisplayValue(expected?: TextExpectation): void;
590
+ toHaveTextContent(expected?: TextExpectation, options?: TextContentOptions): void;
591
+ toHaveAccessibleName(expected?: TextExpectation): void;
592
+ toHaveAccessibleDescription(expected?: TextExpectation): void;
593
+ toHaveAccessibleErrorMessage(expected?: TextExpectation): void;
594
+ toHavePlaceholderText(expected?: TextExpectation): void;
595
+ toHaveSelection(expected?: TextExpectation): void;
596
+ toBeChecked(): void;
597
+ toBePartiallyChecked(): void;
598
+ toBePressed(): void;
599
+ toBeExpanded(): void;
600
+ toBeSelected(): void;
601
+ toBeDisabled(): void;
602
+ toBeEnabled(): void;
603
+ toBeVisible(): void;
604
+ toBeRooted(): void;
605
+ toBeEmpty(): void;
606
+ toBeInvalid(): void;
607
+ toBeValid(): void;
608
+ toBeRequired(): void;
609
+ toHaveFocus(): void;
610
+ toHaveValue(expected?: number | string): void;
611
+ toHaveRole(expected: Gtk.AccessibleRole): void;
612
+ toContainElement(descendant: Gtk.Widget | null): void;
613
+ toHaveClass(...args: (ClassExpectation | { exact: boolean })[]): void;
614
+ toHaveObjectProperty(name: string, expected?: unknown): void;
615
+ }
616
+
617
+ interface Assertion extends WidgetMatchers {}
199
618
  interface AsymmetricMatchersContaining extends WidgetMatchers {}
619
+ /* eslint-enable @typescript-eslint/consistent-type-definitions */
200
620
  }
621
+
622
+ export {
623
+ toHaveDisplayValue,
624
+ toHaveTextContent,
625
+ toHaveAccessibleName,
626
+ toHaveAccessibleDescription,
627
+ toHaveAccessibleErrorMessage,
628
+ toHavePlaceholderText,
629
+ toHaveSelection,
630
+ toBeChecked,
631
+ toBePartiallyChecked,
632
+ toBePressed,
633
+ toBeExpanded,
634
+ toBeSelected,
635
+ toBeDisabled,
636
+ toBeEnabled,
637
+ toBeVisible,
638
+ toBeRooted,
639
+ toBeEmpty,
640
+ toBeInvalid,
641
+ toBeValid,
642
+ toBeRequired,
643
+ toHaveFocus,
644
+ toHaveValue,
645
+ toHaveRole,
646
+ toContainElement,
647
+ toHaveClass,
648
+ toHaveObjectProperty,
649
+ matchers,
650
+ registerMatchers,
651
+ type ClassExpectation,
652
+ type TextContentOptions,
653
+ type TextExpectation,
654
+ };