@gtkx/testing 1.0.0-rc.2 → 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 (44) hide show
  1. package/dist/bound-queries.d.ts +2 -3
  2. package/dist/bound-queries.d.ts.map +1 -1
  3. package/dist/bound-queries.js.map +1 -1
  4. package/dist/index.d.ts +7 -4
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/matchers.d.ts +81 -21
  9. package/dist/matchers.d.ts.map +1 -1
  10. package/dist/matchers.js +258 -21
  11. package/dist/matchers.js.map +1 -1
  12. package/dist/normalize.d.ts +10 -0
  13. package/dist/normalize.d.ts.map +1 -0
  14. package/dist/normalize.js +20 -0
  15. package/dist/normalize.js.map +1 -0
  16. package/dist/queries.d.ts +3 -21
  17. package/dist/queries.d.ts.map +1 -1
  18. package/dist/queries.js +46 -59
  19. package/dist/queries.js.map +1 -1
  20. package/dist/render.js +1 -1
  21. package/dist/render.js.map +1 -1
  22. package/dist/types.d.ts +29 -3
  23. package/dist/types.d.ts.map +1 -1
  24. package/dist/types.js.map +1 -1
  25. package/dist/user-event/controller.d.ts +2 -1
  26. package/dist/user-event/controller.d.ts.map +1 -1
  27. package/dist/user-event/controller.js +8 -1
  28. package/dist/user-event/controller.js.map +1 -1
  29. package/dist/widget-accessible-properties.d.ts +12 -2
  30. package/dist/widget-accessible-properties.d.ts.map +1 -1
  31. package/dist/widget-accessible-properties.js +112 -10
  32. package/dist/widget-accessible-properties.js.map +1 -1
  33. package/dist/within.js.map +1 -1
  34. package/package.json +6 -6
  35. package/src/bound-queries.ts +2 -3
  36. package/src/index.ts +11 -2
  37. package/src/matchers.ts +456 -53
  38. package/src/normalize.ts +28 -0
  39. package/src/queries.ts +78 -105
  40. package/src/render.tsx +1 -1
  41. package/src/types.ts +48 -2
  42. package/src/user-event/controller.ts +21 -1
  43. package/src/widget-accessible-properties.ts +172 -10
  44. package/src/within.ts +1 -1
package/src/matchers.ts CHANGED
@@ -1,75 +1,141 @@
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
29
  type TextExpectation = string | RegExp;
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;
17
42
  type MatcherResult = { pass: boolean; message: () => string };
43
+ type MatcherContext = { equals: (actual: unknown, expected: unknown) => boolean };
18
44
  type TextMatcher = (received: unknown, expected?: TextExpectation) => MatcherResult;
19
45
  type StateMatcher = (received: unknown) => MatcherResult;
20
- type ValueMatcher = (received: unknown, expected: number) => MatcherResult;
21
46
  type TextMatcherContext = { matcherName: string; widget: Gtk.Widget; actual: string | null };
47
+ type ClassArguments = { expected: ClassExpectation[]; isExact: boolean };
48
+
49
+ type TextContentMatcher = (
50
+ received: unknown,
51
+ expected?: TextExpectation,
52
+ options?: TextContentOptions,
53
+ ) => MatcherResult;
22
54
 
23
55
  type MatcherImplementations = {
24
56
  toHaveDisplayValue: TextMatcher;
25
- toHaveTextContent: TextMatcher;
57
+ toHaveTextContent: TextContentMatcher;
26
58
  toHaveAccessibleName: TextMatcher;
59
+ toHaveAccessibleDescription: TextMatcher;
60
+ toHaveAccessibleErrorMessage: TextMatcher;
27
61
  toHavePlaceholderText: TextMatcher;
62
+ toHaveSelection: TextMatcher;
28
63
  toBeChecked: StateMatcher;
64
+ toBePartiallyChecked: StateMatcher;
29
65
  toBePressed: StateMatcher;
30
66
  toBeExpanded: StateMatcher;
31
67
  toBeSelected: StateMatcher;
32
- toHaveValue: ValueMatcher;
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;
33
82
  };
34
83
 
35
84
  type ExpectExtend = { extend: (m: MatcherImplementations) => void };
36
85
 
37
- const toHaveDisplayValue: TextMatcher = textMatcher("toHaveDisplayValue", getWidgetDisplayValue, "exact");
86
+ const registration = { isRegistered: false };
87
+ const displayValueMatcher: TextMatcher = textMatcher("toHaveDisplayValue", getWidgetDisplayValue, "exact");
88
+ const toHaveAccessibleName: TextMatcher = textMatcher("toHaveAccessibleName", getWidgetAccessibleName, "exact");
38
89
 
39
- const toHaveTextContent: TextMatcher = textMatcher(
40
- "toHaveTextContent",
41
- (widget) => getWidgetNodeText(widget) ?? getWidgetAccessibleName(widget),
42
- "substring",
90
+ const toHaveAccessibleDescription: TextMatcher = textMatcher(
91
+ "toHaveAccessibleDescription",
92
+ getWidgetDescribedByText,
93
+ "exact",
43
94
  );
44
95
 
45
- const toHaveAccessibleName: TextMatcher = textMatcher("toHaveAccessibleName", getWidgetAccessibleName, "exact");
46
-
47
- const toHavePlaceholderText: TextMatcher = textMatcher(
48
- "toHavePlaceholderText",
49
- getWidgetPlaceholderText,
96
+ const toHaveAccessibleErrorMessage: TextMatcher = textMatcher(
97
+ "toHaveAccessibleErrorMessage",
98
+ readErrorMessageText,
50
99
  "exact",
51
100
  );
52
101
 
53
- const toBeChecked: StateMatcher = booleanStateMatcher("toBeChecked", "checked", getWidgetCheckedState);
102
+ const toHavePlaceholderText: TextMatcher = textMatcher("toHavePlaceholderText", getWidgetPlaceholderText, "exact");
103
+ const toHaveSelection: TextMatcher = textMatcher("toHaveSelection", getWidgetSelection, "exact");
54
104
  const toBePressed: StateMatcher = booleanStateMatcher("toBePressed", "pressed", getWidgetPressedState);
55
105
  const toBeExpanded: StateMatcher = booleanStateMatcher("toBeExpanded", "expanded", getWidgetExpandedState);
56
106
  const toBeSelected: StateMatcher = booleanStateMatcher("toBeSelected", "selected", getWidgetSelectedState);
107
+ const toBeRequired: StateMatcher = booleanStateMatcher("toBeRequired", "required", getWidgetRequiredState);
57
108
 
58
109
  /** The widget assertion matchers keyed by name, suitable for passing to `expect.extend`. */
59
110
  const matchers: MatcherImplementations = {
60
111
  toHaveDisplayValue,
61
112
  toHaveTextContent,
62
113
  toHaveAccessibleName,
114
+ toHaveAccessibleDescription,
115
+ toHaveAccessibleErrorMessage,
63
116
  toHavePlaceholderText,
117
+ toHaveSelection,
64
118
  toBeChecked,
119
+ toBePartiallyChecked,
65
120
  toBePressed,
66
121
  toBeExpanded,
67
122
  toBeSelected,
123
+ toBeDisabled,
124
+ toBeEnabled,
125
+ toBeVisible,
126
+ toBeRooted,
127
+ toBeEmpty,
128
+ toBeInvalid,
129
+ toBeValid,
130
+ toBeRequired,
131
+ toHaveFocus,
68
132
  toHaveValue,
133
+ toHaveRole,
134
+ toContainElement,
135
+ toHaveClass,
136
+ toHaveObjectProperty,
69
137
  };
70
138
 
71
- const registration = { isRegistered: false };
72
-
73
139
  const asWidget = (received: unknown, matcherName: string): Gtk.Widget => {
74
140
  if (!(received instanceof Gtk.Widget)) {
75
141
  throw new TypeError(`${matcherName}: received value must be a Gtk.Widget, got ${typeof received}`);
@@ -78,6 +144,14 @@ const asWidget = (received: unknown, matcherName: string): Gtk.Widget => {
78
144
  return received;
79
145
  };
80
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
+
152
+ return received;
153
+ };
154
+
81
155
  const describeWidget = (widget: Gtk.Widget): string => {
82
156
  const role = Gtk.AccessibleRole[widget.getAccessibleRole()];
83
157
  const name = getWidgetAccessibleName(widget);
@@ -85,6 +159,9 @@ const describeWidget = (widget: Gtk.Widget): string => {
85
159
  return name === null ? `<${role}>` : `<${role} name=${JSON.stringify(name)}>`;
86
160
  };
87
161
 
162
+ const describeObject = (object: GObject.Object): string =>
163
+ object instanceof Gtk.Widget ? describeWidget(object) : `<${object.constructor.name}>`;
164
+
88
165
  const isTextMatch = (actual: string, expected: TextExpectation, mode: "exact" | "substring"): boolean => {
89
166
  if (expected instanceof RegExp) {
90
167
  expected.lastIndex = 0;
@@ -118,6 +195,127 @@ const matchedResult = (context: TextMatcherContext, expected: TextExpectation, i
118
195
  `but received ${JSON.stringify(context.actual)}\n${describeWidget(context.widget)}`,
119
196
  });
120
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
+
121
319
  function textMatcher(
122
320
  matcherName: string,
123
321
  read: (widget: Gtk.Widget) => string | null,
@@ -136,6 +334,37 @@ function textMatcher(
136
334
  };
137
335
  }
138
336
 
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
+
139
368
  function booleanStateMatcher(
140
369
  matcherName: string,
141
370
  stateName: string,
@@ -146,32 +375,59 @@ function booleanStateMatcher(
146
375
  const state = read(widget);
147
376
 
148
377
  if (state === null) {
149
- throw new Error(
150
- `${matcherName}: widget does not expose a ${stateName} state ` +
151
- `(role ${Gtk.AccessibleRole[widget.getAccessibleRole()]})\n${describeWidget(widget)}`,
152
- );
378
+ throw notApplicable(matcherName, `${stateName} state`, widget);
153
379
  }
154
380
 
155
- return {
156
- pass: state,
157
- message: () =>
158
- `expected widget ${negationPrefix(state)}to be ${stateName}\n${describeWidget(widget)}`,
159
- };
381
+ return stateResult(widget, stateName, state);
160
382
  };
161
383
  }
162
384
 
163
- function toHaveValue(received: unknown, expected: number): MatcherResult {
385
+ function readCheckedState(widget: Gtk.Widget, matcherName: string): CheckedState {
386
+ const state = getWidgetCheckedState(widget);
387
+
388
+ if (state === null) {
389
+ throw notApplicable(matcherName, "checked state", widget);
390
+ }
391
+
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 {
164
418
  const widget = asWidget(received, "toHaveValue");
419
+
420
+ if (typeof expected === "string") {
421
+ return displayValueMatcher(received, expected);
422
+ }
423
+
165
424
  const actual = getWidgetValue(widget).now;
166
425
 
167
426
  if (actual === null) {
168
- throw new Error(
169
- "toHaveValue: widget does not expose a numeric value " +
170
- `(role ${Gtk.AccessibleRole[widget.getAccessibleRole()]})\n${describeWidget(widget)}`,
171
- );
427
+ throw notApplicable("toHaveValue", "numeric value", widget);
172
428
  }
173
429
 
174
- const isPass = actual === expected;
430
+ const isPass = expected === undefined || actual === expected;
175
431
 
176
432
  return {
177
433
  pass: isPass,
@@ -181,15 +437,135 @@ function toHaveValue(received: unknown, expected: number): MatcherResult {
181
437
  };
182
438
  }
183
439
 
184
- const globalExpect = (): ExpectExtend | null => {
185
- const candidate: unknown = Reflect.get(globalThis, "expect");
440
+ function toBeDisabled(received: unknown): MatcherResult {
441
+ const widget = asWidget(received, "toBeDisabled");
186
442
 
187
- if (candidate && typeof (candidate as ExpectExtend).extend === "function") {
188
- return candidate as ExpectExtend;
443
+ return stateResult(widget, "disabled", isWidgetDisabled(widget));
444
+ }
445
+
446
+ function toBeEnabled(received: unknown): MatcherResult {
447
+ const widget = asWidget(received, "toBeEnabled");
448
+
449
+ return stateResult(widget, "enabled", !isWidgetDisabled(widget));
450
+ }
451
+
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" };
189
461
  }
190
462
 
191
- return null;
192
- };
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
+ }
193
569
 
194
570
  /** Registers the widget matchers on the global `expect`, when one is available. Safe to call more than once. */
195
571
  const registerMatchers = (): void => {
@@ -209,29 +585,37 @@ const registerMatchers = (): void => {
209
585
 
210
586
  declare module "@vitest/expect" {
211
587
  /* eslint-disable @typescript-eslint/consistent-type-definitions -- declaration merging requires interfaces */
212
- interface Assertion {
588
+ interface WidgetMatchers {
213
589
  toHaveDisplayValue(expected?: TextExpectation): void;
214
- toHaveTextContent(expected?: TextExpectation): void;
590
+ toHaveTextContent(expected?: TextExpectation, options?: TextContentOptions): void;
215
591
  toHaveAccessibleName(expected?: TextExpectation): void;
592
+ toHaveAccessibleDescription(expected?: TextExpectation): void;
593
+ toHaveAccessibleErrorMessage(expected?: TextExpectation): void;
216
594
  toHavePlaceholderText(expected?: TextExpectation): void;
595
+ toHaveSelection(expected?: TextExpectation): void;
217
596
  toBeChecked(): void;
597
+ toBePartiallyChecked(): void;
218
598
  toBePressed(): void;
219
599
  toBeExpanded(): void;
220
600
  toBeSelected(): void;
221
- toHaveValue(expected: number): 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;
222
615
  }
223
616
 
224
- interface AsymmetricMatchersContaining {
225
- toHaveDisplayValue(expected?: TextExpectation): void;
226
- toHaveTextContent(expected?: TextExpectation): void;
227
- toHaveAccessibleName(expected?: TextExpectation): void;
228
- toHavePlaceholderText(expected?: TextExpectation): void;
229
- toBeChecked(): void;
230
- toBePressed(): void;
231
- toBeExpanded(): void;
232
- toBeSelected(): void;
233
- toHaveValue(expected: number): void;
234
- }
617
+ interface Assertion extends WidgetMatchers {}
618
+ interface AsymmetricMatchersContaining extends WidgetMatchers {}
235
619
  /* eslint-enable @typescript-eslint/consistent-type-definitions */
236
620
  }
237
621
 
@@ -239,13 +623,32 @@ export {
239
623
  toHaveDisplayValue,
240
624
  toHaveTextContent,
241
625
  toHaveAccessibleName,
626
+ toHaveAccessibleDescription,
627
+ toHaveAccessibleErrorMessage,
242
628
  toHavePlaceholderText,
629
+ toHaveSelection,
243
630
  toBeChecked,
631
+ toBePartiallyChecked,
244
632
  toBePressed,
245
633
  toBeExpanded,
246
634
  toBeSelected,
247
- matchers,
635
+ toBeDisabled,
636
+ toBeEnabled,
637
+ toBeVisible,
638
+ toBeRooted,
639
+ toBeEmpty,
640
+ toBeInvalid,
641
+ toBeValid,
642
+ toBeRequired,
643
+ toHaveFocus,
248
644
  toHaveValue,
645
+ toHaveRole,
646
+ toContainElement,
647
+ toHaveClass,
648
+ toHaveObjectProperty,
649
+ matchers,
249
650
  registerMatchers,
651
+ type ClassExpectation,
652
+ type TextContentOptions,
250
653
  type TextExpectation,
251
654
  };
@@ -0,0 +1,28 @@
1
+ import type { NormalizerFn, NormalizerOptions } from "./types.js";
2
+
3
+ /**
4
+ * Builds the default text normalizer, which optionally trims surrounding whitespace and collapses
5
+ * runs of whitespace into single spaces.
6
+ * @param options Toggles for trimming and whitespace collapsing.
7
+ * @returns A function that normalizes a string for comparison against a matcher.
8
+ */
9
+ const getDefaultNormalizer = ({
10
+ trim = true,
11
+ collapseWhitespace = true,
12
+ }: NormalizerOptions = {}): NormalizerFn => {
13
+ return (text: string): string => {
14
+ let result = text;
15
+
16
+ if (trim) {
17
+ result = result.trim();
18
+ }
19
+
20
+ if (collapseWhitespace) {
21
+ result = result.replaceAll(/\s+/g, " ");
22
+ }
23
+
24
+ return result;
25
+ };
26
+ };
27
+
28
+ export { getDefaultNormalizer };