@gtkx/testing 0.18.0 → 0.18.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/dist/bind-queries.d.ts +1 -0
- package/dist/bind-queries.d.ts.map +1 -0
- package/dist/bind-queries.js +1 -0
- package/dist/bind-queries.js.map +1 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -0
- package/dist/error-builder.d.ts +1 -0
- package/dist/error-builder.d.ts.map +1 -0
- package/dist/error-builder.js +1 -0
- package/dist/error-builder.js.map +1 -0
- package/dist/fire-event.d.ts +1 -0
- package/dist/fire-event.d.ts.map +1 -0
- package/dist/fire-event.js +1 -0
- package/dist/fire-event.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -0
- package/dist/pretty-widget.d.ts +1 -0
- package/dist/pretty-widget.d.ts.map +1 -0
- package/dist/pretty-widget.js +1 -0
- package/dist/pretty-widget.js.map +1 -0
- package/dist/queries.d.ts +1 -0
- package/dist/queries.d.ts.map +1 -0
- package/dist/queries.js +1 -0
- package/dist/queries.js.map +1 -0
- package/dist/render-hook.d.ts +1 -0
- package/dist/render-hook.d.ts.map +1 -0
- package/dist/render-hook.js +1 -0
- package/dist/render-hook.js.map +1 -0
- package/dist/render.d.ts +1 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +1 -0
- package/dist/render.js.map +1 -0
- package/dist/role-helpers.d.ts +1 -0
- package/dist/role-helpers.d.ts.map +1 -0
- package/dist/role-helpers.js +1 -0
- package/dist/role-helpers.js.map +1 -0
- package/dist/screen.d.ts +1 -0
- package/dist/screen.d.ts.map +1 -0
- package/dist/screen.js +1 -0
- package/dist/screen.js.map +1 -0
- package/dist/screenshot.d.ts +1 -0
- package/dist/screenshot.d.ts.map +1 -0
- package/dist/screenshot.js +1 -0
- package/dist/screenshot.js.map +1 -0
- package/dist/timing.d.ts +1 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +1 -0
- package/dist/timing.js.map +1 -0
- package/dist/traversal.d.ts +8 -0
- package/dist/traversal.d.ts.map +1 -0
- package/dist/traversal.js +1 -0
- package/dist/traversal.js.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/dist/user-event.d.ts +1 -0
- package/dist/user-event.d.ts.map +1 -0
- package/dist/user-event.js +1 -0
- package/dist/user-event.js.map +1 -0
- package/dist/wait-for.d.ts +1 -0
- package/dist/wait-for.d.ts.map +1 -0
- package/dist/wait-for.js +1 -0
- package/dist/wait-for.js.map +1 -0
- package/dist/widget-text.d.ts +1 -0
- package/dist/widget-text.d.ts.map +1 -0
- package/dist/widget-text.js +1 -0
- package/dist/widget-text.js.map +1 -0
- package/dist/widget.d.ts +1 -0
- package/dist/widget.d.ts.map +1 -0
- package/dist/widget.js +1 -0
- package/dist/widget.js.map +1 -0
- package/dist/within.d.ts +1 -0
- package/dist/within.d.ts.map +1 -0
- package/dist/within.js +1 -0
- package/dist/within.js.map +1 -0
- package/package.json +7 -5
- package/src/bind-queries.ts +52 -0
- package/src/config.ts +89 -0
- package/src/error-builder.ts +102 -0
- package/src/fire-event.ts +43 -0
- package/src/index.ts +51 -0
- package/src/pretty-widget.ts +205 -0
- package/src/queries.ts +511 -0
- package/src/render-hook.tsx +71 -0
- package/src/render.tsx +192 -0
- package/src/role-helpers.ts +126 -0
- package/src/screen.ts +125 -0
- package/src/screenshot.ts +105 -0
- package/src/timing.ts +17 -0
- package/src/traversal.ts +48 -0
- package/src/types.ts +210 -0
- package/src/user-event.ts +492 -0
- package/src/wait-for.ts +115 -0
- package/src/widget-text.ts +206 -0
- package/src/widget.ts +15 -0
- package/src/within.ts +31 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gtkx/testing",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
4
4
|
"description": "Testing utilities for GTKX applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"gtkx",
|
|
@@ -32,17 +32,19 @@
|
|
|
32
32
|
"default": "./dist/index.js"
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
|
+
"sideEffects": false,
|
|
35
36
|
"files": [
|
|
36
|
-
"dist"
|
|
37
|
+
"dist",
|
|
38
|
+
"src"
|
|
37
39
|
],
|
|
38
40
|
"dependencies": {
|
|
39
|
-
"@gtkx/ffi": "0.18.
|
|
40
|
-
"@gtkx/react": "0.18.
|
|
41
|
+
"@gtkx/ffi": "0.18.2",
|
|
42
|
+
"@gtkx/react": "0.18.2"
|
|
41
43
|
},
|
|
42
44
|
"devDependencies": {
|
|
43
45
|
"@types/react-reconciler": "^0.33.0",
|
|
44
46
|
"react-reconciler": "^0.33.0",
|
|
45
|
-
"@gtkx/vitest": "0.18.
|
|
47
|
+
"@gtkx/vitest": "0.18.2"
|
|
46
48
|
},
|
|
47
49
|
"scripts": {
|
|
48
50
|
"build": "tsc -b && cp ../../README.md .",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type * as Gtk from "@gtkx/ffi/gtk";
|
|
2
|
+
import * as queries from "./queries.js";
|
|
3
|
+
import type { Container } from "./traversal.js";
|
|
4
|
+
import type { BoundQueries, ByRoleOptions, TextMatch, TextMatchOptions } from "./types.js";
|
|
5
|
+
|
|
6
|
+
type ContainerOrGetter = Container | (() => Container);
|
|
7
|
+
|
|
8
|
+
const resolveContainer = (containerOrGetter: ContainerOrGetter): Container =>
|
|
9
|
+
typeof containerOrGetter === "function" ? containerOrGetter() : containerOrGetter;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Binds all query functions to a container.
|
|
13
|
+
*
|
|
14
|
+
* @param containerOrGetter - The container to bind queries to, or a function that returns it
|
|
15
|
+
* @returns Object with all query methods bound to the container
|
|
16
|
+
*
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export const bindQueries = (containerOrGetter: ContainerOrGetter): BoundQueries => ({
|
|
20
|
+
queryByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) =>
|
|
21
|
+
queries.queryByRole(resolveContainer(containerOrGetter), role, options),
|
|
22
|
+
queryByLabelText: (text: TextMatch, options?: TextMatchOptions) =>
|
|
23
|
+
queries.queryByLabelText(resolveContainer(containerOrGetter), text, options),
|
|
24
|
+
queryByText: (text: TextMatch, options?: TextMatchOptions) =>
|
|
25
|
+
queries.queryByText(resolveContainer(containerOrGetter), text, options),
|
|
26
|
+
queryByTestId: (testId: TextMatch, options?: TextMatchOptions) =>
|
|
27
|
+
queries.queryByTestId(resolveContainer(containerOrGetter), testId, options),
|
|
28
|
+
queryAllByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) =>
|
|
29
|
+
queries.queryAllByRole(resolveContainer(containerOrGetter), role, options),
|
|
30
|
+
queryAllByLabelText: (text: TextMatch, options?: TextMatchOptions) =>
|
|
31
|
+
queries.queryAllByLabelText(resolveContainer(containerOrGetter), text, options),
|
|
32
|
+
queryAllByText: (text: TextMatch, options?: TextMatchOptions) =>
|
|
33
|
+
queries.queryAllByText(resolveContainer(containerOrGetter), text, options),
|
|
34
|
+
queryAllByTestId: (testId: TextMatch, options?: TextMatchOptions) =>
|
|
35
|
+
queries.queryAllByTestId(resolveContainer(containerOrGetter), testId, options),
|
|
36
|
+
findByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) =>
|
|
37
|
+
queries.findByRole(resolveContainer(containerOrGetter), role, options),
|
|
38
|
+
findByLabelText: (text: TextMatch, options?: TextMatchOptions) =>
|
|
39
|
+
queries.findByLabelText(resolveContainer(containerOrGetter), text, options),
|
|
40
|
+
findByText: (text: TextMatch, options?: TextMatchOptions) =>
|
|
41
|
+
queries.findByText(resolveContainer(containerOrGetter), text, options),
|
|
42
|
+
findByTestId: (testId: TextMatch, options?: TextMatchOptions) =>
|
|
43
|
+
queries.findByTestId(resolveContainer(containerOrGetter), testId, options),
|
|
44
|
+
findAllByRole: (role: Gtk.AccessibleRole, options?: ByRoleOptions) =>
|
|
45
|
+
queries.findAllByRole(resolveContainer(containerOrGetter), role, options),
|
|
46
|
+
findAllByLabelText: (text: TextMatch, options?: TextMatchOptions) =>
|
|
47
|
+
queries.findAllByLabelText(resolveContainer(containerOrGetter), text, options),
|
|
48
|
+
findAllByText: (text: TextMatch, options?: TextMatchOptions) =>
|
|
49
|
+
queries.findAllByText(resolveContainer(containerOrGetter), text, options),
|
|
50
|
+
findAllByTestId: (testId: TextMatch, options?: TextMatchOptions) =>
|
|
51
|
+
queries.findAllByTestId(resolveContainer(containerOrGetter), testId, options),
|
|
52
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Container } from "./traversal.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration options for the testing library.
|
|
5
|
+
*/
|
|
6
|
+
export type Config = {
|
|
7
|
+
/**
|
|
8
|
+
* Whether to show role suggestions in error messages when elements are not found.
|
|
9
|
+
* @default true
|
|
10
|
+
*/
|
|
11
|
+
showSuggestions: boolean;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Custom error factory for query failures.
|
|
15
|
+
* Allows customizing how errors are constructed.
|
|
16
|
+
*/
|
|
17
|
+
getElementError: (message: string, container: Container) => Error;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Default timeout in milliseconds for async utilities (waitFor, findBy* queries).
|
|
21
|
+
* @default 1000
|
|
22
|
+
*/
|
|
23
|
+
asyncUtilTimeout: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const defaultGetElementError = (message: string, _container: Container): Error => {
|
|
27
|
+
return new Error(message);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const defaultConfig: Config = {
|
|
31
|
+
showSuggestions: true,
|
|
32
|
+
getElementError: defaultGetElementError,
|
|
33
|
+
asyncUtilTimeout: 1000,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let currentConfig: Config = { ...defaultConfig };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the current testing library configuration.
|
|
40
|
+
*
|
|
41
|
+
* @returns The current configuration object
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* import { getConfig } from "@gtkx/testing";
|
|
46
|
+
*
|
|
47
|
+
* const config = getConfig();
|
|
48
|
+
* console.log(config.showSuggestions);
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export const getConfig = (): Readonly<Config> => {
|
|
52
|
+
return currentConfig;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Configures the testing library behavior.
|
|
57
|
+
*
|
|
58
|
+
* Accepts either a partial configuration object or a function that receives
|
|
59
|
+
* the current configuration and returns updates.
|
|
60
|
+
*
|
|
61
|
+
* @param newConfig - Partial configuration or updater function
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```tsx
|
|
65
|
+
* import { configure } from "@gtkx/testing";
|
|
66
|
+
*
|
|
67
|
+
* // Disable role suggestions
|
|
68
|
+
* configure({ showSuggestions: false });
|
|
69
|
+
*
|
|
70
|
+
* // Use updater function
|
|
71
|
+
* configure((current) => ({
|
|
72
|
+
* showSuggestions: !current.showSuggestions,
|
|
73
|
+
* }));
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export const configure = (newConfig: Partial<Config> | ((current: Config) => Partial<Config>)): void => {
|
|
77
|
+
const updates = typeof newConfig === "function" ? newConfig(currentConfig) : newConfig;
|
|
78
|
+
currentConfig = { ...currentConfig, ...updates };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resets configuration to defaults.
|
|
83
|
+
* Primarily used for testing.
|
|
84
|
+
*
|
|
85
|
+
* @internal
|
|
86
|
+
*/
|
|
87
|
+
export const resetConfig = (): void => {
|
|
88
|
+
currentConfig = { ...defaultConfig };
|
|
89
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type * as Gtk from "@gtkx/ffi/gtk";
|
|
2
|
+
import { getConfig } from "./config.js";
|
|
3
|
+
import { prettyWidget } from "./pretty-widget.js";
|
|
4
|
+
import { formatRole, prettyRoles } from "./role-helpers.js";
|
|
5
|
+
import type { Container } from "./traversal.js";
|
|
6
|
+
import type { ByRoleOptions, TextMatch } from "./types.js";
|
|
7
|
+
|
|
8
|
+
type QueryType = "role" | "text" | "labelText" | "testId";
|
|
9
|
+
|
|
10
|
+
const formatTextMatcher = (text: TextMatch): string => {
|
|
11
|
+
if (typeof text === "function") {
|
|
12
|
+
return "custom function";
|
|
13
|
+
}
|
|
14
|
+
if (text instanceof RegExp) {
|
|
15
|
+
return text.toString();
|
|
16
|
+
}
|
|
17
|
+
return `'${text}'`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const formatByRoleDescription = (role: Gtk.AccessibleRole, options?: ByRoleOptions): string => {
|
|
21
|
+
const parts = [`role '${formatRole(role).toUpperCase()}'`];
|
|
22
|
+
if (options?.name) parts.push(`name ${formatTextMatcher(options.name)}`);
|
|
23
|
+
if (options?.checked !== undefined) parts.push(`checked=${options.checked}`);
|
|
24
|
+
if (options?.pressed !== undefined) parts.push(`pressed=${options.pressed}`);
|
|
25
|
+
if (options?.selected !== undefined) parts.push(`selected=${options.selected}`);
|
|
26
|
+
if (options?.expanded !== undefined) parts.push(`expanded=${options.expanded}`);
|
|
27
|
+
if (options?.level !== undefined) parts.push(`level=${options.level}`);
|
|
28
|
+
return parts.join(" and ");
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const formatQueryDescription = (
|
|
32
|
+
queryType: QueryType,
|
|
33
|
+
args: { role?: Gtk.AccessibleRole; text?: TextMatch; testId?: TextMatch; options?: ByRoleOptions },
|
|
34
|
+
): string => {
|
|
35
|
+
switch (queryType) {
|
|
36
|
+
case "role":
|
|
37
|
+
return formatByRoleDescription(args.role as Gtk.AccessibleRole, args.options);
|
|
38
|
+
case "text":
|
|
39
|
+
return `text ${formatTextMatcher(args.text as TextMatch)}`;
|
|
40
|
+
case "labelText":
|
|
41
|
+
return `label text ${formatTextMatcher(args.text as TextMatch)}`;
|
|
42
|
+
case "testId":
|
|
43
|
+
return `test id ${formatTextMatcher(args.testId as TextMatch)}`;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Builds an error for when no elements match a query.
|
|
49
|
+
*/
|
|
50
|
+
export const buildNotFoundError = (
|
|
51
|
+
container: Container,
|
|
52
|
+
queryType: QueryType,
|
|
53
|
+
args: { role?: Gtk.AccessibleRole; text?: TextMatch; testId?: TextMatch; options?: ByRoleOptions },
|
|
54
|
+
): Error => {
|
|
55
|
+
const config = getConfig();
|
|
56
|
+
const description = formatQueryDescription(queryType, args);
|
|
57
|
+
const lines: string[] = [`Unable to find an element with ${description}`];
|
|
58
|
+
|
|
59
|
+
if (config.showSuggestions && queryType === "role") {
|
|
60
|
+
lines.push("");
|
|
61
|
+
lines.push("Here are the accessible roles:");
|
|
62
|
+
lines.push("");
|
|
63
|
+
lines.push(prettyRoles(container));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
lines.push("");
|
|
67
|
+
lines.push(prettyWidget(container, { highlight: false }));
|
|
68
|
+
|
|
69
|
+
const message = lines.join("\n");
|
|
70
|
+
return config.getElementError(message, container);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Builds an error for when multiple elements match a query but only one was expected.
|
|
75
|
+
*/
|
|
76
|
+
export const buildMultipleFoundError = (
|
|
77
|
+
container: Container,
|
|
78
|
+
queryType: QueryType,
|
|
79
|
+
args: { role?: Gtk.AccessibleRole; text?: TextMatch; testId?: TextMatch; options?: ByRoleOptions },
|
|
80
|
+
count: number,
|
|
81
|
+
): Error => {
|
|
82
|
+
const config = getConfig();
|
|
83
|
+
const description = formatQueryDescription(queryType, args);
|
|
84
|
+
const lines: string[] = [`Found ${count} elements with ${description}, but expected only one`];
|
|
85
|
+
|
|
86
|
+
lines.push("");
|
|
87
|
+
lines.push(prettyWidget(container, { highlight: false }));
|
|
88
|
+
|
|
89
|
+
const message = lines.join("\n");
|
|
90
|
+
return config.getElementError(message, container);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Builds a timeout error with the last query error message.
|
|
95
|
+
*/
|
|
96
|
+
export const buildTimeoutError = (timeout: number, lastError: Error | null): Error => {
|
|
97
|
+
const baseMessage = `Timed out after ${timeout}ms`;
|
|
98
|
+
if (lastError) {
|
|
99
|
+
return new Error(`${baseMessage}.\n\n${lastError.message}`);
|
|
100
|
+
}
|
|
101
|
+
return new Error(baseMessage);
|
|
102
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type Object as GObject, signalEmitv, signalLookup, typeFromName, Value } from "@gtkx/ffi/gobject";
|
|
2
|
+
import type * as Gtk from "@gtkx/ffi/gtk";
|
|
3
|
+
import { tick } from "./timing.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Emits a GTK signal on a widget or event controller.
|
|
7
|
+
*
|
|
8
|
+
* Low-level utility for triggering signals directly. Prefer {@link userEvent}
|
|
9
|
+
* for common interactions like clicking and typing.
|
|
10
|
+
*
|
|
11
|
+
* @param element - The widget or event controller to emit the signal on
|
|
12
|
+
* @param signalName - GTK signal name (e.g., "clicked", "activate", "drag-begin")
|
|
13
|
+
* @param args - Additional signal arguments as GValues
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* import { fireEvent } from "@gtkx/testing";
|
|
18
|
+
* import { Value } from "@gtkx/ffi/gobject";
|
|
19
|
+
*
|
|
20
|
+
* // Emit signal on widget
|
|
21
|
+
* await fireEvent(widget, "clicked");
|
|
22
|
+
*
|
|
23
|
+
* // Emit signal on gesture controller
|
|
24
|
+
* const gesture = widget.observeControllers().getObject(0) as Gtk.GestureDrag;
|
|
25
|
+
* await fireEvent(gesture, "drag-begin", Value.newFromDouble(100), Value.newFromDouble(100));
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @see {@link userEvent} for high-level user interactions
|
|
29
|
+
*/
|
|
30
|
+
export const fireEvent = async (
|
|
31
|
+
element: Gtk.Widget | Gtk.EventController,
|
|
32
|
+
signalName: string,
|
|
33
|
+
...args: Value[]
|
|
34
|
+
): Promise<void> => {
|
|
35
|
+
const gtype = typeFromName((element.constructor as typeof GObject).glibTypeName);
|
|
36
|
+
const signalId = signalLookup(signalName, gtype);
|
|
37
|
+
|
|
38
|
+
const instanceValue = Value.newFromObject(element as GObject);
|
|
39
|
+
|
|
40
|
+
signalEmitv([instanceValue, ...args], signalId, 0);
|
|
41
|
+
|
|
42
|
+
await tick();
|
|
43
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type { Config } from "./config.js";
|
|
2
|
+
export { configure, getConfig } from "./config.js";
|
|
3
|
+
export { fireEvent } from "./fire-event.js";
|
|
4
|
+
export type { PrettyWidgetOptions } from "./pretty-widget.js";
|
|
5
|
+
export { prettyWidget } from "./pretty-widget.js";
|
|
6
|
+
export {
|
|
7
|
+
findAllByLabelText,
|
|
8
|
+
findAllByRole,
|
|
9
|
+
findAllByTestId,
|
|
10
|
+
findAllByText,
|
|
11
|
+
findByLabelText,
|
|
12
|
+
findByRole,
|
|
13
|
+
findByTestId,
|
|
14
|
+
findByText,
|
|
15
|
+
queryAllByLabelText,
|
|
16
|
+
queryAllByRole,
|
|
17
|
+
queryAllByTestId,
|
|
18
|
+
queryAllByText,
|
|
19
|
+
queryByLabelText,
|
|
20
|
+
queryByRole,
|
|
21
|
+
queryByTestId,
|
|
22
|
+
queryByText,
|
|
23
|
+
} from "./queries.js";
|
|
24
|
+
export { cleanup, render } from "./render.js";
|
|
25
|
+
export { renderHook } from "./render-hook.js";
|
|
26
|
+
export type { RoleInfo } from "./role-helpers.js";
|
|
27
|
+
export { getRoles, logRoles, prettyRoles } from "./role-helpers.js";
|
|
28
|
+
export { screen } from "./screen.js";
|
|
29
|
+
export type { ScreenshotOptions } from "./screenshot.js";
|
|
30
|
+
export { screenshot } from "./screenshot.js";
|
|
31
|
+
export { tick } from "./timing.js";
|
|
32
|
+
export type { Container } from "./traversal.js";
|
|
33
|
+
export type {
|
|
34
|
+
BoundQueries,
|
|
35
|
+
ByRoleOptions,
|
|
36
|
+
NormalizerOptions,
|
|
37
|
+
RenderHookOptions,
|
|
38
|
+
RenderHookResult,
|
|
39
|
+
RenderOptions,
|
|
40
|
+
RenderResult,
|
|
41
|
+
ScreenshotResult,
|
|
42
|
+
TextMatch,
|
|
43
|
+
TextMatchFunction,
|
|
44
|
+
TextMatchOptions,
|
|
45
|
+
WaitForOptions,
|
|
46
|
+
WrapperComponent,
|
|
47
|
+
} from "./types.js";
|
|
48
|
+
export type { PointerInput, TabOptions } from "./user-event.js";
|
|
49
|
+
export { userEvent } from "./user-event.js";
|
|
50
|
+
export { waitFor, waitForElementToBeRemoved } from "./wait-for.js";
|
|
51
|
+
export { within } from "./within.js";
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import * as Gtk from "@gtkx/ffi/gtk";
|
|
2
|
+
import { formatRole } from "./role-helpers.js";
|
|
3
|
+
import { type Container, isApplication } from "./traversal.js";
|
|
4
|
+
import { getWidgetText } from "./widget-text.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_LENGTH = 7000;
|
|
7
|
+
const INDENT = " ";
|
|
8
|
+
|
|
9
|
+
const debugIdMap = new WeakMap<Gtk.Widget, string>();
|
|
10
|
+
let nextDebugId = 0;
|
|
11
|
+
|
|
12
|
+
const getWidgetDebugId = (widget: Gtk.Widget): string => {
|
|
13
|
+
let id = debugIdMap.get(widget);
|
|
14
|
+
if (!id) {
|
|
15
|
+
id = String(nextDebugId++);
|
|
16
|
+
debugIdMap.set(widget, id);
|
|
17
|
+
}
|
|
18
|
+
return id;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Options for {@link prettyWidget}.
|
|
23
|
+
*/
|
|
24
|
+
export type PrettyWidgetOptions = {
|
|
25
|
+
/** Maximum output length before truncation (default: 7000) */
|
|
26
|
+
maxLength?: number;
|
|
27
|
+
/** Enable ANSI color highlighting (default: auto-detect) */
|
|
28
|
+
highlight?: boolean;
|
|
29
|
+
/** Include widget IDs for MCP/agentic interactions (default: false) */
|
|
30
|
+
includeIds?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type HighlightColors = {
|
|
34
|
+
tag: (s: string) => string;
|
|
35
|
+
attr: (s: string) => string;
|
|
36
|
+
value: (s: string) => string;
|
|
37
|
+
text: (s: string) => string;
|
|
38
|
+
reset: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const shouldHighlight = (): boolean => {
|
|
42
|
+
if (typeof process === "undefined") return false;
|
|
43
|
+
if (process.env.COLORS === "false" || process.env.NO_COLOR) return false;
|
|
44
|
+
if (process.env.COLORS === "true" || process.env.FORCE_COLOR) return true;
|
|
45
|
+
return process.stdout?.isTTY ?? false;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ansi = {
|
|
49
|
+
cyan: "\x1b[36m",
|
|
50
|
+
yellow: "\x1b[33m",
|
|
51
|
+
green: "\x1b[32m",
|
|
52
|
+
reset: "\x1b[0m",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const createColors = (enabled: boolean): HighlightColors => {
|
|
56
|
+
if (!enabled) {
|
|
57
|
+
const identity = (s: string): string => s;
|
|
58
|
+
return { tag: identity, attr: identity, value: identity, text: identity, reset: "" };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
tag: (s) => `${ansi.cyan}${s}${ansi.reset}`,
|
|
62
|
+
attr: (s) => `${ansi.yellow}${s}${ansi.reset}`,
|
|
63
|
+
value: (s) => `${ansi.green}${s}${ansi.reset}`,
|
|
64
|
+
text: (s) => s,
|
|
65
|
+
reset: ansi.reset,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const formatTagName = (widget: Gtk.Widget): string => {
|
|
70
|
+
return widget.constructor.name;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const escapeAttrValue = (value: string): string => {
|
|
74
|
+
return value.replace(/"/g, """);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const formatAttributes = (widget: Gtk.Widget, colors: HighlightColors, includeIds: boolean): string => {
|
|
78
|
+
const attrs: [string, string][] = [];
|
|
79
|
+
|
|
80
|
+
if (includeIds) {
|
|
81
|
+
attrs.push(["id", getWidgetDebugId(widget)]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const name = widget.getName();
|
|
85
|
+
if (name) {
|
|
86
|
+
attrs.push(["data-testid", name]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const role = widget.getAccessibleRole();
|
|
90
|
+
if (role !== undefined) {
|
|
91
|
+
attrs.push(["role", formatRole(role)]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!widget.getSensitive()) {
|
|
95
|
+
attrs.push(["aria-disabled", "true"]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!widget.getVisible()) {
|
|
99
|
+
attrs.push(["aria-hidden", "true"]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (attrs.length === 0) return "";
|
|
103
|
+
|
|
104
|
+
return attrs
|
|
105
|
+
.sort(([a], [b]) => {
|
|
106
|
+
if (a === "id") return -1;
|
|
107
|
+
if (b === "id") return 1;
|
|
108
|
+
return a.localeCompare(b);
|
|
109
|
+
})
|
|
110
|
+
.map(([key, value]) => ` ${colors.attr(key)}=${colors.value(`"${escapeAttrValue(value)}"`)}`)
|
|
111
|
+
.join("");
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const hasChildren = (widget: Gtk.Widget): boolean => {
|
|
115
|
+
return widget.getFirstChild() !== null;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const printWidget = (widget: Gtk.Widget, colors: HighlightColors, depth: number, includeIds: boolean): string => {
|
|
119
|
+
const indent = INDENT.repeat(depth);
|
|
120
|
+
const tagName = formatTagName(widget);
|
|
121
|
+
const attributes = formatAttributes(widget, colors, includeIds);
|
|
122
|
+
const text = getWidgetText(widget);
|
|
123
|
+
const children: string[] = [];
|
|
124
|
+
|
|
125
|
+
let child = widget.getFirstChild();
|
|
126
|
+
while (child) {
|
|
127
|
+
children.push(printWidget(child, colors, depth + 1, includeIds));
|
|
128
|
+
child = child.getNextSibling();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const openTag = `${colors.tag("<")}${colors.tag(tagName)}${attributes}${colors.tag(">")}`;
|
|
132
|
+
|
|
133
|
+
if (!hasChildren(widget) && !text) {
|
|
134
|
+
return `${indent}${openTag}\n`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const closeTag = `${colors.tag("</")}${colors.tag(tagName)}${colors.tag(">")}`;
|
|
138
|
+
|
|
139
|
+
if (text && !hasChildren(widget)) {
|
|
140
|
+
const textContent = colors.text(text);
|
|
141
|
+
return `${indent}${openTag}\n${indent}${INDENT}${textContent}\n${indent}${closeTag}\n`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let result = `${indent}${openTag}\n`;
|
|
145
|
+
if (text) {
|
|
146
|
+
result += `${indent}${INDENT}${colors.text(text)}\n`;
|
|
147
|
+
}
|
|
148
|
+
for (const childOutput of children) {
|
|
149
|
+
result += childOutput;
|
|
150
|
+
}
|
|
151
|
+
result += `${indent}${closeTag}\n`;
|
|
152
|
+
|
|
153
|
+
return result;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const printContainer = (container: Container, colors: HighlightColors, includeIds: boolean): string => {
|
|
157
|
+
if (isApplication(container)) {
|
|
158
|
+
const windows = Gtk.Window.listToplevels();
|
|
159
|
+
return windows.map((window) => printWidget(window, colors, 0, includeIds)).join("");
|
|
160
|
+
}
|
|
161
|
+
return printWidget(container, colors, 0, includeIds);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Formats a widget tree as a readable string for debugging.
|
|
166
|
+
*
|
|
167
|
+
* Renders the widget hierarchy in an HTML-like format with accessibility
|
|
168
|
+
* attributes like role, data-testid, and text content.
|
|
169
|
+
*
|
|
170
|
+
* @param container - The container widget or application to format
|
|
171
|
+
* @param options - Formatting options for length and highlighting
|
|
172
|
+
* @returns Formatted string representation of the widget tree
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```tsx
|
|
176
|
+
* import { prettyWidget } from "@gtkx/testing";
|
|
177
|
+
*
|
|
178
|
+
* console.log(prettyWidget(application));
|
|
179
|
+
* // Output:
|
|
180
|
+
* // <GtkApplicationWindow role="window">
|
|
181
|
+
* // <GtkButton role="button">
|
|
182
|
+
* // Click me
|
|
183
|
+
* // </GtkButton>
|
|
184
|
+
* // </GtkApplicationWindow>
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
export const prettyWidget = (container: Container, options: PrettyWidgetOptions = {}): string => {
|
|
188
|
+
const envLimit = process.env.DEBUG_PRINT_LIMIT ? Number(process.env.DEBUG_PRINT_LIMIT) : DEFAULT_MAX_LENGTH;
|
|
189
|
+
const maxLength = options.maxLength ?? envLimit;
|
|
190
|
+
const highlight = options.highlight ?? shouldHighlight();
|
|
191
|
+
const includeIds = options.includeIds ?? false;
|
|
192
|
+
|
|
193
|
+
if (maxLength === 0) {
|
|
194
|
+
return "";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const colors = createColors(highlight);
|
|
198
|
+
const output = printContainer(container, colors, includeIds);
|
|
199
|
+
|
|
200
|
+
if (output.length > maxLength) {
|
|
201
|
+
return `${output.slice(0, maxLength)}...`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return output.trimEnd();
|
|
205
|
+
};
|