@gtkx/testing 1.0.0-rc.4 → 1.1.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 +5 -5
- package/dist/accessible-native.d.ts +16 -0
- package/dist/accessible-native.d.ts.map +1 -0
- package/dist/accessible-native.js +206 -0
- package/dist/accessible-native.js.map +1 -0
- 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 +27 -10
- package/dist/build-queries.d.ts.map +1 -1
- package/dist/build-queries.js +22 -24
- package/dist/build-queries.js.map +1 -1
- package/dist/cleanup-registry.d.ts +1 -1
- package/dist/cleanup-registry.d.ts.map +1 -1
- package/dist/cleanup-registry.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/errors.d.ts +10 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -1
- package/dist/errors.js.map +1 -1
- package/dist/fire-event.d.ts +35 -6
- package/dist/fire-event.d.ts.map +1 -1
- package/dist/fire-event.js +23 -7
- package/dist/fire-event.js.map +1 -1
- package/dist/frame-sync.d.ts +3 -2
- package/dist/frame-sync.d.ts.map +1 -1
- package/dist/frame-sync.js +19 -9
- package/dist/frame-sync.js.map +1 -1
- package/dist/harness-window.d.ts +5 -0
- package/dist/harness-window.d.ts.map +1 -0
- package/dist/harness-window.js +17 -0
- package/dist/harness-window.js.map +1 -0
- package/dist/index.d.ts +14 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -5
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +5 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +5 -0
- package/dist/internal.js.map +1 -0
- package/dist/matchers.d.ts +88 -46
- package/dist/matchers.d.ts.map +1 -1
- package/dist/matchers.js +178 -20
- package/dist/matchers.js.map +1 -1
- package/dist/pretty-format.d.ts +2 -0
- package/dist/pretty-format.d.ts.map +1 -0
- package/dist/pretty-format.js +2 -0
- package/dist/pretty-format.js.map +1 -0
- package/dist/pretty-widget.d.ts +5 -2
- package/dist/pretty-widget.d.ts.map +1 -1
- package/dist/pretty-widget.js +75 -57
- package/dist/pretty-widget.js.map +1 -1
- package/dist/production.d.ts +8 -0
- package/dist/production.d.ts.map +1 -0
- package/dist/production.js +34 -0
- package/dist/production.js.map +1 -0
- package/dist/queries.d.ts +98 -56
- package/dist/queries.d.ts.map +1 -1
- package/dist/queries.js +123 -83
- package/dist/queries.js.map +1 -1
- package/dist/query-helpers.d.ts +44 -0
- package/dist/query-helpers.d.ts.map +1 -0
- package/dist/query-helpers.js +57 -0
- package/dist/query-helpers.js.map +1 -0
- package/dist/render-children.d.ts +9 -0
- package/dist/render-children.d.ts.map +1 -0
- package/dist/render-children.js +11 -0
- package/dist/render-children.js.map +1 -0
- package/dist/render.d.ts +12 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +52 -16
- package/dist/render.js.map +1 -1
- package/dist/role-helpers.d.ts +12 -3
- package/dist/role-helpers.d.ts.map +1 -1
- package/dist/role-helpers.js +15 -5
- package/dist/role-helpers.js.map +1 -1
- package/dist/role-naming.d.ts +5 -0
- package/dist/role-naming.d.ts.map +1 -0
- package/dist/role-naming.js +96 -0
- package/dist/role-naming.js.map +1 -0
- package/dist/screenshot.d.ts +12 -20
- package/dist/screenshot.d.ts.map +1 -1
- package/dist/screenshot.js +179 -93
- 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 +7 -1
- package/dist/traversal.d.ts.map +1 -1
- package/dist/traversal.js +40 -6
- package/dist/traversal.js.map +1 -1
- package/dist/types.d.ts +32 -15
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/user-event/adjustment.d.ts +3 -0
- package/dist/user-event/adjustment.d.ts.map +1 -1
- package/dist/user-event/adjustment.js +25 -3
- package/dist/user-event/adjustment.js.map +1 -1
- package/dist/user-event/click.d.ts +44 -8
- package/dist/user-event/click.d.ts.map +1 -1
- package/dist/user-event/click.js +176 -44
- package/dist/user-event/click.js.map +1 -1
- package/dist/user-event/column-header-click.d.ts +4 -0
- package/dist/user-event/column-header-click.d.ts.map +1 -0
- package/dist/user-event/column-header-click.js +55 -0
- package/dist/user-event/column-header-click.js.map +1 -0
- package/dist/user-event/event-wrapper.d.ts.map +1 -1
- package/dist/user-event/event-wrapper.js +34 -20
- package/dist/user-event/event-wrapper.js.map +1 -1
- package/dist/user-event/index.d.ts +80 -13
- package/dist/user-event/index.d.ts.map +1 -1
- package/dist/user-event/index.js +44 -26
- package/dist/user-event/index.js.map +1 -1
- package/dist/user-event/indexed-children.d.ts +10 -0
- package/dist/user-event/indexed-children.d.ts.map +1 -0
- package/dist/user-event/indexed-children.js +50 -0
- package/dist/user-event/indexed-children.js.map +1 -0
- package/dist/user-event/keyboard.d.ts.map +1 -1
- package/dist/user-event/keyboard.js +115 -31
- package/dist/user-event/keyboard.js.map +1 -1
- package/dist/user-event/list-row-click.d.ts +4 -0
- package/dist/user-event/list-row-click.d.ts.map +1 -0
- package/dist/user-event/list-row-click.js +38 -0
- package/dist/user-event/list-row-click.js.map +1 -0
- package/dist/user-event/native-click.d.ts +6 -0
- package/dist/user-event/native-click.d.ts.map +1 -0
- package/dist/user-event/native-click.js +36 -0
- package/dist/user-event/native-click.js.map +1 -0
- 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/pointer.d.ts +1 -1
- package/dist/user-event/pointer.d.ts.map +1 -1
- package/dist/user-event/pointer.js +33 -9
- package/dist/user-event/pointer.js.map +1 -1
- package/dist/user-event/selection.d.ts +11 -8
- package/dist/user-event/selection.d.ts.map +1 -1
- package/dist/user-event/selection.js +49 -38
- package/dist/user-event/selection.js.map +1 -1
- package/dist/user-event/text.d.ts +12 -5
- package/dist/user-event/text.d.ts.map +1 -1
- package/dist/user-event/text.js +151 -46
- 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-accessible-properties.d.ts +14 -26
- package/dist/widget-accessible-properties.d.ts.map +1 -1
- package/dist/widget-accessible-properties.js +135 -190
- package/dist/widget-accessible-properties.js.map +1 -1
- package/dist/widget-getters.d.ts +11 -0
- package/dist/widget-getters.d.ts.map +1 -0
- package/dist/widget-getters.js +46 -0
- package/dist/widget-getters.js.map +1 -0
- 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/dist/within.d.ts +3 -2
- package/dist/within.d.ts.map +1 -1
- package/dist/within.js +3 -2
- package/dist/within.js.map +1 -1
- package/package.json +21 -8
- package/src/accessible-native.ts +333 -0
- package/src/act.ts +1 -1
- package/src/build-queries.ts +47 -41
- package/src/cleanup-registry.ts +1 -1
- package/src/config.ts +10 -1
- package/src/errors.ts +12 -1
- package/src/fire-event.ts +61 -8
- package/src/frame-sync.ts +30 -9
- package/src/harness-window.ts +23 -0
- package/src/index.ts +18 -11
- package/src/internal.ts +12 -0
- package/src/matchers.ts +457 -72
- package/src/pretty-format.ts +1 -0
- package/src/pretty-widget.ts +98 -70
- package/src/production.ts +51 -0
- package/src/queries.ts +127 -89
- package/src/query-helpers.ts +97 -0
- package/src/render-children.ts +25 -0
- package/src/render.tsx +77 -17
- package/src/role-helpers.ts +16 -5
- package/src/role-naming.ts +99 -0
- package/src/screenshot.ts +266 -104
- package/src/timers.ts +31 -0
- package/src/traversal.ts +66 -6
- package/src/types.ts +34 -17
- package/src/user-event/adjustment.ts +31 -3
- package/src/user-event/click.ts +248 -52
- package/src/user-event/column-header-click.ts +76 -0
- package/src/user-event/event-wrapper.ts +41 -23
- package/src/user-event/index.ts +130 -40
- package/src/user-event/indexed-children.ts +75 -0
- package/src/user-event/keyboard.ts +161 -43
- package/src/user-event/list-row-click.ts +54 -0
- package/src/user-event/native-click.ts +51 -0
- package/src/user-event/notebook-tab-click.ts +57 -0
- package/src/user-event/pointer.ts +48 -9
- package/src/user-event/selection.ts +67 -41
- package/src/user-event/text.ts +192 -46
- package/src/wait-for.ts +4 -4
- package/src/widget-accessible-properties.ts +183 -230
- package/src/widget-getters.ts +85 -0
- package/src/window-state.ts +70 -0
- package/src/within.ts +3 -2
package/src/screenshot.ts
CHANGED
|
@@ -1,12 +1,51 @@
|
|
|
1
|
+
import * as Gdk from "@gtkx/gi/gdk";
|
|
2
|
+
import * as Graphene from "@gtkx/gi/graphene";
|
|
1
3
|
import * as Gsk from "@gtkx/gi/gsk";
|
|
2
4
|
import * as Gtk from "@gtkx/gi/gtk";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import type { ScreenshotOptions, ScreenshotResult } from "./types.js";
|
|
8
|
+
import { getConfig } from "./config.js";
|
|
9
|
+
import { now } from "./timers.js";
|
|
10
|
+
import { descendants } from "./traversal.js";
|
|
7
11
|
import { waitFor } from "./wait-for.js";
|
|
12
|
+
import { activeToplevel, mappedToplevels } from "./window-state.js";
|
|
13
|
+
|
|
14
|
+
type FrameProbe = {
|
|
15
|
+
counter: bigint | null;
|
|
16
|
+
startedAt: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type CaptureFailure = "no-size" | "no-content";
|
|
20
|
+
|
|
21
|
+
type CaptureState = {
|
|
22
|
+
probe: FrameProbe;
|
|
23
|
+
failure: CaptureFailure | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type CaptureResult =
|
|
27
|
+
{ status: "captured"; result: ScreenshotResult } |
|
|
28
|
+
{ status: "failed"; failure: CaptureFailure };
|
|
29
|
+
|
|
30
|
+
type CaptureOutcome =
|
|
31
|
+
{ status: "captured"; result: ScreenshotResult } |
|
|
32
|
+
{ status: "stalled"; failure: CaptureFailure };
|
|
8
33
|
|
|
9
34
|
const DEFAULT_SCREENSHOT_INTERVAL = 10;
|
|
35
|
+
const PRESENTATION_PROBE_MS = 250;
|
|
36
|
+
|
|
37
|
+
const NOT_PRESENTING_MESSAGE =
|
|
38
|
+
"Screenshot failed: the display is not presenting frames to this window, so GTK has no rendered " +
|
|
39
|
+
"content to capture. The window is hidden, minimized, or its compositor is not presenting it; this " +
|
|
40
|
+
"is a display problem, not a widget problem.";
|
|
41
|
+
|
|
42
|
+
const EMPTY_WIDGET_HINT =
|
|
43
|
+
"The display is presenting frames to this window, so the widget itself is empty: it painted nothing.";
|
|
44
|
+
|
|
45
|
+
const OTHER_FAILURE_HINT =
|
|
46
|
+
"The display is presenting frames to this window, so the capture failed for another reason.";
|
|
47
|
+
|
|
48
|
+
const NOTHING_ON_SCREEN_MESSAGE = "Nothing is on screen to capture: no toplevel window is mapped";
|
|
10
49
|
|
|
11
50
|
const bytesToBase64 = (bytes: number[]): string => {
|
|
12
51
|
return Buffer.from(bytes).toString("base64");
|
|
@@ -16,162 +55,285 @@ const describeWidgetState = (widget: Gtk.Widget): string =>
|
|
|
16
55
|
`realized=${String(widget.getRealized())} mapped=${String(widget.getMapped())} ` +
|
|
17
56
|
`visible=${String(widget.getVisible())}`;
|
|
18
57
|
|
|
19
|
-
const
|
|
20
|
-
const
|
|
58
|
+
const getMappedPopovers = (widget: Gtk.Widget): Gtk.Popover[] => {
|
|
59
|
+
const popovers: Gtk.Popover[] = [];
|
|
60
|
+
|
|
61
|
+
for (const descendant of descendants(widget)) {
|
|
62
|
+
if (descendant instanceof Gtk.Popover && descendant.getMapped()) {
|
|
63
|
+
popovers.push(descendant);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return popovers;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const appendPopover = (snapshot: Gtk.Snapshot, target: Gtk.Widget, popover: Gtk.Popover): void => {
|
|
71
|
+
const [hasBounds, bounds] = popover.computeBounds(target);
|
|
72
|
+
const paintable = new Gtk.WidgetPaintable({ widget: popover });
|
|
21
73
|
const width = paintable.getIntrinsicWidth();
|
|
22
74
|
const height = paintable.getIntrinsicHeight();
|
|
23
75
|
|
|
24
|
-
if (width <= 0 || height <= 0) {
|
|
25
|
-
|
|
76
|
+
if (!hasBounds || width <= 0 || height <= 0) {
|
|
77
|
+
return;
|
|
26
78
|
}
|
|
27
79
|
|
|
28
|
-
|
|
29
|
-
snapshot.
|
|
80
|
+
snapshot.save();
|
|
81
|
+
snapshot.translate(new Graphene.Point({ x: bounds.origin.x, y: bounds.origin.y }));
|
|
30
82
|
paintable.snapshot(snapshot, width, height);
|
|
31
|
-
|
|
83
|
+
snapshot.restore();
|
|
84
|
+
};
|
|
32
85
|
|
|
33
|
-
|
|
34
|
-
|
|
86
|
+
const appendPopovers = (snapshot: Gtk.Snapshot, target: Gtk.Widget): void => {
|
|
87
|
+
for (const popover of getMappedPopovers(target)) {
|
|
88
|
+
appendPopover(snapshot, target, popover);
|
|
35
89
|
}
|
|
90
|
+
};
|
|
36
91
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
renderer.realizeForDisplay(display);
|
|
92
|
+
const getSurface = (widget: Gtk.Widget): Gdk.Surface | null => widget.getNative()?.getSurface() ?? null;
|
|
93
|
+
const isSurfaceOnScreen = (widget: Gtk.Widget): boolean => getSurface(widget)?.getMapped() ?? false;
|
|
40
94
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const pngBytes = texture.saveToPngBytes();
|
|
44
|
-
const data = pngBytes.getData();
|
|
95
|
+
const getFrameClock = (widget: Gtk.Widget): Gdk.FrameClock | null =>
|
|
96
|
+
widget.getRoot()?.getFrameClock() ?? widget.getFrameClock();
|
|
45
97
|
|
|
46
|
-
|
|
47
|
-
throw new Error("Failed to serialize screenshot to PNG");
|
|
48
|
-
}
|
|
98
|
+
const getFrameCounter = (widget: Gtk.Widget): bigint | null => getFrameClock(widget)?.getFrameCounter() ?? null;
|
|
49
99
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
100
|
+
const describePresentation = (widget: Gtk.Widget): string =>
|
|
101
|
+
`${describeWidgetState(widget)} surfaceMapped=${String(isSurfaceOnScreen(widget))} ` +
|
|
102
|
+
`frameCounter=${String(getFrameCounter(widget))}`;
|
|
103
|
+
|
|
104
|
+
const requestFrame = (widget: Gtk.Widget): void => {
|
|
105
|
+
getFrameClock(widget)?.requestPhase(Gdk.FrameClockPhase.UPDATE);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const startFrameProbe = (widget: Gtk.Widget): FrameProbe => {
|
|
109
|
+
requestFrame(widget);
|
|
110
|
+
|
|
111
|
+
return { counter: getFrameCounter(widget), startedAt: now() };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const updateFrameProbe = (widget: Gtk.Widget, probe: FrameProbe): void => {
|
|
115
|
+
requestFrame(widget);
|
|
116
|
+
|
|
117
|
+
if (probe.counter !== null) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const counter = getFrameCounter(widget);
|
|
122
|
+
|
|
123
|
+
if (counter !== null) {
|
|
124
|
+
probe.counter = counter;
|
|
125
|
+
probe.startedAt = now();
|
|
58
126
|
}
|
|
59
127
|
};
|
|
60
128
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
*
|
|
64
|
-
* @param widget The widget to render to an image.
|
|
65
|
-
* @param options Optional scale, timeout, and retry interval.
|
|
66
|
-
* @returns The base64-encoded PNG data along with its mime type and dimensions.
|
|
67
|
-
*/
|
|
68
|
-
const screenshot = async (widget: Gtk.Widget, options?: ScreenshotOptions): Promise<ScreenshotResult> => {
|
|
69
|
-
const scale = options?.scale ?? 1;
|
|
129
|
+
const hasFrameAdvanced = (widget: Gtk.Widget, probe: FrameProbe): boolean => {
|
|
130
|
+
const counter = getFrameCounter(widget);
|
|
70
131
|
|
|
71
|
-
|
|
72
|
-
|
|
132
|
+
return counter !== null && probe.counter !== null && counter > probe.counter;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const isPresenting = (widget: Gtk.Widget, probe: FrameProbe): boolean =>
|
|
136
|
+
isSurfaceOnScreen(widget) && hasFrameAdvanced(widget, probe);
|
|
137
|
+
|
|
138
|
+
const isPresentationStalled = (widget: Gtk.Widget, probe: FrameProbe): boolean =>
|
|
139
|
+
now() - probe.startedAt >= PRESENTATION_PROBE_MS && !isPresenting(widget, probe);
|
|
140
|
+
|
|
141
|
+
const allocateRoot = (widget: Gtk.Widget): void => {
|
|
142
|
+
const root = widget.getRoot();
|
|
143
|
+
|
|
144
|
+
if (!root) {
|
|
145
|
+
return;
|
|
73
146
|
}
|
|
74
147
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
148
|
+
const width = root.getWidth();
|
|
149
|
+
const height = root.getHeight();
|
|
150
|
+
|
|
151
|
+
if (width > 0 && height > 0) {
|
|
152
|
+
root.allocate(width, height, -1, null);
|
|
153
|
+
}
|
|
79
154
|
};
|
|
80
155
|
|
|
81
|
-
const
|
|
82
|
-
|
|
156
|
+
const isDirectlyPaintable = (child: Gtk.Widget): boolean =>
|
|
157
|
+
child.getMapped() && child.getWidth() > 0 && !(child instanceof Gtk.Popover);
|
|
83
158
|
|
|
84
|
-
|
|
85
|
-
|
|
159
|
+
const getPaintableNode = (widget: Gtk.Widget, scale: number, width: number, height: number): Gsk.RenderNode | null => {
|
|
160
|
+
const paintable = new Gtk.WidgetPaintable({ widget });
|
|
161
|
+
const snapshot = new Gtk.Snapshot();
|
|
162
|
+
snapshot.scale(scale, scale);
|
|
163
|
+
paintable.snapshot(snapshot, width, height);
|
|
164
|
+
appendPopovers(snapshot, widget);
|
|
165
|
+
|
|
166
|
+
return snapshot.toNode();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const getChildrenNode = (widget: Gtk.Widget, scale: number): Gsk.RenderNode | null => {
|
|
170
|
+
const snapshot = new Gtk.Snapshot();
|
|
171
|
+
snapshot.scale(scale, scale);
|
|
172
|
+
|
|
173
|
+
for (let child = widget.getFirstChild(); child !== null; child = child.getNextSibling()) {
|
|
174
|
+
if (isDirectlyPaintable(child)) {
|
|
175
|
+
widget.snapshotChild(child, snapshot);
|
|
176
|
+
}
|
|
86
177
|
}
|
|
87
178
|
|
|
88
|
-
|
|
179
|
+
appendPopovers(snapshot, widget);
|
|
180
|
+
|
|
181
|
+
return snapshot.toNode();
|
|
89
182
|
};
|
|
90
183
|
|
|
91
|
-
const
|
|
92
|
-
const
|
|
184
|
+
const getRenderNode = (widget: Gtk.Widget, scale: number, width: number, height: number): Gsk.RenderNode | null => {
|
|
185
|
+
const presented = getPaintableNode(widget, scale, width, height);
|
|
93
186
|
|
|
94
|
-
if (
|
|
95
|
-
|
|
187
|
+
if (presented) {
|
|
188
|
+
return presented;
|
|
96
189
|
}
|
|
97
190
|
|
|
98
|
-
|
|
191
|
+
allocateRoot(widget);
|
|
192
|
+
|
|
193
|
+
return getPaintableNode(widget, scale, width, height) ?? getChildrenNode(widget, scale);
|
|
99
194
|
};
|
|
100
195
|
|
|
101
|
-
const
|
|
196
|
+
const renderToPng = (widget: Gtk.Widget, node: Gsk.RenderNode, width: number, height: number): ScreenshotResult => {
|
|
197
|
+
const renderer = new Gsk.CairoRenderer();
|
|
198
|
+
renderer.realizeForDisplay(widget.getDisplay());
|
|
102
199
|
|
|
103
|
-
|
|
104
|
-
|
|
200
|
+
try {
|
|
201
|
+
const viewport = Graphene.Rect.alloc().init(0, 0, width, height);
|
|
202
|
+
const texture = renderer.renderTexture(node, viewport);
|
|
203
|
+
const data = texture.saveToPngBytes().getData();
|
|
105
204
|
|
|
106
|
-
|
|
205
|
+
if (!data) {
|
|
206
|
+
throw new Error("Failed to serialize screenshot to PNG");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { data: bytesToBase64(data), mimeType: "image/png", width, height };
|
|
210
|
+
} finally {
|
|
211
|
+
renderer.unrealize();
|
|
212
|
+
}
|
|
107
213
|
};
|
|
108
214
|
|
|
109
|
-
const
|
|
110
|
-
|
|
215
|
+
const captureSnapshot = (widget: Gtk.Widget, scale: number): CaptureResult => {
|
|
216
|
+
const paintable = new Gtk.WidgetPaintable({ widget });
|
|
217
|
+
const width = paintable.getIntrinsicWidth();
|
|
218
|
+
const height = paintable.getIntrinsicHeight();
|
|
111
219
|
|
|
112
|
-
|
|
113
|
-
|
|
220
|
+
if (width <= 0 || height <= 0) {
|
|
221
|
+
return { status: "failed", failure: "no-size" };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const renderNode = getRenderNode(widget, scale, width, height);
|
|
114
225
|
|
|
115
|
-
if (!
|
|
116
|
-
|
|
226
|
+
if (!renderNode) {
|
|
227
|
+
return { status: "failed", failure: "no-content" };
|
|
117
228
|
}
|
|
118
229
|
|
|
119
|
-
|
|
230
|
+
const scaledWidth = Math.round(width * scale);
|
|
231
|
+
const scaledHeight = Math.round(height * scale);
|
|
232
|
+
|
|
233
|
+
return { status: "captured", result: renderToPng(widget, renderNode, scaledWidth, scaledHeight) };
|
|
120
234
|
};
|
|
121
235
|
|
|
122
|
-
const
|
|
123
|
-
|
|
236
|
+
const failureMessage = (widget: Gtk.Widget, failure: CaptureFailure): string =>
|
|
237
|
+
failure === "no-size"
|
|
238
|
+
? `Widget has no size: ensure it is realized and visible (${describeWidgetState(widget)})`
|
|
239
|
+
: `Widget produced no render content (${describeWidgetState(widget)})`;
|
|
124
240
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
241
|
+
const notPresentingMessage = (widget: Gtk.Widget): string =>
|
|
242
|
+
`${NOT_PRESENTING_MESSAGE} (${describePresentation(widget)})`;
|
|
243
|
+
|
|
244
|
+
const stalledMessage = (widget: Gtk.Widget, failure: CaptureFailure): string =>
|
|
245
|
+
`${notPresentingMessage(widget)}\n\n${failureMessage(widget, failure)}`;
|
|
246
|
+
|
|
247
|
+
const presentingHint = (failure: CaptureFailure | null): string =>
|
|
248
|
+
failure === "no-content" ? EMPTY_WIDGET_HINT : OTHER_FAILURE_HINT;
|
|
128
249
|
|
|
129
|
-
|
|
130
|
-
|
|
250
|
+
const timeoutHint = (widget: Gtk.Widget, state: CaptureState): string =>
|
|
251
|
+
isPresenting(widget, state.probe) ? presentingHint(state.failure) : notPresentingMessage(widget);
|
|
252
|
+
|
|
253
|
+
const explainTimeout = (error: Error, widget: Gtk.Widget, state: CaptureState): Error =>
|
|
254
|
+
getConfig().getElementError(`${error.message}\n\n${timeoutHint(widget, state)}`);
|
|
255
|
+
|
|
256
|
+
const attemptCapture = (widget: Gtk.Widget, scale: number, state: CaptureState): CaptureOutcome => {
|
|
257
|
+
const attempt = captureSnapshot(widget, scale);
|
|
258
|
+
|
|
259
|
+
if (attempt.status === "captured") {
|
|
260
|
+
return attempt;
|
|
131
261
|
}
|
|
132
262
|
|
|
133
|
-
|
|
134
|
-
|
|
263
|
+
state.failure = attempt.failure;
|
|
264
|
+
updateFrameProbe(widget, state.probe);
|
|
265
|
+
|
|
266
|
+
if (isPresentationStalled(widget, state.probe)) {
|
|
267
|
+
return { status: "stalled", failure: attempt.failure };
|
|
135
268
|
}
|
|
136
269
|
|
|
137
|
-
|
|
270
|
+
throw new Error(failureMessage(widget, attempt.failure));
|
|
138
271
|
};
|
|
139
272
|
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
writeFileSync(filepath, Buffer.from(result.data, "base64"));
|
|
144
|
-
|
|
145
|
-
return filepath;
|
|
273
|
+
const writeScreenshot = (result: ScreenshotResult, path: string): void => {
|
|
274
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
275
|
+
writeFileSync(path, Buffer.from(result.data, "base64"));
|
|
146
276
|
};
|
|
147
277
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
278
|
+
const captureUntilPaintable = (
|
|
279
|
+
widget: Gtk.Widget,
|
|
280
|
+
scale: number,
|
|
281
|
+
options: ScreenshotOptions | undefined,
|
|
282
|
+
): Promise<CaptureOutcome> => {
|
|
283
|
+
const state: CaptureState = { probe: startFrameProbe(widget), failure: null };
|
|
284
|
+
|
|
285
|
+
return waitFor(() => attemptCapture(widget, scale, state), {
|
|
286
|
+
timeout: options?.timeout,
|
|
287
|
+
interval: options?.interval ?? DEFAULT_SCREENSHOT_INTERVAL,
|
|
288
|
+
onTimeout: (error) => explainTimeout(error, widget, state),
|
|
289
|
+
});
|
|
155
290
|
};
|
|
156
291
|
|
|
157
292
|
/**
|
|
158
|
-
* Captures a
|
|
159
|
-
*
|
|
293
|
+
* Captures a PNG snapshot of a widget, retrying until it has a paintable size,
|
|
294
|
+
* and writes the image to `options.path` when one is given, creating any
|
|
295
|
+
* missing parent directories. Popovers open inside the widget render on their
|
|
296
|
+
* own surfaces, so they are composited into the image at their on-screen
|
|
297
|
+
* positions. A display that never presents a frame leaves GTK without a cached
|
|
298
|
+
* render node, so the widget's contents are then snapshotted directly; when
|
|
299
|
+
* even that yields nothing, the failure names the display instead of blaming
|
|
300
|
+
* the widget.
|
|
160
301
|
*
|
|
161
|
-
* @param
|
|
162
|
-
*
|
|
163
|
-
* @param options Optional scale, timeout, and retry interval.
|
|
302
|
+
* @param widget The widget to render to an image.
|
|
303
|
+
* @param options Optional scale, timeout, retry interval, and output path.
|
|
164
304
|
* @returns The base64-encoded PNG data along with its mime type and dimensions.
|
|
165
305
|
*/
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
options?: ScreenshotOptions,
|
|
169
|
-
): Promise<ScreenshotResult> => {
|
|
170
|
-
const target = resolveWindow(selector);
|
|
171
|
-
const result = await screenshot(target, options);
|
|
172
|
-
logScreenshotPath(saveScreenshotToTempFile(result));
|
|
306
|
+
const screenshot = async (widget: Gtk.Widget, options?: ScreenshotOptions): Promise<ScreenshotResult> => {
|
|
307
|
+
const scale = options?.scale ?? 1;
|
|
173
308
|
|
|
174
|
-
|
|
309
|
+
if (!Number.isFinite(scale) || scale <= 0) {
|
|
310
|
+
throw new Error(`Screenshot scale must be a positive number, got ${String(scale)}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const outcome = await captureUntilPaintable(widget, scale, options);
|
|
314
|
+
|
|
315
|
+
if (outcome.status === "stalled") {
|
|
316
|
+
throw new Error(stalledMessage(widget, outcome.failure));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (options?.path) {
|
|
320
|
+
writeScreenshot(outcome.result, options.path);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return outcome.result;
|
|
175
324
|
};
|
|
176
325
|
|
|
177
|
-
|
|
326
|
+
const screenTarget = (): Gtk.Window => {
|
|
327
|
+
const target = activeToplevel() ?? mappedToplevels()[0];
|
|
328
|
+
|
|
329
|
+
if (!target) {
|
|
330
|
+
throw new Error(NOTHING_ON_SCREEN_MESSAGE);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return target;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const captureScreen = async (options?: ScreenshotOptions): Promise<ScreenshotResult> =>
|
|
337
|
+
screenshot(screenTarget(), options);
|
|
338
|
+
|
|
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,28 +11,76 @@ 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");
|
|
17
18
|
|
|
18
19
|
const isApplication = (container: Container): container is Gtk.Application => container instanceof Gtk.Application;
|
|
20
|
+
const isAnyWidget = (): boolean => true;
|
|
21
|
+
const isOnScreen = (widget: Gtk.Widget): boolean => widget.getMapped();
|
|
22
|
+
|
|
23
|
+
const traverseWidgetTree = function* (
|
|
24
|
+
root: Gtk.Widget,
|
|
25
|
+
isIncluded: (widget: Gtk.Widget) => boolean,
|
|
26
|
+
): Generator<Gtk.Widget> {
|
|
27
|
+
if (isIncluded(root)) {
|
|
28
|
+
yield root;
|
|
29
|
+
}
|
|
19
30
|
|
|
20
|
-
const traverseWidgetTree = function* (root: Gtk.Widget): Generator<Gtk.Widget> {
|
|
21
|
-
yield root;
|
|
22
31
|
let child = root.getFirstChild();
|
|
23
32
|
|
|
24
33
|
while (child) {
|
|
25
|
-
yield* traverseWidgetTree(child);
|
|
34
|
+
yield* traverseWidgetTree(child, isIncluded);
|
|
35
|
+
child = child.getNextSibling();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const children = function* (widget: Gtk.Widget): Generator<Gtk.Widget> {
|
|
40
|
+
let child = widget.getFirstChild();
|
|
41
|
+
|
|
42
|
+
while (child) {
|
|
43
|
+
yield child;
|
|
26
44
|
child = child.getNextSibling();
|
|
27
45
|
}
|
|
28
46
|
};
|
|
29
47
|
|
|
30
48
|
const descendants = function* (widget: Gtk.Widget): Generator<Gtk.Widget> {
|
|
31
|
-
const tree = traverseWidgetTree(widget);
|
|
49
|
+
const tree = traverseWidgetTree(widget, isAnyWidget);
|
|
32
50
|
tree.next();
|
|
33
51
|
yield* tree;
|
|
34
52
|
};
|
|
35
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
|
+
|
|
73
|
+
const relationCandidates = (widget: Gtk.Widget): Gtk.Accessible[] => {
|
|
74
|
+
const pool: Gtk.Accessible[] = [...widget.listMnemonicLabels(), ...descendants(widget)];
|
|
75
|
+
const root = widget.getRoot();
|
|
76
|
+
|
|
77
|
+
if (root instanceof Gtk.Widget) {
|
|
78
|
+
pool.push(...traverseWidgetTree(root, isAnyWidget));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return [...new Set(pool)];
|
|
82
|
+
};
|
|
83
|
+
|
|
36
84
|
const resolveRoot = (container: QueryContainer): Gtk.Widget | null => {
|
|
37
85
|
if (container instanceof Gtk.Widget) {
|
|
38
86
|
return container;
|
|
@@ -69,7 +117,7 @@ const roots = function* (container: Container): Generator<Gtk.Widget> {
|
|
|
69
117
|
|
|
70
118
|
const traverse = function* (container: Container): Generator<Gtk.Widget> {
|
|
71
119
|
for (const root of roots(container)) {
|
|
72
|
-
yield* traverseWidgetTree(root);
|
|
120
|
+
yield* traverseWidgetTree(root, isOnScreen);
|
|
73
121
|
}
|
|
74
122
|
};
|
|
75
123
|
|
|
@@ -85,4 +133,16 @@ const findAll = (container: Container, isMatch: (node: Gtk.Widget) => boolean):
|
|
|
85
133
|
return results;
|
|
86
134
|
};
|
|
87
135
|
|
|
88
|
-
export {
|
|
136
|
+
export {
|
|
137
|
+
TOPLEVELS,
|
|
138
|
+
ancestorFor,
|
|
139
|
+
ancestors,
|
|
140
|
+
children,
|
|
141
|
+
descendants,
|
|
142
|
+
findAll,
|
|
143
|
+
isOnScreen,
|
|
144
|
+
relationCandidates,
|
|
145
|
+
roots,
|
|
146
|
+
traverse,
|
|
147
|
+
type Container,
|
|
148
|
+
};
|