@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.
- package/README.md +1 -1
- package/dist/act.d.ts +1 -3
- package/dist/act.d.ts.map +1 -1
- package/dist/act.js +1 -1
- package/dist/act.js.map +1 -1
- package/dist/build-queries.d.ts +1 -1
- package/dist/build-queries.js +1 -1
- package/dist/build-queries.js.map +1 -1
- package/dist/config.d.ts +8 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/frame-sync.d.ts +2 -2
- package/dist/frame-sync.d.ts.map +1 -1
- package/dist/frame-sync.js +10 -10
- package/dist/frame-sync.js.map +1 -1
- package/dist/harness-window.d.ts +2 -1
- package/dist/harness-window.d.ts.map +1 -1
- package/dist/harness-window.js +12 -1
- package/dist/harness-window.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/matchers.d.ts.map +1 -1
- package/dist/matchers.js +22 -2
- package/dist/matchers.js.map +1 -1
- package/dist/pretty-widget.d.ts.map +1 -1
- package/dist/pretty-widget.js +2 -1
- package/dist/pretty-widget.js.map +1 -1
- package/dist/production.d.ts.map +1 -1
- package/dist/production.js +2 -2
- package/dist/production.js.map +1 -1
- package/dist/render.d.ts +12 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +45 -11
- package/dist/render.js.map +1 -1
- package/dist/role-helpers.d.ts.map +1 -1
- package/dist/role-helpers.js +2 -1
- package/dist/role-helpers.js.map +1 -1
- package/dist/screenshot.d.ts.map +1 -1
- package/dist/screenshot.js +8 -14
- package/dist/screenshot.js.map +1 -1
- package/dist/timers.d.ts +9 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +21 -0
- package/dist/timers.js.map +1 -0
- package/dist/traversal.d.ts +4 -1
- package/dist/traversal.d.ts.map +1 -1
- package/dist/traversal.js +16 -1
- package/dist/traversal.js.map +1 -1
- package/dist/types.d.ts +6 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/user-event/adjustment.d.ts.map +1 -1
- package/dist/user-event/adjustment.js +2 -1
- package/dist/user-event/adjustment.js.map +1 -1
- package/dist/user-event/click.d.ts +5 -4
- package/dist/user-event/click.d.ts.map +1 -1
- package/dist/user-event/click.js +5 -4
- package/dist/user-event/click.js.map +1 -1
- package/dist/user-event/column-header-click.d.ts.map +1 -1
- package/dist/user-event/column-header-click.js +2 -12
- package/dist/user-event/column-header-click.js.map +1 -1
- package/dist/user-event/event-wrapper.d.ts.map +1 -1
- package/dist/user-event/event-wrapper.js +34 -21
- package/dist/user-event/event-wrapper.js.map +1 -1
- package/dist/user-event/index.d.ts +36 -10
- package/dist/user-event/index.d.ts.map +1 -1
- package/dist/user-event/index.js +4 -3
- package/dist/user-event/index.js.map +1 -1
- package/dist/user-event/keyboard.d.ts.map +1 -1
- package/dist/user-event/keyboard.js +93 -38
- package/dist/user-event/keyboard.js.map +1 -1
- package/dist/user-event/list-row-click.d.ts.map +1 -1
- package/dist/user-event/list-row-click.js +4 -5
- package/dist/user-event/list-row-click.js.map +1 -1
- package/dist/user-event/native-click.d.ts.map +1 -1
- package/dist/user-event/native-click.js +4 -0
- package/dist/user-event/native-click.js.map +1 -1
- package/dist/user-event/notebook-tab-click.d.ts +5 -0
- package/dist/user-event/notebook-tab-click.d.ts.map +1 -0
- package/dist/user-event/notebook-tab-click.js +39 -0
- package/dist/user-event/notebook-tab-click.js.map +1 -0
- package/dist/user-event/text.d.ts +6 -3
- package/dist/user-event/text.d.ts.map +1 -1
- package/dist/user-event/text.js +88 -33
- package/dist/user-event/text.js.map +1 -1
- package/dist/wait-for.d.ts.map +1 -1
- package/dist/wait-for.js +4 -3
- package/dist/wait-for.js.map +1 -1
- package/dist/widget-getters.d.ts +4 -1
- package/dist/widget-getters.d.ts.map +1 -1
- package/dist/widget-getters.js +29 -2
- package/dist/widget-getters.js.map +1 -1
- package/dist/window-state.d.ts +10 -0
- package/dist/window-state.d.ts.map +1 -0
- package/dist/window-state.js +41 -0
- package/dist/window-state.js.map +1 -0
- package/package.json +15 -7
- package/src/act.ts +1 -1
- package/src/build-queries.ts +1 -1
- package/src/config.ts +10 -1
- package/src/frame-sync.ts +15 -10
- package/src/harness-window.ts +15 -1
- package/src/internal.ts +1 -0
- package/src/matchers.ts +29 -2
- package/src/pretty-widget.ts +2 -1
- package/src/production.ts +2 -3
- package/src/render.tsx +67 -12
- package/src/role-helpers.ts +2 -1
- package/src/screenshot.ts +10 -17
- package/src/timers.ts +31 -0
- package/src/traversal.ts +22 -0
- package/src/types.ts +6 -1
- package/src/user-event/adjustment.ts +2 -1
- package/src/user-event/click.ts +5 -4
- package/src/user-event/column-header-click.ts +2 -16
- package/src/user-event/event-wrapper.ts +41 -25
- package/src/user-event/index.ts +40 -13
- package/src/user-event/keyboard.ts +124 -54
- package/src/user-event/list-row-click.ts +4 -7
- package/src/user-event/native-click.ts +5 -0
- package/src/user-event/notebook-tab-click.ts +57 -0
- package/src/user-event/text.ts +113 -38
- package/src/wait-for.ts +4 -4
- package/src/widget-getters.ts +55 -2
- 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 =
|
|
25
|
+
const fallback = scheduleTimeout(() => {
|
|
26
26
|
widget.removeTickCallback(tickId);
|
|
27
27
|
finish();
|
|
28
|
-
},
|
|
28
|
+
}, timeout);
|
|
29
29
|
|
|
30
30
|
tickId = widget.addTickCallback(() => {
|
|
31
31
|
if (!isReady()) {
|
|
32
32
|
return GLib.SOURCE_CONTINUE;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
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
|
|
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(
|
|
63
|
+
if (!hasFrameClock(window) || isReady(window)) {
|
|
59
64
|
queueMicrotask(finish);
|
|
60
65
|
|
|
61
66
|
return;
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
runUntilReady(
|
|
69
|
+
runUntilReady(window, () => isReady(window), timeout, finish);
|
|
65
70
|
};
|
|
66
71
|
|
|
67
|
-
export {
|
|
72
|
+
export { scheduleNextFrame, scheduleWhenWindowReady };
|
package/src/harness-window.ts
CHANGED
|
@@ -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
|
-
|
|
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
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
|
|
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
|
|
930
|
+
return focusResult(widget, widget.getPlatformState(Gtk.AccessiblePlatformState.FOCUSED));
|
|
904
931
|
}
|
|
905
932
|
|
|
906
933
|
function toHaveRole(received: unknown, expected: Gtk.AccessibleRole): MatcherResult {
|
package/src/pretty-widget.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
16
|
-
import {
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
198
|
-
await
|
|
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
|
|
265
|
+
await settleRender(resolved, options?.container, "rerender");
|
|
211
266
|
},
|
|
212
267
|
debug: (element: Container | Container[] = baseElement, debugOptions?: PrettyWidgetOptions) => {
|
|
213
268
|
logWidget(element, debugOptions);
|
package/src/role-helpers.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
325
|
-
const
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
28
|
+
`userEvent.slide requires a Gtk.Range (e.g. Gtk.Scale), got ${getTypeTag(widget)}`,
|
|
28
29
|
);
|
|
29
30
|
}
|
|
30
31
|
|
package/src/user-event/click.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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 =
|
|
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) {
|