@gtkx/testing 0.21.0 → 1.0.0-rc.2
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 +137 -34
- package/dist/act.d.ts +19 -0
- package/dist/act.d.ts.map +1 -0
- package/dist/act.js +56 -0
- package/dist/act.js.map +1 -0
- package/dist/bound-queries.d.ts +33 -0
- package/dist/bound-queries.d.ts.map +1 -0
- package/dist/bound-queries.js +2 -0
- package/dist/bound-queries.js.map +1 -0
- package/dist/build-queries.d.ts +16 -0
- package/dist/build-queries.d.ts.map +1 -0
- package/dist/build-queries.js +115 -0
- package/dist/build-queries.js.map +1 -0
- package/dist/cleanup-registry.d.ts +5 -0
- package/dist/cleanup-registry.d.ts.map +1 -0
- package/dist/cleanup-registry.js +21 -0
- package/dist/cleanup-registry.js.map +1 -0
- package/dist/config.d.ts +20 -52
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +17 -46
- package/dist/config.js.map +1 -1
- package/dist/editable.d.ts +8 -0
- package/dist/editable.d.ts.map +1 -0
- package/dist/editable.js +26 -0
- package/dist/editable.js.map +1 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +135 -0
- package/dist/errors.js.map +1 -0
- package/dist/fire-event.d.ts +8 -25
- package/dist/fire-event.d.ts.map +1 -1
- package/dist/fire-event.js +11 -30
- package/dist/fire-event.js.map +1 -1
- package/dist/frame-sync.d.ts +4 -0
- package/dist/frame-sync.d.ts.map +1 -0
- package/dist/frame-sync.js +37 -0
- package/dist/frame-sync.js.map +1 -0
- package/dist/index.d.ts +17 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/matchers.d.ts +59 -0
- package/dist/matchers.d.ts.map +1 -0
- package/dist/matchers.js +117 -0
- package/dist/matchers.js.map +1 -0
- package/dist/pretty-widget.d.ts +24 -26
- package/dist/pretty-widget.d.ts.map +1 -1
- package/dist/pretty-widget.js +111 -117
- package/dist/pretty-widget.js.map +1 -1
- package/dist/queries.d.ts +190 -180
- package/dist/queries.d.ts.map +1 -1
- package/dist/queries.js +291 -320
- package/dist/queries.js.map +1 -1
- package/dist/register-test-hooks.d.ts +2 -0
- package/dist/register-test-hooks.d.ts.map +1 -0
- package/dist/register-test-hooks.js +16 -0
- package/dist/register-test-hooks.js.map +1 -0
- package/dist/render-hook.d.ts +15 -35
- package/dist/render-hook.d.ts.map +1 -1
- package/dist/render-hook.js +9 -45
- package/dist/render-hook.js.map +1 -1
- package/dist/render.d.ts +13 -42
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +138 -121
- package/dist/render.js.map +1 -1
- package/dist/role-helpers.d.ts +19 -53
- package/dist/role-helpers.d.ts.map +1 -1
- package/dist/role-helpers.js +48 -63
- package/dist/role-helpers.js.map +1 -1
- package/dist/screen.d.ts +7 -63
- package/dist/screen.d.ts.map +1 -1
- package/dist/screen.js +24 -110
- package/dist/screen.js.map +1 -1
- package/dist/screenshot.d.ts +21 -23
- package/dist/screenshot.d.ts.map +1 -1
- package/dist/screenshot.js +98 -50
- package/dist/screenshot.js.map +1 -1
- package/dist/suggestions.d.ts +27 -0
- package/dist/suggestions.d.ts.map +1 -0
- package/dist/suggestions.js +61 -0
- package/dist/suggestions.js.map +1 -0
- package/dist/traversal.d.ts +12 -10
- package/dist/traversal.d.ts.map +1 -1
- package/dist/traversal.js +36 -15
- package/dist/traversal.js.map +1 -1
- package/dist/types.d.ts +115 -169
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/user-event/adjustment.d.ts +10 -0
- package/dist/user-event/adjustment.d.ts.map +1 -0
- package/dist/user-event/adjustment.js +35 -0
- package/dist/user-event/adjustment.js.map +1 -0
- package/dist/user-event/click.d.ts +8 -0
- package/dist/user-event/click.d.ts.map +1 -0
- package/dist/user-event/click.js +73 -0
- package/dist/user-event/click.js.map +1 -0
- package/dist/user-event/controller.d.ts +8 -0
- package/dist/user-event/controller.d.ts.map +1 -0
- package/dist/user-event/controller.js +31 -0
- package/dist/user-event/controller.js.map +1 -0
- package/dist/user-event/dispatch.d.ts +7 -0
- package/dist/user-event/dispatch.d.ts.map +1 -0
- package/dist/user-event/dispatch.js +11 -0
- package/dist/user-event/dispatch.js.map +1 -0
- package/dist/user-event/event-wrapper.d.ts +4 -0
- package/dist/user-event/event-wrapper.d.ts.map +1 -0
- package/dist/user-event/event-wrapper.js +59 -0
- package/dist/user-event/event-wrapper.js.map +1 -0
- package/dist/user-event/gesture.d.ts +35 -0
- package/dist/user-event/gesture.d.ts.map +1 -0
- package/dist/user-event/gesture.js +138 -0
- package/dist/user-event/gesture.js.map +1 -0
- package/dist/user-event/index.d.ts +51 -0
- package/dist/user-event/index.d.ts.map +1 -0
- package/dist/user-event/index.js +42 -0
- package/dist/user-event/index.js.map +1 -0
- package/dist/user-event/keyboard.d.ts +10 -0
- package/dist/user-event/keyboard.d.ts.map +1 -0
- package/dist/user-event/keyboard.js +204 -0
- package/dist/user-event/keyboard.js.map +1 -0
- package/dist/user-event/pointer.d.ts +10 -0
- package/dist/user-event/pointer.d.ts.map +1 -0
- package/dist/user-event/pointer.js +30 -0
- package/dist/user-event/pointer.js.map +1 -0
- package/dist/user-event/selection.d.ts +5 -0
- package/dist/user-event/selection.d.ts.map +1 -0
- package/dist/user-event/selection.js +105 -0
- package/dist/user-event/selection.js.map +1 -0
- package/dist/user-event/state.d.ts +7 -0
- package/dist/user-event/state.d.ts.map +1 -0
- package/dist/user-event/state.js +3 -0
- package/dist/user-event/state.js.map +1 -0
- package/dist/user-event/text.d.ts +18 -0
- package/dist/user-event/text.d.ts.map +1 -0
- package/dist/user-event/text.js +113 -0
- package/dist/user-event/text.js.map +1 -0
- package/dist/wait-for.d.ts +16 -36
- package/dist/wait-for.d.ts.map +1 -1
- package/dist/wait-for.js +74 -70
- package/dist/wait-for.js.map +1 -1
- package/dist/widget-accessible-properties.d.ts +34 -0
- package/dist/widget-accessible-properties.d.ts.map +1 -0
- package/dist/widget-accessible-properties.js +260 -0
- package/dist/widget-accessible-properties.js.map +1 -0
- package/dist/within.d.ts +10 -26
- package/dist/within.d.ts.map +1 -1
- package/dist/within.js +16 -25
- package/dist/within.js.map +1 -1
- package/package.json +23 -12
- package/src/act.ts +77 -0
- package/src/bound-queries.ts +37 -0
- package/src/build-queries.ts +226 -0
- package/src/cleanup-registry.ts +27 -0
- package/src/config.ts +35 -62
- package/src/editable.ts +37 -0
- package/src/errors.ts +191 -0
- package/src/fire-event.ts +13 -38
- package/src/frame-sync.ts +51 -0
- package/src/index.ts +58 -15
- package/src/matchers.ts +251 -0
- package/src/pretty-widget.ts +163 -130
- package/src/queries.ts +485 -396
- package/src/register-test-hooks.ts +19 -0
- package/src/render-hook.tsx +38 -45
- package/src/render.tsx +192 -155
- package/src/role-helpers.ts +65 -72
- package/src/screen.ts +26 -114
- package/src/screenshot.ts +128 -56
- package/src/suggestions.ts +106 -0
- package/src/traversal.ts +56 -22
- package/src/types.ts +144 -172
- package/src/user-event/adjustment.ts +62 -0
- package/src/user-event/click.ts +99 -0
- package/src/user-event/controller.ts +58 -0
- package/src/user-event/dispatch.ts +25 -0
- package/src/user-event/event-wrapper.ts +80 -0
- package/src/user-event/gesture.ts +239 -0
- package/src/user-event/index.ts +81 -0
- package/src/user-event/keyboard.ts +290 -0
- package/src/user-event/pointer.ts +46 -0
- package/src/user-event/selection.ts +159 -0
- package/src/user-event/state.ts +8 -0
- package/src/user-event/text.ts +172 -0
- package/src/wait-for.ts +117 -78
- package/src/widget-accessible-properties.ts +388 -0
- package/src/within.ts +29 -27
- package/dist/bind-queries.d.ts +0 -14
- package/dist/bind-queries.d.ts.map +0 -1
- package/dist/bind-queries.js +0 -29
- package/dist/bind-queries.js.map +0 -1
- package/dist/error-builder.d.ts +0 -28
- package/dist/error-builder.d.ts.map +0 -1
- package/dist/error-builder.js +0 -81
- package/dist/error-builder.js.map +0 -1
- package/dist/timing.d.ts +0 -18
- package/dist/timing.d.ts.map +0 -1
- package/dist/timing.js +0 -18
- package/dist/timing.js.map +0 -1
- package/dist/user-event.d.ts +0 -138
- package/dist/user-event.d.ts.map +0 -1
- package/dist/user-event.js +0 -411
- package/dist/user-event.js.map +0 -1
- package/dist/widget-text.d.ts +0 -69
- package/dist/widget-text.d.ts.map +0 -1
- package/dist/widget-text.js +0 -177
- package/dist/widget-text.js.map +0 -1
- package/dist/widget.d.ts +0 -3
- package/dist/widget.d.ts.map +0 -1
- package/dist/widget.js +0 -13
- package/dist/widget.js.map +0 -1
- package/src/bind-queries.ts +0 -52
- package/src/error-builder.ts +0 -102
- package/src/timing.ts +0 -17
- package/src/user-event.ts +0 -492
- package/src/widget-text.ts +0 -202
- package/src/widget.ts +0 -15
package/src/role-helpers.ts
CHANGED
|
@@ -1,64 +1,68 @@
|
|
|
1
|
-
import * as Gtk from "@gtkx/
|
|
1
|
+
import * as Gtk from "@gtkx/gi/gtk";
|
|
2
|
+
import { sortStringsBy } from "@gtkx/utils";
|
|
2
3
|
import { type Container, traverse } from "./traversal.js";
|
|
3
|
-
import { getWidgetAccessibleName } from "./widget-
|
|
4
|
+
import { getWidgetAccessibleName } from "./widget-accessible-properties.js";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
const ROLE_NAMES_BY_VALUE = enumNamesByValue(Gtk.AccessibleRole);
|
|
7
|
+
|
|
8
|
+
function enumNamesByValue(enumObject: Record<string, string | number>): Map<number, string> {
|
|
9
|
+
return new Map<number, string>(
|
|
10
|
+
Object.entries(enumObject)
|
|
11
|
+
.filter((entry): entry is [string, number] => typeof entry[1] === "number")
|
|
12
|
+
.map(([name, value]) => [value, name]),
|
|
13
|
+
);
|
|
14
|
+
}
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
|
-
*
|
|
17
|
+
* Converts an accessible role enum value into its lowercase name, falling back
|
|
18
|
+
* to the numeric value when the role is unknown.
|
|
15
19
|
*
|
|
16
|
-
* @param role
|
|
17
|
-
* @returns Lowercase role name (e.g., "button", "checkbox")
|
|
20
|
+
* @param role The accessible role to format.
|
|
18
21
|
*/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (!name)
|
|
22
|
+
const formatRole = (role: Gtk.AccessibleRole): string => {
|
|
23
|
+
const name = ROLE_NAMES_BY_VALUE.get(role);
|
|
24
|
+
|
|
25
|
+
if (!name) {
|
|
26
|
+
return String(role);
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
return name.toLowerCase();
|
|
24
30
|
};
|
|
25
31
|
|
|
32
|
+
const formatRoleList = (roles: Iterable<Gtk.AccessibleRole>): string => {
|
|
33
|
+
const names = [...roles].map((role) => formatRole(role).toUpperCase());
|
|
34
|
+
|
|
35
|
+
if (names.length <= 1) {
|
|
36
|
+
return names.join("");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (names.length === 2) {
|
|
40
|
+
return names.join(" or ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const head = names.slice(0, -1);
|
|
44
|
+
const last = names.at(-1) ?? "";
|
|
45
|
+
|
|
46
|
+
return `${head.join(", ")}, or ${last}`;
|
|
47
|
+
};
|
|
48
|
+
|
|
26
49
|
/**
|
|
27
|
-
*
|
|
50
|
+
* Groups every widget in a container's tree by its accessible role name.
|
|
28
51
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* @param container - The container to scan for roles
|
|
33
|
-
* @returns Map of role names to arrays of RoleInfo
|
|
34
|
-
*
|
|
35
|
-
* @example
|
|
36
|
-
* ```tsx
|
|
37
|
-
* import { getRoles } from "@gtkx/testing";
|
|
38
|
-
*
|
|
39
|
-
* const roles = getRoles(container);
|
|
40
|
-
* // Map {
|
|
41
|
-
* // "button" => [{ widget: ..., name: "Submit" }, { widget: ..., name: "Cancel" }],
|
|
42
|
-
* // "checkbox" => [{ widget: ..., name: "Remember me" }]
|
|
43
|
-
* // }
|
|
44
|
-
* ```
|
|
52
|
+
* @param container The scope to traverse.
|
|
53
|
+
* @returns A map from role name to the widgets that have that role.
|
|
45
54
|
*/
|
|
46
|
-
|
|
47
|
-
const roles
|
|
55
|
+
const getRoles = (container: Container): Map<string, Gtk.Widget[]> => {
|
|
56
|
+
const roles: Map<string, Gtk.Widget[]> = new Map();
|
|
48
57
|
|
|
49
58
|
for (const widget of traverse(container)) {
|
|
50
|
-
const
|
|
51
|
-
if (role === undefined) continue;
|
|
52
|
-
|
|
53
|
-
const roleName = formatRole(role);
|
|
54
|
-
const name = getWidgetAccessibleName(widget);
|
|
55
|
-
const info: RoleInfo = { widget, name };
|
|
56
|
-
|
|
59
|
+
const roleName = formatRole(widget.getAccessibleRole());
|
|
57
60
|
const existing = roles.get(roleName);
|
|
61
|
+
|
|
58
62
|
if (existing) {
|
|
59
|
-
existing.push(
|
|
63
|
+
existing.push(widget);
|
|
60
64
|
} else {
|
|
61
|
-
roles.set(roleName, [
|
|
65
|
+
roles.set(roleName, [widget]);
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -67,18 +71,19 @@ export const getRoles = (container: Container): Map<string, RoleInfo[]> => {
|
|
|
67
71
|
|
|
68
72
|
const formatWidgetPreview = (widget: Gtk.Widget, name: string | null): string => {
|
|
69
73
|
const tagName = widget.constructor.name;
|
|
70
|
-
const roleAttr = formatRole(widget.getAccessibleRole
|
|
74
|
+
const roleAttr = formatRole(widget.getAccessibleRole());
|
|
71
75
|
const nameDisplay = name ? `Name "${name}"` : 'Name ""';
|
|
76
|
+
|
|
72
77
|
return `${nameDisplay}: <${tagName} role="${roleAttr}">${name ?? ""}</${tagName}>`;
|
|
73
78
|
};
|
|
74
79
|
|
|
75
80
|
/**
|
|
76
|
-
* Formats roles
|
|
81
|
+
* Formats the accessible roles in a container's tree as a readable string,
|
|
82
|
+
* listing each role together with its widgets and their accessible names.
|
|
77
83
|
*
|
|
78
|
-
* @param container
|
|
79
|
-
* @returns Formatted string showing all roles and their accessible names
|
|
84
|
+
* @param container The scope to inspect.
|
|
80
85
|
*/
|
|
81
|
-
|
|
86
|
+
const prettyRoles = (container: Container): string => {
|
|
82
87
|
const roles = getRoles(container);
|
|
83
88
|
|
|
84
89
|
if (roles.size === 0) {
|
|
@@ -86,14 +91,15 @@ export const prettyRoles = (container: Container): string => {
|
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
const lines: string[] = [];
|
|
89
|
-
|
|
90
|
-
const sortedRoles = [...roles.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
94
|
+
const sortedRoles = sortStringsBy([...roles], ([roleName]) => roleName);
|
|
91
95
|
|
|
92
96
|
for (const [roleName, widgets] of sortedRoles) {
|
|
93
97
|
lines.push(`${roleName}:`);
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
|
|
99
|
+
for (const widget of widgets) {
|
|
100
|
+
lines.push(` ${formatWidgetPreview(widget, getWidgetAccessibleName(widget))}`);
|
|
96
101
|
}
|
|
102
|
+
|
|
97
103
|
lines.push("");
|
|
98
104
|
}
|
|
99
105
|
|
|
@@ -101,26 +107,13 @@ export const prettyRoles = (container: Container): string => {
|
|
|
101
107
|
};
|
|
102
108
|
|
|
103
109
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* Useful for debugging test failures and discovering available roles.
|
|
110
|
+
* Prints the accessible roles in a container's tree to the console using
|
|
111
|
+
* {@link prettyRoles}.
|
|
107
112
|
*
|
|
108
|
-
* @param container
|
|
109
|
-
*
|
|
110
|
-
* @example
|
|
111
|
-
* ```tsx
|
|
112
|
-
* import { render, logRoles } from "@gtkx/testing";
|
|
113
|
-
*
|
|
114
|
-
* const { container } = await render(<MyComponent />);
|
|
115
|
-
* logRoles(container);
|
|
116
|
-
* // Console output:
|
|
117
|
-
* // button:
|
|
118
|
-
* // Name "Submit": <GtkButton role="button">Submit</GtkButton>
|
|
119
|
-
* // Name "Cancel": <GtkButton role="button">Cancel</GtkButton>
|
|
120
|
-
* // checkbox:
|
|
121
|
-
* // Name "Remember me": <GtkCheckButton role="checkbox">Remember me</GtkCheckButton>
|
|
122
|
-
* ```
|
|
113
|
+
* @param container The scope to inspect.
|
|
123
114
|
*/
|
|
124
|
-
|
|
115
|
+
const logRoles = (container: Container): void => {
|
|
125
116
|
console.log(prettyRoles(container));
|
|
126
117
|
};
|
|
118
|
+
|
|
119
|
+
export { formatRole, formatRoleList, getRoles, prettyRoles, logRoles };
|
package/src/screen.ts
CHANGED
|
@@ -1,125 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import * as Gtk from "@gtkx/ffi/gtk";
|
|
5
|
-
import { bindQueries } from "./bind-queries.js";
|
|
6
|
-
import { prettyWidget } from "./pretty-widget.js";
|
|
7
|
-
import { logRoles } from "./role-helpers.js";
|
|
8
|
-
import { type ScreenshotOptions, screenshot as screenshotWidget } from "./screenshot.js";
|
|
9
|
-
import type { ScreenshotResult } from "./types.js";
|
|
1
|
+
import type { RenderResult, Screen } from "./bound-queries.js";
|
|
10
2
|
|
|
11
|
-
const
|
|
12
|
-
const dir = join(tmpdir(), "gtkx-screenshots");
|
|
13
|
-
if (!existsSync(dir)) {
|
|
14
|
-
mkdirSync(dir, { recursive: true });
|
|
15
|
-
}
|
|
16
|
-
return dir;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const saveAndLogScreenshot = (result: ScreenshotResult): void => {
|
|
20
|
-
const dir = getScreenshotDir();
|
|
21
|
-
const filename = `${Date.now()}-screenshot.png`;
|
|
22
|
-
const filepath = join(dir, filename);
|
|
23
|
-
const buffer = Buffer.from(result.data, "base64");
|
|
24
|
-
writeFileSync(filepath, buffer);
|
|
25
|
-
console.log(`Screenshot saved: file://${filepath}`);
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
let currentRoot: Gtk.Application | null = null;
|
|
29
|
-
|
|
30
|
-
/** @internal */
|
|
31
|
-
export const setScreenRoot = (root: Gtk.Application | null): void => {
|
|
32
|
-
currentRoot = root;
|
|
33
|
-
};
|
|
3
|
+
const NO_RENDER_MESSAGE = "No render has been performed: call render() before using screen queries";
|
|
34
4
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
5
|
+
const defaultScreen: RenderResult = new Proxy({} as RenderResult, {
|
|
6
|
+
get: (_target, property) => {
|
|
7
|
+
if (property === "baseElement" || property === "container") {
|
|
8
|
+
return throwNoRender();
|
|
9
|
+
}
|
|
39
10
|
|
|
40
|
-
|
|
41
|
-
}
|
|
11
|
+
return throwNoRender;
|
|
12
|
+
},
|
|
13
|
+
});
|
|
42
14
|
|
|
43
|
-
const
|
|
15
|
+
const activeScreen: { current: RenderResult } = { current: defaultScreen };
|
|
44
16
|
|
|
45
17
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* Provides the same query methods as render result, but automatically
|
|
49
|
-
* uses the most recently rendered application as the container.
|
|
50
|
-
*
|
|
51
|
-
* @example
|
|
52
|
-
* ```tsx
|
|
53
|
-
* import { render, screen } from "@gtkx/testing";
|
|
54
|
-
*
|
|
55
|
-
* test("finds button", async () => {
|
|
56
|
-
* await render(<MyComponent />);
|
|
57
|
-
* const button = await screen.findByRole(Gtk.AccessibleRole.BUTTON);
|
|
58
|
-
* expect(button).toBeDefined();
|
|
59
|
-
* });
|
|
60
|
-
* ```
|
|
61
|
-
*
|
|
62
|
-
* @see {@link render} for rendering components
|
|
63
|
-
* @see {@link within} for scoped queries
|
|
18
|
+
* Queries and debug utilities bound to the most recent render, scoped to the
|
|
19
|
+
* current toplevel windows. Accessing it before any render throws.
|
|
64
20
|
*/
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
debug: () => {
|
|
69
|
-
console.log(prettyWidget(getRoot()));
|
|
70
|
-
},
|
|
71
|
-
/** Log all accessible roles to console for debugging */
|
|
72
|
-
logRoles: () => {
|
|
73
|
-
logRoles(getRoot());
|
|
74
|
-
},
|
|
75
|
-
/**
|
|
76
|
-
* Capture a screenshot of the application window, saving it to a temporary file and logging the file path.
|
|
77
|
-
*
|
|
78
|
-
* @param selector - Window selector: index (number), title substring (string), or title pattern (RegExp).
|
|
79
|
-
* If omitted, captures the first window.
|
|
80
|
-
* @param options - Optional timeout and interval configuration for waiting on widget rendering.
|
|
81
|
-
* @returns Screenshot result containing base64-encoded PNG data
|
|
82
|
-
* @throws Error if no windows are available or no matching window is found
|
|
83
|
-
*
|
|
84
|
-
* @example
|
|
85
|
-
* ```tsx
|
|
86
|
-
* await screen.screenshot(); // First window
|
|
87
|
-
* await screen.screenshot(0); // Window at index 0
|
|
88
|
-
* await screen.screenshot("Settings"); // Window with title containing "Settings"
|
|
89
|
-
* await screen.screenshot(/^My App/); // Window with title matching regex
|
|
90
|
-
* ```
|
|
91
|
-
*/
|
|
92
|
-
screenshot: async (selector?: number | string | RegExp, options?: ScreenshotOptions): Promise<ScreenshotResult> => {
|
|
93
|
-
const windows = Gtk.Window.listToplevels();
|
|
94
|
-
|
|
95
|
-
if (windows.length === 0) {
|
|
96
|
-
throw new Error("No windows available for screenshot");
|
|
97
|
-
}
|
|
21
|
+
const screen: Screen = new Proxy({} as Screen, {
|
|
22
|
+
get: (_target, property): unknown => Reflect.get(activeScreen.current, property),
|
|
23
|
+
});
|
|
98
24
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
targetWindow = windows[0] as Gtk.Window;
|
|
103
|
-
} else if (typeof selector === "number") {
|
|
104
|
-
targetWindow = windows[selector] as Gtk.Window | undefined;
|
|
105
|
-
if (!targetWindow) {
|
|
106
|
-
throw new Error(`Window at index ${selector} not found`);
|
|
107
|
-
}
|
|
108
|
-
} else {
|
|
109
|
-
const isRegex = selector instanceof RegExp;
|
|
110
|
-
targetWindow = windows.find((w) => {
|
|
111
|
-
const title = (w as Gtk.Window).getTitle() ?? "";
|
|
112
|
-
return isRegex ? selector.test(title) : title.includes(selector);
|
|
113
|
-
}) as Gtk.Window | undefined;
|
|
25
|
+
const throwNoRender = (): never => {
|
|
26
|
+
throw new Error(NO_RENDER_MESSAGE);
|
|
27
|
+
};
|
|
114
28
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
}
|
|
29
|
+
const setScreen = (result: RenderResult): void => {
|
|
30
|
+
activeScreen.current = result;
|
|
31
|
+
};
|
|
120
32
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return result;
|
|
124
|
-
},
|
|
33
|
+
const clearScreen = (): void => {
|
|
34
|
+
activeScreen.current = defaultScreen;
|
|
125
35
|
};
|
|
36
|
+
|
|
37
|
+
export { screen, setScreen, clearScreen };
|
package/src/screenshot.ts
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
import
|
|
2
|
-
import * as
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
1
|
+
import * as Gsk from "@gtkx/gi/gsk";
|
|
2
|
+
import * as Gtk from "@gtkx/gi/gtk";
|
|
3
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { ScreenshotOptions, ScreenshotResult, WindowSelector } from "./types.js";
|
|
6
7
|
import { waitFor } from "./wait-for.js";
|
|
7
8
|
|
|
9
|
+
const DEFAULT_SCREENSHOT_INTERVAL = 10;
|
|
10
|
+
|
|
8
11
|
const bytesToBase64 = (bytes: number[]): string => {
|
|
9
12
|
return Buffer.from(bytes).toString("base64");
|
|
10
13
|
};
|
|
11
14
|
|
|
12
|
-
const
|
|
13
|
-
|
|
15
|
+
const describeWidgetState = (widget: Gtk.Widget): string =>
|
|
16
|
+
`realized=${String(widget.getRealized())} mapped=${String(widget.getMapped())} ` +
|
|
17
|
+
`visible=${String(widget.getVisible())}`;
|
|
14
18
|
|
|
15
|
-
const captureSnapshot = (widget: Gtk.Widget): ScreenshotResult => {
|
|
16
|
-
const paintable = new Gtk.WidgetPaintable(widget);
|
|
19
|
+
const captureSnapshot = (widget: Gtk.Widget, scale: number): ScreenshotResult => {
|
|
20
|
+
const paintable = new Gtk.WidgetPaintable({ widget });
|
|
17
21
|
const width = paintable.getIntrinsicWidth();
|
|
18
22
|
const height = paintable.getIntrinsicHeight();
|
|
19
23
|
|
|
20
24
|
if (width <= 0 || height <= 0) {
|
|
21
|
-
throw new Error(
|
|
25
|
+
throw new Error(`Widget has no size: ensure it is realized and visible (${describeWidgetState(widget)})`);
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
const snapshot = new Gtk.Snapshot();
|
|
29
|
+
snapshot.scale(scale, scale);
|
|
25
30
|
paintable.snapshot(snapshot, width, height);
|
|
26
31
|
const renderNode = snapshot.toNode();
|
|
27
32
|
|
|
28
33
|
if (!renderNode) {
|
|
29
|
-
throw new Error(
|
|
34
|
+
throw new Error(`Widget produced no render content (${describeWidgetState(widget)})`);
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
const display = widget.getDisplay();
|
|
33
|
-
if (!display) {
|
|
34
|
-
throw new Error("Widget has no display: ensure it is realized");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
38
|
const renderer = new Gsk.CairoRenderer();
|
|
38
39
|
renderer.realizeForDisplay(display);
|
|
39
40
|
|
|
40
41
|
try {
|
|
41
|
-
const texture = renderer.renderTexture(renderNode);
|
|
42
|
+
const texture = renderer.renderTexture(renderNode, null);
|
|
42
43
|
const pngBytes = texture.saveToPngBytes();
|
|
43
|
-
const
|
|
44
|
-
const data = pngBytes.getData(sizeRef);
|
|
44
|
+
const data = pngBytes.getData();
|
|
45
45
|
|
|
46
46
|
if (!data) {
|
|
47
47
|
throw new Error("Failed to serialize screenshot to PNG");
|
|
@@ -50,8 +50,8 @@ const captureSnapshot = (widget: Gtk.Widget): ScreenshotResult => {
|
|
|
50
50
|
return {
|
|
51
51
|
data: bytesToBase64(data),
|
|
52
52
|
mimeType: "image/png",
|
|
53
|
-
width,
|
|
54
|
-
height,
|
|
53
|
+
width: Math.round(width * scale),
|
|
54
|
+
height: Math.round(height * scale),
|
|
55
55
|
};
|
|
56
56
|
} finally {
|
|
57
57
|
renderer.unrealize();
|
|
@@ -59,47 +59,119 @@ const captureSnapshot = (widget: Gtk.Widget): ScreenshotResult => {
|
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
62
|
+
* Captures a PNG snapshot of a widget, retrying until it has a paintable size.
|
|
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.
|
|
63
67
|
*/
|
|
64
|
-
|
|
68
|
+
const screenshot = async (widget: Gtk.Widget, options?: ScreenshotOptions): Promise<ScreenshotResult> => {
|
|
69
|
+
const scale = options?.scale ?? 1;
|
|
70
|
+
|
|
71
|
+
if (!Number.isFinite(scale) || scale <= 0) {
|
|
72
|
+
throw new Error(`Screenshot scale must be a positive number, got ${String(scale)}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return waitFor(() => captureSnapshot(widget, scale), {
|
|
76
|
+
timeout: options?.timeout,
|
|
77
|
+
interval: options?.interval ?? DEFAULT_SCREENSHOT_INTERVAL,
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const firstToplevelWindow = (windows: Gtk.Widget[]): Gtk.Window => {
|
|
82
|
+
const [first] = windows;
|
|
83
|
+
|
|
84
|
+
if (!(first instanceof Gtk.Window)) {
|
|
85
|
+
throw new TypeError("First toplevel is not a Window");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return first;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const windowAtIndex = (windows: Gtk.Widget[], index: number): Gtk.Window => {
|
|
92
|
+
const indexed = windows[index];
|
|
93
|
+
|
|
94
|
+
if (!(indexed instanceof Gtk.Window)) {
|
|
95
|
+
throw new TypeError(`Window at index ${String(index)} not found`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return indexed;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const isWindow = (widget: Gtk.Widget): widget is Gtk.Window => widget instanceof Gtk.Window;
|
|
102
|
+
|
|
103
|
+
const hasMatchingTitle = (window: Gtk.Window, selector: string | RegExp): boolean => {
|
|
104
|
+
const title = window.getTitle() ?? "";
|
|
105
|
+
|
|
106
|
+
return selector instanceof RegExp ? selector.test(title) : title.includes(selector);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const describeTitleSelector = (selector: string | RegExp): string =>
|
|
110
|
+
selector instanceof RegExp ? selector.toString() : `"${selector}"`;
|
|
111
|
+
|
|
112
|
+
const windowByTitle = (windows: Gtk.Widget[], selector: string | RegExp): Gtk.Window => {
|
|
113
|
+
const found = windows.filter(isWindow).find((window) => hasMatchingTitle(window, selector));
|
|
114
|
+
|
|
115
|
+
if (!found) {
|
|
116
|
+
throw new Error(`No window found with title matching ${describeTitleSelector(selector)}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return found;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const resolveWindow = (selector?: WindowSelector): Gtk.Window => {
|
|
123
|
+
const windows = Gtk.Window.listToplevels();
|
|
124
|
+
|
|
125
|
+
if (windows.length === 0) {
|
|
126
|
+
throw new Error("No windows available for screenshot");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (selector === undefined) {
|
|
130
|
+
return firstToplevelWindow(windows);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (typeof selector === "number") {
|
|
134
|
+
return windowAtIndex(windows, selector);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return windowByTitle(windows, selector);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const saveScreenshotToTempFile = (result: ScreenshotResult): string => {
|
|
141
|
+
const dir = mkdtempSync(join(tmpdir(), "gtkx-screenshots-"));
|
|
142
|
+
const filepath = join(dir, "screenshot.png");
|
|
143
|
+
writeFileSync(filepath, Buffer.from(result.data, "base64"));
|
|
144
|
+
|
|
145
|
+
return filepath;
|
|
146
|
+
};
|
|
65
147
|
|
|
66
148
|
/**
|
|
67
|
-
*
|
|
149
|
+
* Prints a clickable file URL for a saved screenshot to the console.
|
|
68
150
|
*
|
|
69
|
-
*
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* ```tsx
|
|
79
|
-
* import { render, screenshot } from "@gtkx/testing";
|
|
80
|
-
* import * as Gtk from "@gtkx/ffi/gtk";
|
|
151
|
+
* @param filepath Absolute path of the saved screenshot file.
|
|
152
|
+
*/
|
|
153
|
+
const logScreenshotPath = (filepath: string): void => {
|
|
154
|
+
console.log(`Screenshot saved: file://${filepath}`);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Captures a screenshot of a toplevel window, writes it to a temporary file,
|
|
159
|
+
* logs its path, and returns the image data.
|
|
81
160
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* ```
|
|
161
|
+
* @param selector Chooses the window by index, title substring, or regular
|
|
162
|
+
* expression; defaults to the first toplevel.
|
|
163
|
+
* @param options Optional scale, timeout, and retry interval.
|
|
164
|
+
* @returns The base64-encoded PNG data along with its mime type and dimensions.
|
|
87
165
|
*/
|
|
88
|
-
|
|
89
|
-
|
|
166
|
+
const captureAndSaveScreenshot = async (
|
|
167
|
+
selector?: WindowSelector,
|
|
168
|
+
options?: ScreenshotOptions,
|
|
169
|
+
): Promise<ScreenshotResult> => {
|
|
170
|
+
const target = resolveWindow(selector);
|
|
171
|
+
const result = await screenshot(target, options);
|
|
172
|
+
logScreenshotPath(saveScreenshotToTempFile(result));
|
|
90
173
|
|
|
91
|
-
return
|
|
92
|
-
timeout: options?.timeout ?? DEFAULT_SCREENSHOT_TIMEOUT,
|
|
93
|
-
interval: options?.interval ?? DEFAULT_SCREENSHOT_INTERVAL,
|
|
94
|
-
onTimeout: (error) => {
|
|
95
|
-
const paintable = new Gtk.WidgetPaintable(widget);
|
|
96
|
-
const width = paintable.getIntrinsicWidth();
|
|
97
|
-
const height = paintable.getIntrinsicHeight();
|
|
98
|
-
|
|
99
|
-
if (width <= 0 || height <= 0) {
|
|
100
|
-
return new Error("Widget has no size: ensure it is realized and visible");
|
|
101
|
-
}
|
|
102
|
-
return new Error(`Widget produced no render content after waiting for paint cycle: ${error.message}`);
|
|
103
|
-
},
|
|
104
|
-
});
|
|
174
|
+
return result;
|
|
105
175
|
};
|
|
176
|
+
|
|
177
|
+
export { screenshot, logScreenshotPath, captureAndSaveScreenshot };
|