@gtkx/testing 1.0.0 → 1.2.0

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 (128) hide show
  1. package/README.md +1 -1
  2. package/dist/act.d.ts +1 -3
  3. package/dist/act.d.ts.map +1 -1
  4. package/dist/act.js +1 -1
  5. package/dist/act.js.map +1 -1
  6. package/dist/build-queries.d.ts +1 -1
  7. package/dist/build-queries.js +1 -1
  8. package/dist/build-queries.js.map +1 -1
  9. package/dist/config.d.ts +8 -1
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +1 -0
  12. package/dist/config.js.map +1 -1
  13. package/dist/frame-sync.d.ts +2 -2
  14. package/dist/frame-sync.d.ts.map +1 -1
  15. package/dist/frame-sync.js +10 -10
  16. package/dist/frame-sync.js.map +1 -1
  17. package/dist/harness-window.d.ts +2 -1
  18. package/dist/harness-window.d.ts.map +1 -1
  19. package/dist/harness-window.js +12 -1
  20. package/dist/harness-window.js.map +1 -1
  21. package/dist/internal.d.ts +1 -0
  22. package/dist/internal.d.ts.map +1 -1
  23. package/dist/internal.js +1 -0
  24. package/dist/internal.js.map +1 -1
  25. package/dist/matchers.d.ts.map +1 -1
  26. package/dist/matchers.js +22 -2
  27. package/dist/matchers.js.map +1 -1
  28. package/dist/pretty-widget.d.ts.map +1 -1
  29. package/dist/pretty-widget.js +2 -1
  30. package/dist/pretty-widget.js.map +1 -1
  31. package/dist/production.d.ts.map +1 -1
  32. package/dist/production.js +2 -2
  33. package/dist/production.js.map +1 -1
  34. package/dist/render.d.ts +12 -1
  35. package/dist/render.d.ts.map +1 -1
  36. package/dist/render.js +45 -11
  37. package/dist/render.js.map +1 -1
  38. package/dist/role-helpers.d.ts.map +1 -1
  39. package/dist/role-helpers.js +2 -1
  40. package/dist/role-helpers.js.map +1 -1
  41. package/dist/screenshot.d.ts.map +1 -1
  42. package/dist/screenshot.js +8 -14
  43. package/dist/screenshot.js.map +1 -1
  44. package/dist/timers.d.ts +9 -0
  45. package/dist/timers.d.ts.map +1 -0
  46. package/dist/timers.js +21 -0
  47. package/dist/timers.js.map +1 -0
  48. package/dist/traversal.d.ts +4 -1
  49. package/dist/traversal.d.ts.map +1 -1
  50. package/dist/traversal.js +16 -1
  51. package/dist/traversal.js.map +1 -1
  52. package/dist/types.d.ts +6 -1
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/types.js.map +1 -1
  55. package/dist/user-event/adjustment.d.ts.map +1 -1
  56. package/dist/user-event/adjustment.js +2 -1
  57. package/dist/user-event/adjustment.js.map +1 -1
  58. package/dist/user-event/click.d.ts +5 -4
  59. package/dist/user-event/click.d.ts.map +1 -1
  60. package/dist/user-event/click.js +5 -4
  61. package/dist/user-event/click.js.map +1 -1
  62. package/dist/user-event/column-header-click.d.ts.map +1 -1
  63. package/dist/user-event/column-header-click.js +2 -12
  64. package/dist/user-event/column-header-click.js.map +1 -1
  65. package/dist/user-event/event-wrapper.d.ts.map +1 -1
  66. package/dist/user-event/event-wrapper.js +34 -21
  67. package/dist/user-event/event-wrapper.js.map +1 -1
  68. package/dist/user-event/index.d.ts +36 -10
  69. package/dist/user-event/index.d.ts.map +1 -1
  70. package/dist/user-event/index.js +4 -3
  71. package/dist/user-event/index.js.map +1 -1
  72. package/dist/user-event/keyboard.d.ts.map +1 -1
  73. package/dist/user-event/keyboard.js +93 -38
  74. package/dist/user-event/keyboard.js.map +1 -1
  75. package/dist/user-event/list-row-click.d.ts.map +1 -1
  76. package/dist/user-event/list-row-click.js +4 -5
  77. package/dist/user-event/list-row-click.js.map +1 -1
  78. package/dist/user-event/native-click.d.ts.map +1 -1
  79. package/dist/user-event/native-click.js +4 -0
  80. package/dist/user-event/native-click.js.map +1 -1
  81. package/dist/user-event/notebook-tab-click.d.ts +5 -0
  82. package/dist/user-event/notebook-tab-click.d.ts.map +1 -0
  83. package/dist/user-event/notebook-tab-click.js +39 -0
  84. package/dist/user-event/notebook-tab-click.js.map +1 -0
  85. package/dist/user-event/text.d.ts +6 -3
  86. package/dist/user-event/text.d.ts.map +1 -1
  87. package/dist/user-event/text.js +88 -33
  88. package/dist/user-event/text.js.map +1 -1
  89. package/dist/wait-for.d.ts.map +1 -1
  90. package/dist/wait-for.js +4 -3
  91. package/dist/wait-for.js.map +1 -1
  92. package/dist/widget-getters.d.ts +4 -1
  93. package/dist/widget-getters.d.ts.map +1 -1
  94. package/dist/widget-getters.js +29 -2
  95. package/dist/widget-getters.js.map +1 -1
  96. package/dist/window-state.d.ts +10 -0
  97. package/dist/window-state.d.ts.map +1 -0
  98. package/dist/window-state.js +41 -0
  99. package/dist/window-state.js.map +1 -0
  100. package/package.json +15 -7
  101. package/src/act.ts +1 -1
  102. package/src/build-queries.ts +1 -1
  103. package/src/config.ts +10 -1
  104. package/src/frame-sync.ts +15 -10
  105. package/src/harness-window.ts +15 -1
  106. package/src/internal.ts +1 -0
  107. package/src/matchers.ts +29 -2
  108. package/src/pretty-widget.ts +2 -1
  109. package/src/production.ts +2 -3
  110. package/src/render.tsx +67 -12
  111. package/src/role-helpers.ts +2 -1
  112. package/src/screenshot.ts +10 -17
  113. package/src/timers.ts +31 -0
  114. package/src/traversal.ts +22 -0
  115. package/src/types.ts +6 -1
  116. package/src/user-event/adjustment.ts +2 -1
  117. package/src/user-event/click.ts +5 -4
  118. package/src/user-event/column-header-click.ts +2 -16
  119. package/src/user-event/event-wrapper.ts +41 -25
  120. package/src/user-event/index.ts +40 -13
  121. package/src/user-event/keyboard.ts +124 -54
  122. package/src/user-event/list-row-click.ts +4 -7
  123. package/src/user-event/native-click.ts +5 -0
  124. package/src/user-event/notebook-tab-click.ts +57 -0
  125. package/src/user-event/text.ts +113 -38
  126. package/src/wait-for.ts +4 -4
  127. package/src/widget-getters.ts +55 -2
  128. package/src/window-state.ts +70 -0
package/src/frame-sync.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type * as Gtk from "@gtkx/gi/gtk";
2
2
  import * as GLib from "@gtkx/gi/glib";
3
+ import { cancelTimeout, scheduleTimeout } from "./timers.js";
3
4
 
4
5
  const CLOCK_STALL_FALLBACK_MS = 500;
5
6
 
@@ -17,22 +18,21 @@ const once = (callback: () => void): (() => void) => {
17
18
  };
18
19
 
19
20
  const hasFrameClock = (widget: Gtk.Widget | null): widget is Gtk.Widget => widget?.getFrameClock() != null;
20
- const isSized = (widget: Gtk.Widget): boolean => widget.getWidth() > 0;
21
21
 
22
- const runUntilReady = (widget: Gtk.Widget, isReady: () => boolean, finish: () => void): void => {
22
+ const runUntilReady = (widget: Gtk.Widget, isReady: () => boolean, timeout: number, finish: () => void): void => {
23
23
  let tickId = 0;
24
24
 
25
- const fallback = setTimeout(() => {
25
+ const fallback = scheduleTimeout(() => {
26
26
  widget.removeTickCallback(tickId);
27
27
  finish();
28
- }, CLOCK_STALL_FALLBACK_MS);
28
+ }, timeout);
29
29
 
30
30
  tickId = widget.addTickCallback(() => {
31
31
  if (!isReady()) {
32
32
  return GLib.SOURCE_CONTINUE;
33
33
  }
34
34
 
35
- clearTimeout(fallback);
35
+ cancelTimeout(fallback);
36
36
  finish();
37
37
 
38
38
  return GLib.SOURCE_REMOVE;
@@ -49,19 +49,24 @@ const scheduleNextFrame = (widget: Gtk.Widget): Promise<void> =>
49
49
  return;
50
50
  }
51
51
 
52
- runUntilReady(widget, () => true, finish);
52
+ runUntilReady(widget, () => true, CLOCK_STALL_FALLBACK_MS, finish);
53
53
  });
54
54
 
55
- const scheduleAfterLayout = (widget: Gtk.Widget | null, callback: () => void): void => {
55
+ const scheduleWhenWindowReady = (
56
+ window: Gtk.Window | null,
57
+ isReady: (window: Gtk.Window) => boolean,
58
+ timeout: number,
59
+ callback: () => void,
60
+ ): void => {
56
61
  const finish = once(callback);
57
62
 
58
- if (!hasFrameClock(widget) || isSized(widget)) {
63
+ if (!hasFrameClock(window) || isReady(window)) {
59
64
  queueMicrotask(finish);
60
65
 
61
66
  return;
62
67
  }
63
68
 
64
- runUntilReady(widget, () => isSized(widget), finish);
69
+ runUntilReady(window, () => isReady(window), timeout, finish);
65
70
  };
66
71
 
67
- export { scheduleAfterLayout, scheduleNextFrame };
72
+ export { scheduleNextFrame, scheduleWhenWindowReady };
@@ -2,8 +2,22 @@ import * as Gtk from "@gtkx/gi/gtk";
2
2
 
3
3
  const HARNESS_WINDOW_WIDTH = 800;
4
4
  const HARNESS_WINDOW_HEIGHT = 600;
5
+ const TEXT_END_POSITION = -1;
5
6
 
6
7
  const createHarnessWindow = (): Gtk.Window =>
7
8
  new Gtk.Window({ defaultWidth: HARNESS_WINDOW_WIDTH, defaultHeight: HARNESS_WINDOW_HEIGHT });
8
9
 
9
- export { createHarnessWindow };
10
+ const presentHarnessWindow = (window: Gtk.Window | null): void => {
11
+ if (!window) {
12
+ return;
13
+ }
14
+
15
+ window.present();
16
+ const focus = window.getFocus();
17
+
18
+ if (focus instanceof Gtk.Text) {
19
+ focus.setPosition(TEXT_END_POSITION);
20
+ }
21
+ };
22
+
23
+ export { createHarnessWindow, presentHarnessWindow };
package/src/internal.ts CHANGED
@@ -9,3 +9,4 @@ export {
9
9
  } from "./accessible-native.js";
10
10
  export { cleanup, render } from "./production.js";
11
11
  export { renderChildren, type ChildrenBuilder } from "./render-children.js";
12
+ export { getTypeTag } from "./widget-getters.js";
package/src/matchers.ts CHANGED
@@ -39,6 +39,8 @@ import {
39
39
  isWidgetValueMatch,
40
40
  isWidgetVisible,
41
41
  } from "./widget-accessible-properties.js";
42
+ import { getTypeTag } from "./widget-getters.js";
43
+ import { isWindowActivated } from "./window-state.js";
42
44
 
43
45
  /** The expected value for a text matcher: an exact string or a regular expression. */
44
46
  type TextExpectation = string | RegExp;
@@ -389,7 +391,7 @@ const describeWidget = (widget: Gtk.Widget): string => {
389
391
  };
390
392
 
391
393
  const describeObject = (object: GObject.Object): string =>
392
- object instanceof Gtk.Widget ? describeWidget(object) : `<${object.constructor.name}>`;
394
+ object instanceof Gtk.Widget ? describeWidget(object) : `<${getTypeTag(object)}>`;
393
395
 
394
396
  const isTextMatch = (actual: string, expected: TextExpectation, mode: "exact" | "substring"): boolean => {
395
397
  if (expected instanceof RegExp) {
@@ -429,6 +431,31 @@ const stateResult = (widget: Gtk.Widget, stateName: string, isPass: boolean): Ma
429
431
  message: () => `expected widget ${negationPrefix(isPass)}to be ${stateName}\n${describeWidget(widget)}`,
430
432
  });
431
433
 
434
+ const isHoldingFocus = (widget: Gtk.Widget, root: Gtk.Window): boolean => {
435
+ const focus = root.getFocus();
436
+
437
+ return focus !== null && (focus === widget || focus.isAncestor(widget));
438
+ };
439
+
440
+ const isFocusHeldByInactiveWindow = (widget: Gtk.Widget): boolean => {
441
+ const root = widget.getRoot();
442
+
443
+ return root instanceof Gtk.Window && !isWindowActivated(root) && isHoldingFocus(widget, root);
444
+ };
445
+
446
+ const focusResult = (widget: Gtk.Widget, isPass: boolean): MatcherResult => {
447
+ if (isPass || !isFocusHeldByInactiveWindow(widget)) {
448
+ return stateResult(widget, "focused", isPass);
449
+ }
450
+
451
+ return {
452
+ pass: false,
453
+ message: () =>
454
+ "expected widget to be focused: its window has given it the focus, " +
455
+ `but that window is not active\n${describeWidget(widget)}`,
456
+ };
457
+ };
458
+
432
459
  const describeAttributeValue = (value: AccessibleAttributeValue | null): string =>
433
460
  value === null ? "unset" : JSON.stringify(value);
434
461
 
@@ -900,7 +927,7 @@ function toBeValid(received: unknown): MatcherResult {
900
927
  function toHaveFocus(received: unknown): MatcherResult {
901
928
  const widget = asWidget(received, "toHaveFocus");
902
929
 
903
- return stateResult(widget, "focused", widget.getPlatformState(Gtk.AccessiblePlatformState.FOCUSED));
930
+ return focusResult(widget, widget.getPlatformState(Gtk.AccessiblePlatformState.FOCUSED));
904
931
  }
905
932
 
906
933
  function toHaveRole(received: unknown, expected: Gtk.AccessibleRole): MatcherResult {
@@ -4,6 +4,7 @@ import { type Config, format, type NewPlugin, type PrettyFormatOptions } from "@
4
4
  import { formatRole } from "./role-helpers.js";
5
5
  import { type Container, descendants, isOnScreen, roots } from "./traversal.js";
6
6
  import { getWidgetText } from "./widget-accessible-properties.js";
7
+ import { getTypeTag } from "./widget-getters.js";
7
8
 
8
9
  /** Produces the value of the `id` attribute printed first on a widget's opening tag. */
9
10
  type WidgetIdResolver = (widget: Gtk.Widget) => string;
@@ -181,7 +182,7 @@ const formatBody = (widget: Gtk.Widget, indentation: string, depth: number, ctx:
181
182
 
182
183
  const formatWidget = (widget: Gtk.Widget, indentation: string, depth: number, ctx: FormatContext): string => {
183
184
  const { config } = ctx;
184
- const tag = widget.constructor.name;
185
+ const tag = getTypeTag(widget);
185
186
  const attrs = formatAttrs(buildAttrs(widget, ctx.getId), config.colors);
186
187
  const openTag = `${paint(config.colors.tag, "<" + tag)}${attrs}${paint(config.colors.tag, ">")}`;
187
188
  const closeTag = paint(config.colors.tag, `</${tag}>`);
package/src/production.ts CHANGED
@@ -2,6 +2,7 @@ import type * as Gtk from "@gtkx/gi/gtk";
2
2
  import type { ReactNode } from "react";
3
3
  import { createRoot, type Root } from "@gtkx/react";
4
4
  import { createHarnessWindow } from "./harness-window.js";
5
+ import { delay } from "./timers.js";
5
6
 
6
7
  type ProductionRenderResult = {
7
8
  rerender: (element: ReactNode) => Promise<void>;
@@ -15,11 +16,9 @@ type ActiveRender = {
15
16
  const SETTLE_TURNS = 3;
16
17
  const activeRenders: Set<ActiveRender> = new Set();
17
18
 
18
- const turn = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
19
-
20
19
  const settle = async (): Promise<void> => {
21
20
  for (let index = 0; index < SETTLE_TURNS; index++) {
22
- await turn();
21
+ await delay(0);
23
22
  }
24
23
  };
25
24
 
package/src/render.tsx CHANGED
@@ -12,14 +12,16 @@ import type { RenderResult } from "./bound-queries.js";
12
12
  import type { QueryMap, RenderOptions, ScreenshotOptions } from "./types.js";
13
13
  import { runInAct } from "./act.js";
14
14
  import { addToCleanupQueue, runCleanup } from "./cleanup-registry.js";
15
- import { scheduleAfterLayout } from "./frame-sync.js";
16
- import { createHarnessWindow } from "./harness-window.js";
15
+ import { getConfig } from "./config.js";
16
+ import { scheduleWhenWindowReady } from "./frame-sync.js";
17
+ import { createHarnessWindow, presentHarnessWindow } from "./harness-window.js";
17
18
  import { logWidget, type PrettyWidgetOptions } from "./pretty-widget.js";
18
19
  import { logRoles } from "./role-helpers.js";
19
20
  import { clearScreen, setScreen } from "./screen.js";
20
21
  import { captureScreen } from "./screenshot.js";
21
22
  import { type Container, roots, TOPLEVELS } from "./traversal.js";
22
23
  import { resetClipboard } from "./user-event/index.js";
24
+ import { findPresentedWindowFailure, findRenderedWindowFailure, mappedToplevels } from "./window-state.js";
23
25
  import { within } from "./within.js";
24
26
 
25
27
  /** A mounted render tracked so cleanup can unmount it. */
@@ -40,17 +42,34 @@ type ReconcilerErrorState = {
40
42
  isHandlerInstalled: boolean;
41
43
  };
42
44
 
45
+ type WindowFailureReporter = (window: Gtk.Window) => string | null;
46
+ type SettleAction = "render" | "rerender";
47
+
43
48
  const reconcilerErrors: ReconcilerErrorState = { lastError: null, isHandlerInstalled: false };
44
49
  const activeRenders: Set<ActiveRender> = new Set();
45
50
 
46
- const flushLayout = (window: Gtk.Window | null): Promise<void> =>
47
- new Promise<void>((resolve) => {
48
- scheduleAfterLayout(window, () => {
49
- settleAccessible();
50
- resolve();
51
- });
51
+ const settleWindow = async (
52
+ window: Gtk.Window | null,
53
+ findFailure: WindowFailureReporter,
54
+ action: SettleAction,
55
+ ): Promise<void> => {
56
+ const timeout = getConfig().windowActivationTimeout;
57
+
58
+ await new Promise<void>((resolve) => {
59
+ scheduleWhenWindowReady(window, (target) => findFailure(target) === null, timeout, resolve);
52
60
  });
53
61
 
62
+ settleAccessible();
63
+ const failure = window === null ? null : findFailure(window);
64
+
65
+ if (failure !== null) {
66
+ throw new Error(
67
+ `${action} timed out after ${String(timeout)}ms waiting for the window it rendered into: ${failure}. ` +
68
+ "Platform state such as focus is only readable once that window is allocated and active.",
69
+ );
70
+ }
71
+ };
72
+
54
73
  const update = async (element: ReactNode, root: ReconcilerRoot): Promise<void> => {
55
74
  await runInAct(() => {
56
75
  root.update(element);
@@ -138,6 +157,31 @@ const resolveResultContainer = (
138
157
  return firstToplevelWidget(baseElement);
139
158
  };
140
159
 
160
+ const realizedWindow = (root: Gtk.Root | null): Gtk.Window | null =>
161
+ root instanceof Gtk.Window && root.getFrameClock() !== null ? root : null;
162
+
163
+ const settleTarget = (resolved: ResolvedContainer, container: RenderOptions["container"]): Gtk.Window | null => {
164
+ if (resolved.window) {
165
+ return realizedWindow(resolved.window);
166
+ }
167
+
168
+ if (container instanceof Gtk.Widget) {
169
+ return realizedWindow(container.getRoot());
170
+ }
171
+
172
+ return realizedWindow(mappedToplevels()[0] ?? null);
173
+ };
174
+
175
+ const settleRender = async (
176
+ resolved: ResolvedContainer,
177
+ container: RenderOptions["container"],
178
+ action: SettleAction,
179
+ ): Promise<void> => {
180
+ const isPresentedNow = action === "render" && resolved.window !== null;
181
+ const findFailure = isPresentedNow ? findPresentedWindowFailure : findRenderedWindowFailure;
182
+ await settleWindow(settleTarget(resolved, container), findFailure, action);
183
+ };
184
+
141
185
  const renderErrorHandlers = <Q extends QueryMap>(options: RenderOptions<Q> | undefined) => ({
142
186
  onUncaughtError: handleError,
143
187
  onCaughtError: (error: unknown, errorInfo: ErrorInfo): void => {
@@ -160,11 +204,22 @@ const applyEnableAnimations = (areAnimationsEnabled: boolean): void => {
160
204
  /**
161
205
  * Renders a React element into a GTK4 widget tree and returns queries
162
206
  * scoped to it along with controls for rerendering and unmounting. When no
163
- * container is supplied, a harness window is created and presented.
207
+ * container is supplied, a harness window is created and presented, and the editable it hands the
208
+ * focus to keeps its text unselected, with its caret at the end. The returned promise settles once
209
+ * the window the tree is shown in has been laid out and activation has arrived, so platform state
210
+ * such as focus is already readable when it resolves. A presented harness window is waited on until
211
+ * it is itself active; a window the caller or the tree owns is waited on until the application holds
212
+ * activation, since only a present can claim it. Rendering into a container that is not shown waits
213
+ * for nothing, and a window that never becomes readable within `windowActivationTimeout` throws an
214
+ * error naming the condition that failed rather than resolving with unreadable platform state. A
215
+ * tree mounted into a container that sits outside every toplevel, or inside a window that is not
216
+ * visible, is out of reach of a pointer and a keyboard, so every `userEvent` helper aimed at it
217
+ * rejects after `actionabilityTimeout` naming that condition, and `fireEvent` drives it instead.
164
218
  *
165
219
  * @param element The React element to render.
166
220
  * @param options Optional container, wrapper, custom queries, and other render settings.
167
221
  * @returns A render result with bound queries, debug helpers, and lifecycle controls.
222
+ * @throws When the window the tree is shown in is not laid out and activated in time.
168
223
  */
169
224
  const render = async <Q extends QueryMap = Record<never, never>>(
170
225
  element: ReactNode,
@@ -194,8 +249,8 @@ const render = async <Q extends QueryMap = Record<never, never>>(
194
249
  };
195
250
 
196
251
  await update(wrap(element), root);
197
- resolved.window?.present();
198
- await flushLayout(resolved.window);
252
+ presentHarnessWindow(resolved.window);
253
+ await settleRender(resolved, options?.container, "render");
199
254
  const container = resolveResultContainer(resolved, options?.container, baseElement);
200
255
 
201
256
  const result: RenderResult<Q> = {
@@ -207,7 +262,7 @@ const render = async <Q extends QueryMap = Record<never, never>>(
207
262
  },
208
263
  rerender: async (newElement: ReactNode) => {
209
264
  await update(wrap(newElement), root);
210
- await flushLayout(resolved.window);
265
+ await settleRender(resolved, options?.container, "rerender");
211
266
  },
212
267
  debug: (element: Container | Container[] = baseElement, debugOptions?: PrettyWidgetOptions) => {
213
268
  logWidget(element, debugOptions);
@@ -2,6 +2,7 @@ import * as Gtk from "@gtkx/gi/gtk";
2
2
  import { sortStringsBy } from "@gtkx/utils";
3
3
  import { type Container, traverse } from "./traversal.js";
4
4
  import { getWidgetAccessibleName, getWidgetLevel } from "./widget-accessible-properties.js";
5
+ import { getTypeTag } from "./widget-getters.js";
5
6
 
6
7
  const ROLE_NAMES_BY_VALUE = enumNamesByValue(Gtk.AccessibleRole);
7
8
 
@@ -71,7 +72,7 @@ const getRoles = (container: Container): Map<string, Gtk.Widget[]> => {
71
72
  };
72
73
 
73
74
  const formatWidgetPreview = (widget: Gtk.Widget, name: string | null): string => {
74
- const tagName = widget.constructor.name;
75
+ const tagName = getTypeTag(widget);
75
76
  const roleAttr = formatRole(widget.getAccessibleRole());
76
77
  const nameDisplay = name ? `Name "${name}"` : 'Name ""';
77
78
 
package/src/screenshot.ts CHANGED
@@ -6,8 +6,10 @@ import { mkdirSync, writeFileSync } from "node:fs";
6
6
  import { dirname } from "node:path";
7
7
  import type { ScreenshotOptions, ScreenshotResult } from "./types.js";
8
8
  import { getConfig } from "./config.js";
9
+ import { now } from "./timers.js";
9
10
  import { descendants } from "./traversal.js";
10
11
  import { waitFor } from "./wait-for.js";
12
+ import { activeToplevel, mappedToplevels } from "./window-state.js";
11
13
 
12
14
  type FrameProbe = {
13
15
  counter: bigint | null;
@@ -45,7 +47,7 @@ const OTHER_FAILURE_HINT =
45
47
 
46
48
  const NOTHING_ON_SCREEN_MESSAGE = "Nothing is on screen to capture: no toplevel window is mapped";
47
49
 
48
- const bytesToBase64 = (bytes: number[]): string => {
50
+ const bytesToBase64 = (bytes: Uint8Array | number[]): string => {
49
51
  return Buffer.from(bytes).toString("base64");
50
52
  };
51
53
 
@@ -106,7 +108,7 @@ const requestFrame = (widget: Gtk.Widget): void => {
106
108
  const startFrameProbe = (widget: Gtk.Widget): FrameProbe => {
107
109
  requestFrame(widget);
108
110
 
109
- return { counter: getFrameCounter(widget), startedAt: Date.now() };
111
+ return { counter: getFrameCounter(widget), startedAt: now() };
110
112
  };
111
113
 
112
114
  const updateFrameProbe = (widget: Gtk.Widget, probe: FrameProbe): void => {
@@ -120,7 +122,7 @@ const updateFrameProbe = (widget: Gtk.Widget, probe: FrameProbe): void => {
120
122
 
121
123
  if (counter !== null) {
122
124
  probe.counter = counter;
123
- probe.startedAt = Date.now();
125
+ probe.startedAt = now();
124
126
  }
125
127
  };
126
128
 
@@ -134,7 +136,7 @@ const isPresenting = (widget: Gtk.Widget, probe: FrameProbe): boolean =>
134
136
  isSurfaceOnScreen(widget) && hasFrameAdvanced(widget, probe);
135
137
 
136
138
  const isPresentationStalled = (widget: Gtk.Widget, probe: FrameProbe): boolean =>
137
- Date.now() - probe.startedAt >= PRESENTATION_PROBE_MS && !isPresenting(widget, probe);
139
+ now() - probe.startedAt >= PRESENTATION_PROBE_MS && !isPresenting(widget, probe);
138
140
 
139
141
  const allocateRoot = (widget: Gtk.Widget): void => {
140
142
  const root = widget.getRoot();
@@ -321,14 +323,8 @@ const screenshot = async (widget: Gtk.Widget, options?: ScreenshotOptions): Prom
321
323
  return outcome.result;
322
324
  };
323
325
 
324
- const isWindow = (widget: Gtk.Widget): widget is Gtk.Window => widget instanceof Gtk.Window;
325
- const isOnScreen = (window: Gtk.Window): boolean => window.getMapped();
326
- const isActiveWindow = (window: Gtk.Window): boolean => window.isActive();
327
-
328
- const activeToplevel = (): Gtk.Window => {
329
- const toplevels = Gtk.Window.listToplevels().filter((widget) => isWindow(widget));
330
- const onScreen = toplevels.filter((window) => isOnScreen(window));
331
- const target = onScreen.find((window) => isActiveWindow(window)) ?? onScreen[0];
326
+ const screenTarget = (): Gtk.Window => {
327
+ const target = activeToplevel() ?? mappedToplevels()[0];
332
328
 
333
329
  if (!target) {
334
330
  throw new Error(NOTHING_ON_SCREEN_MESSAGE);
@@ -337,10 +333,7 @@ const activeToplevel = (): Gtk.Window => {
337
333
  return target;
338
334
  };
339
335
 
340
- const captureScreen = async (options?: ScreenshotOptions): Promise<ScreenshotResult> => {
341
- const target = activeToplevel();
342
-
343
- return screenshot(target, options);
344
- };
336
+ const captureScreen = async (options?: ScreenshotOptions): Promise<ScreenshotResult> =>
337
+ screenshot(screenTarget(), options);
345
338
 
346
339
  export { captureScreen, screenshot };
package/src/timers.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { clearTimeout, setTimeout } from "node:timers";
3
+
4
+ type FakeClock = { tickAsync: (ms: number) => Promise<unknown> };
5
+ type TimeoutHandle = ReturnType<typeof setTimeout>;
6
+
7
+ const now = (): number => performance.now();
8
+ const scheduleTimeout = (callback: () => void, ms: number): TimeoutHandle => setTimeout(callback, ms);
9
+
10
+ const cancelTimeout = (handle: TimeoutHandle): void => {
11
+ clearTimeout(handle);
12
+ };
13
+
14
+ const delay = (ms: number): Promise<void> =>
15
+ new Promise((resolve) => {
16
+ scheduleTimeout(() => {
17
+ resolve();
18
+ }, ms);
19
+ });
20
+
21
+ const getFakeClock = (): FakeClock | null => {
22
+ const { clock } = globalThis.setTimeout as typeof globalThis.setTimeout & { clock?: FakeClock };
23
+
24
+ return clock ?? null;
25
+ };
26
+
27
+ const advanceFakeClock = async (ms: number): Promise<void> => {
28
+ await getFakeClock()?.tickAsync(ms);
29
+ };
30
+
31
+ export { advanceFakeClock, cancelTimeout, delay, now, scheduleTimeout };
package/src/traversal.ts CHANGED
@@ -11,6 +11,7 @@ type QueryContainer = Gtk.Widget | Gtk.EventController | Gtk.LayoutManager | Gtk
11
11
  * sentinel representing all current toplevel windows.
12
12
  */
13
13
  type Container = QueryContainer | Gtk.Application | typeof TOPLEVELS;
14
+ type WidgetClass<T extends Gtk.Widget> = abstract new (...args: never[]) => T;
14
15
 
15
16
  /** Container sentinel that widens a query to every toplevel window currently open. */
16
17
  const TOPLEVELS: unique symbol = Symbol("gtkx.toplevels");
@@ -50,6 +51,25 @@ const descendants = function* (widget: Gtk.Widget): Generator<Gtk.Widget> {
50
51
  yield* tree;
51
52
  };
52
53
 
54
+ const ancestors = function* (widget: Gtk.Widget): Generator<Gtk.Widget> {
55
+ let parent = widget.getParent();
56
+
57
+ while (parent) {
58
+ yield parent;
59
+ parent = parent.getParent();
60
+ }
61
+ };
62
+
63
+ const ancestorFor = <T extends Gtk.Widget>(widget: Gtk.Widget, type: WidgetClass<T>): T | null => {
64
+ for (const ancestor of ancestors(widget)) {
65
+ if (ancestor instanceof type) {
66
+ return ancestor;
67
+ }
68
+ }
69
+
70
+ return null;
71
+ };
72
+
53
73
  const relationCandidates = (widget: Gtk.Widget): Gtk.Accessible[] => {
54
74
  const pool: Gtk.Accessible[] = [...widget.listMnemonicLabels(), ...descendants(widget)];
55
75
  const root = widget.getRoot();
@@ -115,6 +135,8 @@ const findAll = (container: Container, isMatch: (node: Gtk.Widget) => boolean):
115
135
 
116
136
  export {
117
137
  TOPLEVELS,
138
+ ancestorFor,
139
+ ancestors,
118
140
  children,
119
141
  descendants,
120
142
  findAll,
package/src/types.ts CHANGED
@@ -169,7 +169,12 @@ type QueryFamilies<Head extends unknown[]> = QueryFamily<"Role", "role", Head> &
169
169
  * React behavior toggles, error callbacks, and custom queries to bind.
170
170
  */
171
171
  type RenderOptions<Q extends QueryMap = Record<never, never>> = {
172
- /** Widget or root element to mount into; an undecorated harness window is created when omitted. */
172
+ /**
173
+ * Widget or root element to mount into; an undecorated harness window is created when omitted.
174
+ * A widget container that sits outside every toplevel, such as a bare `Gtk.Box`, keeps the tree
175
+ * out of reach of a pointer and a keyboard, so every `userEvent` helper aimed at it rejects
176
+ * after `actionabilityTimeout`; `fireEvent` drives such a tree instead.
177
+ */
173
178
  container?: Gtk.Widget | RootElement | undefined;
174
179
  /** Root of the subtree that bound queries search. */
175
180
  baseElement?: Container | undefined;
@@ -1,5 +1,6 @@
1
1
  import * as Gtk from "@gtkx/gi/gtk";
2
2
  import { scheduleNextFrame } from "../frame-sync.js";
3
+ import { getTypeTag } from "../widget-getters.js";
3
4
  import { wrapEvent } from "./event-wrapper.js";
4
5
 
5
6
  /** A scroll distance in pixels along each axis. */
@@ -24,7 +25,7 @@ const slide = (widget: Gtk.Widget, value: number): Promise<void> =>
24
25
  wrapEvent(widget, () => {
25
26
  if (!(widget instanceof Gtk.Range)) {
26
27
  throw new TypeError(
27
- `userEvent.slide requires a Gtk.Range (e.g. Gtk.Scale), got ${widget.constructor.name}`,
28
+ `userEvent.slide requires a Gtk.Range (e.g. Gtk.Scale), got ${getTypeTag(widget)}`,
28
29
  );
29
30
  }
30
31
 
@@ -267,7 +267,8 @@ const tryActivate = async (widget: Gtk.Widget): Promise<boolean> => {
267
267
  * handler invokes: a list, grid, or column-view row is focused and selected, and activated as well
268
268
  * on a second press or when its view activates on a single click; an expandable tree expander
269
269
  * toggles its expansion, once per press, and only when it is the widget clicked, so a click on its
270
- * child falls through to the enclosing row; a column header sorts by its column. Sorting goes
270
+ * child falls through to the enclosing row; a notebook tab, clicked directly or through its label,
271
+ * focuses its notebook and switches to its page; a column header sorts by its column. Sorting goes
271
272
  * through Gtk.ColumnView.sortByColumn, so the primary sort column and order match what a pointer
272
273
  * produces while previously sorted columns are dropped rather than kept as secondary keys. A
273
274
  * column-view cell and the row that carries the column headers stand in the way of the click rather
@@ -287,14 +288,14 @@ const click = async (widget: Gtk.Widget): Promise<void> => {
287
288
  * a descendant, is activated and exclusively selected, as GTK's double-click path does whether or
288
289
  * not the container activates on a single click. A widget whose click GTK4 implements itself
289
290
  * receives that outcome once per press, so a list, grid, or column-view row is selected and then
290
- * activated by the second press, a tree expander ends back where it started, and a column header
291
- * sorts and then inverts its order.
291
+ * activated by the second press, a tree expander ends back where it started, a notebook tab stays on
292
+ * the page the first press opened, and a column header sorts and then inverts its order.
292
293
  */
293
294
  const dblClick = (widget: Gtk.Widget): Promise<void> => deliverClick(widget, 2);
294
295
  /**
295
296
  * Delivers a three-press click gesture the same way a double click is delivered, applying the same
296
297
  * outcome to a list box row or flow box child the presses reach, and applying the outcome GTK4
297
- * implements itself once per press to a row, tree expander, or column header.
298
+ * implements itself once per press to a row, tree expander, notebook tab, or column header.
298
299
  */
299
300
  const tripleClick = (widget: Gtk.Widget): Promise<void> => deliverClick(widget, 3);
300
301
 
@@ -1,21 +1,7 @@
1
1
  import * as Gtk from "@gtkx/gi/gtk";
2
- import { children } from "../traversal.js";
2
+ import { ancestorFor, children } from "../traversal.js";
3
3
  import { getWidgetTextContent } from "../widget-accessible-properties.js";
4
4
 
5
- const columnViewFor = (header: Gtk.Widget): Gtk.ColumnView | null => {
6
- let current: Gtk.Widget | null = header.getParent();
7
-
8
- while (current !== null) {
9
- if (current instanceof Gtk.ColumnView) {
10
- return current;
11
- }
12
-
13
- current = current.getParent();
14
- }
15
-
16
- return null;
17
- };
18
-
19
5
  const columnsFor = (view: Gtk.ColumnView): Gtk.ColumnViewColumn[] => {
20
6
  const model = view.getColumns();
21
7
  const columns: Gtk.ColumnViewColumn[] = [];
@@ -75,7 +61,7 @@ const nextSortDirection = (view: Gtk.ColumnView, column: Gtk.ColumnViewColumn):
75
61
  };
76
62
 
77
63
  const applyHeaderClick = (header: Gtk.Widget, nPress: number): void => {
78
- const view = columnViewFor(header);
64
+ const view = ancestorFor(header, Gtk.ColumnView);
79
65
  const column = view === null ? null : sortableColumnFor(header, view);
80
66
 
81
67
  if (view === null || column === null) {