@gtkx/vitest 0.10.4 → 0.11.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 ADDED
@@ -0,0 +1,103 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/eugeniodepalo/gtkx/main/logo.svg" alt="GTKX" width="60" height="60">
3
+ </p>
4
+
5
+ <h1 align="center">GTKX</h1>
6
+
7
+ <p align="center">
8
+ <strong>Build native GTK4 desktop applications with React and TypeScript.</strong>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/@gtkx/react"><img src="https://img.shields.io/npm/v/@gtkx/react.svg" alt="npm version"></a>
13
+ <a href="https://github.com/eugeniodepalo/gtkx/actions"><img src="https://img.shields.io/github/actions/workflow/status/eugeniodepalo/gtkx/ci.yml" alt="CI"></a>
14
+ <a href="https://github.com/eugeniodepalo/gtkx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MPL--2.0-blue.svg" alt="License"></a>
15
+ <a href="https://github.com/eugeniodepalo/gtkx/discussions"><img src="https://img.shields.io/badge/discussions-GitHub-blue" alt="GitHub Discussions"></a>
16
+ </p>
17
+
18
+ ---
19
+
20
+ GTKX lets you write Linux desktop applications using React. Your components render as native GTK4 widgets through a Rust FFI bridge—no webviews, no Electron, just native performance with the developer experience you already know.
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ npx @gtkx/cli create my-app
26
+ cd my-app
27
+ npm run dev
28
+ ```
29
+
30
+ ## Example
31
+
32
+ ```tsx
33
+ import {
34
+ GtkApplicationWindow,
35
+ GtkBox,
36
+ GtkButton,
37
+ GtkLabel,
38
+ quit,
39
+ render,
40
+ } from "@gtkx/react";
41
+ import * as Gtk from "@gtkx/ffi/gtk";
42
+ import { useState } from "react";
43
+
44
+ const App = () => {
45
+ const [count, setCount] = useState(0);
46
+
47
+ return (
48
+ <GtkApplicationWindow
49
+ title="Counter"
50
+ defaultWidth={300}
51
+ defaultHeight={200}
52
+ onCloseRequest={quit}
53
+ >
54
+ <GtkBox
55
+ orientation={Gtk.Orientation.VERTICAL}
56
+ spacing={20}
57
+ valign={Gtk.Align.CENTER}
58
+ >
59
+ <GtkLabel label={`Count: ${count}`} cssClasses={["title-1"]} />
60
+ <GtkButton label="Increment" onClicked={() => setCount((c) => c + 1)} />
61
+ </GtkBox>
62
+ </GtkApplicationWindow>
63
+ );
64
+ };
65
+
66
+ render(<App />, "com.example.counter");
67
+ ```
68
+
69
+ ## Features
70
+
71
+ - **React 19** — Hooks, concurrent features, and the component model you know
72
+ - **Native GTK4 widgets** — Real native controls, not web components in a webview
73
+ - **Adwaita support** — Modern GNOME styling with Libadwaita components
74
+ - **Hot Module Replacement** — Fast refresh during development
75
+ - **TypeScript first** — Full type safety with auto-generated bindings
76
+ - **CSS-in-JS styling** — Familiar styling patterns adapted for GTK
77
+ - **Testing utilities** — Component testing similar to Testing Library
78
+
79
+ ## Examples
80
+
81
+ Explore complete applications in the [`examples/`](./examples) directory:
82
+
83
+ - **[gtk-demo](./examples/gtk-demo)** — Full replica of the official GTK demo app
84
+ - **[hello-world](./examples/hello-world)** — Minimal application showing a counter
85
+ - **[todo](./examples/todo)** — Full-featured todo application with Adwaita styling and testing
86
+ - **[deploying](./examples/deploying)** — Example of packaging and distributing a GTKX app
87
+
88
+ ## Documentation
89
+
90
+ Visit [https://eugeniodepalo.github.io/gtkx](https://eugeniodepalo.github.io/gtkx/) for the full documentation.
91
+
92
+ ## Contributing
93
+
94
+ Contributions are welcome! Please see the [contributing guidelines](./CONTRIBUTING.md) and check out the [good first issues](https://github.com/eugeniodepalo/gtkx/labels/good%20first%20issue).
95
+
96
+ ## Community
97
+
98
+ - [GitHub Discussions](https://github.com/eugeniodepalo/gtkx/discussions) — Questions, ideas, and general discussion
99
+ - [Issue Tracker](https://github.com/eugeniodepalo/gtkx/issues) — Bug reports and feature requests
100
+
101
+ ## License
102
+
103
+ [MPL-2.0](./LICENSE)
package/dist/index.d.ts CHANGED
@@ -1,2 +1 @@
1
- export type { GtkxOptions } from "./plugin.js";
2
1
  export { default } from "./plugin.js";
package/dist/plugin.d.ts CHANGED
@@ -1,34 +1,3 @@
1
1
  import type { Plugin } from "vitest/config";
2
- export interface GtkxOptions {
3
- /**
4
- * Additional setup files to run after the GTKX worker setup.
5
- * These files will be loaded after the display is configured.
6
- */
7
- setupFiles?: string[];
8
- }
9
- /**
10
- * Vitest plugin for GTKX applications.
11
- *
12
- * This plugin configures Vitest to run GTK tests with proper display isolation:
13
- * - Starts Xvfb instances for headless display (one per worker)
14
- * - Sets GTK environment variables automatically
15
- * - Configures test pool for process isolation
16
- *
17
- * When using `@gtkx/testing`, no additional setup is needed - the `render()`
18
- * function handles GTK application lifecycle automatically.
19
- *
20
- * @example
21
- * ```typescript
22
- * // vitest.config.ts
23
- * import gtkx from "@gtkx/vitest";
24
- *
25
- * export default defineConfig({
26
- * plugins: [gtkx()],
27
- * test: {
28
- * include: ["tests/**\/*.test.{ts,tsx}"],
29
- * },
30
- * });
31
- * ```
32
- */
33
- declare const gtkx: (options?: GtkxOptions) => Plugin;
2
+ declare const gtkx: () => Plugin;
34
3
  export default gtkx;
package/dist/plugin.js CHANGED
@@ -1,62 +1,121 @@
1
- import { createHash } from "node:crypto";
2
- import { tmpdir } from "node:os";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- const __dirname = dirname(fileURLToPath(import.meta.url));
6
- const getStateDir = (projectRoot) => {
7
- const hash = createHash("md5").update(projectRoot).digest("hex").slice(0, 8);
8
- return join(tmpdir(), `gtkx-vitest-${hash}`);
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { availableParallelism, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ const getStateDir = () => join(tmpdir(), `gtkx-vitest-${process.pid}`);
6
+ const getBaseDisplay = () => {
7
+ const slot = process.pid % 500;
8
+ return 50 + slot * 10;
9
9
  };
10
- /**
11
- * Vitest plugin for GTKX applications.
12
- *
13
- * This plugin configures Vitest to run GTK tests with proper display isolation:
14
- * - Starts Xvfb instances for headless display (one per worker)
15
- * - Sets GTK environment variables automatically
16
- * - Configures test pool for process isolation
17
- *
18
- * When using `@gtkx/testing`, no additional setup is needed - the `render()`
19
- * function handles GTK application lifecycle automatically.
20
- *
21
- * @example
22
- * ```typescript
23
- * // vitest.config.ts
24
- * import gtkx from "@gtkx/vitest";
25
- *
26
- * export default defineConfig({
27
- * plugins: [gtkx()],
28
- * test: {
29
- * include: ["tests/**\/*.test.{ts,tsx}"],
30
- * },
31
- * });
32
- * ```
33
- */
34
- const gtkx = (options) => {
35
- const workerSetupPath = join(__dirname, "worker-setup.js");
36
- const globalSetupPath = join(__dirname, "global-setup.js");
37
- const userSetupFiles = options?.setupFiles ?? [];
10
+ const waitForDisplay = (display, timeout = 5000) => new Promise((resolve) => {
11
+ const start = Date.now();
12
+ const check = () => {
13
+ const lockFile = `/tmp/.X${display}-lock`;
14
+ if (existsSync(lockFile)) {
15
+ resolve(true);
16
+ return;
17
+ }
18
+ if (Date.now() - start > timeout) {
19
+ resolve(false);
20
+ return;
21
+ }
22
+ setTimeout(check, 50);
23
+ };
24
+ check();
25
+ });
26
+ const startXvfb = async (display) => {
27
+ const xvfb = spawn("Xvfb", [`:${display}`, "-screen", "0", "1024x768x24"], {
28
+ stdio: "ignore",
29
+ detached: true,
30
+ });
31
+ xvfb.unref();
32
+ const ready = await waitForDisplay(display);
33
+ if (!ready) {
34
+ xvfb.kill();
35
+ return null;
36
+ }
37
+ return xvfb;
38
+ };
39
+ const gtkx = () => {
40
+ const workerSetupPath = join(import.meta.dirname, "setup.js");
41
+ const stateDir = getStateDir();
42
+ const xvfbProcesses = [];
43
+ let handlersRegistered = false;
44
+ let tornDown = false;
45
+ const setup = async (vitest) => {
46
+ const configuredWorkers = vitest.config.maxWorkers;
47
+ const maxWorkers = typeof configuredWorkers === "number" ? configuredWorkers : availableParallelism();
48
+ if (existsSync(stateDir)) {
49
+ rmSync(stateDir, { recursive: true, force: true });
50
+ }
51
+ mkdirSync(stateDir, { recursive: true });
52
+ const baseDisplay = getBaseDisplay();
53
+ const displays = [];
54
+ const results = await Promise.all(Array.from({ length: maxWorkers }, (_, i) => startXvfb(baseDisplay + i)));
55
+ for (let i = 0; i < results.length; i++) {
56
+ const xvfb = results[i];
57
+ if (xvfb) {
58
+ xvfbProcesses.push(xvfb);
59
+ displays.push(baseDisplay + i);
60
+ }
61
+ }
62
+ if (displays.length === 0) {
63
+ throw new Error("Failed to start any Xvfb instances");
64
+ }
65
+ for (const display of displays) {
66
+ writeFileSync(join(stateDir, `display-${display}.available`), "");
67
+ }
68
+ };
69
+ const teardown = () => {
70
+ if (tornDown) {
71
+ return;
72
+ }
73
+ tornDown = true;
74
+ for (const xvfb of xvfbProcesses) {
75
+ try {
76
+ xvfb.kill("SIGTERM");
77
+ }
78
+ catch { }
79
+ }
80
+ if (existsSync(stateDir)) {
81
+ rmSync(stateDir, { recursive: true, force: true });
82
+ }
83
+ };
84
+ const reporter = {
85
+ onInit() {
86
+ if (handlersRegistered) {
87
+ return;
88
+ }
89
+ handlersRegistered = true;
90
+ process.on("exit", teardown);
91
+ process.on("SIGTERM", teardown);
92
+ process.on("SIGINT", teardown);
93
+ },
94
+ async onTestRunStart(specifications) {
95
+ const firstSpec = specifications[0];
96
+ if (firstSpec && xvfbProcesses.length === 0) {
97
+ await setup(firstSpec.project.vitest);
98
+ }
99
+ },
100
+ onTestRunEnd() {
101
+ teardown();
102
+ },
103
+ };
38
104
  return {
39
105
  name: "gtkx",
40
106
  config(config) {
41
- const existingGlobalSetup = config.test?.globalSetup ?? [];
42
- const projectRoot = config.root ?? process.cwd();
43
- const stateDir = getStateDir(projectRoot);
107
+ const setupFiles = config.test?.setupFiles ?? [];
44
108
  process.env.GTKX_STATE_DIR = stateDir;
45
109
  return {
46
110
  test: {
47
- globalSetup: [
48
- ...(Array.isArray(existingGlobalSetup) ? existingGlobalSetup : [existingGlobalSetup]),
49
- globalSetupPath,
50
- ],
51
- setupFiles: [workerSetupPath, ...userSetupFiles],
111
+ setupFiles: [workerSetupPath, ...(Array.isArray(setupFiles) ? setupFiles : [setupFiles])],
52
112
  pool: "forks",
53
- maxWorkers: 1,
54
- },
55
- esbuild: {
56
- jsx: "automatic",
57
113
  },
58
114
  };
59
115
  },
116
+ configureVitest({ vitest }) {
117
+ vitest.config.reporters.push(reporter);
118
+ },
60
119
  };
61
120
  };
62
121
  export default gtkx;
@@ -1,4 +1,3 @@
1
- import { execSync } from "node:child_process";
2
1
  import { existsSync, readdirSync, renameSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
  const GTKX_STATE_DIR = process.env.GTKX_STATE_DIR;
@@ -7,8 +6,9 @@ const CLAIM_RETRY_DELAY_MS = 100;
7
6
  if (!GTKX_STATE_DIR) {
8
7
  throw new Error("GTKX_STATE_DIR not set - gtkx plugin must be used");
9
8
  }
9
+ const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
10
10
  const sleepSync = (ms) => {
11
- execSync(`sleep ${ms / 1000}`, { stdio: "ignore" });
11
+ Atomics.wait(sleepBuffer, 0, 0, ms);
12
12
  };
13
13
  const tryClaimDisplay = () => {
14
14
  if (!existsSync(GTKX_STATE_DIR)) {
@@ -45,21 +45,23 @@ const releaseDisplay = (display) => {
45
45
  }
46
46
  catch { }
47
47
  };
48
- process.env.GDK_BACKEND = "x11";
49
- process.env.GSK_RENDERER = "cairo";
50
- process.env.LIBGL_ALWAYS_SOFTWARE = "1";
51
- process.env.NO_AT_BRIDGE = "1";
52
48
  const display = claimDisplay();
53
49
  if (display === null) {
54
- throw new Error("Failed to claim display - globalSetup may not have run");
50
+ throw new Error("Failed to claim display - ensure gtkx plugin is configured");
55
51
  }
52
+ process.env.GDK_BACKEND = "x11";
53
+ process.env.GSK_RENDERER = "cairo";
54
+ process.env.LIBGL_ALWAYS_SOFTWARE = "1";
56
55
  process.env.DISPLAY = `:${display}`;
57
- process.on("exit", () => releaseDisplay(display));
58
- process.on("SIGTERM", () => {
56
+ const cleanup = () => {
59
57
  releaseDisplay(display);
60
- process.exit(0);
58
+ };
59
+ process.on("exit", cleanup);
60
+ process.on("SIGTERM", () => {
61
+ cleanup();
62
+ process.exit(143);
61
63
  });
62
64
  process.on("SIGINT", () => {
63
- releaseDisplay(display);
64
- process.exit(0);
65
+ cleanup();
66
+ process.exit(130);
65
67
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtkx/vitest",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "description": "Vitest plugin for GTKX applications with Xvfb display isolation",
5
5
  "keywords": [
6
6
  "gtk",
@@ -32,12 +32,12 @@
32
32
  "dist"
33
33
  ],
34
34
  "devDependencies": {
35
- "vitest": "^4.0.0"
35
+ "vitest": "^4.0.16"
36
36
  },
37
37
  "peerDependencies": {
38
- "vitest": ">=2.0.0"
38
+ "vitest": ">=4"
39
39
  },
40
40
  "scripts": {
41
- "build": "tsc -b"
41
+ "build": "tsc -b && cp ../../README.md ."
42
42
  }
43
43
  }
@@ -1,3 +0,0 @@
1
- import type { TestProject } from "vitest/node";
2
- export declare const setup: (project: TestProject) => Promise<void>;
3
- export declare const teardown: () => Promise<void>;
@@ -1,89 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- const getBaseDisplay = () => {
5
- const pid = process.pid;
6
- const slot = pid % 500;
7
- return 50 + slot * 10;
8
- };
9
- const xvfbProcesses = [];
10
- let currentStateDir = null;
11
- const waitForDisplay = (display, timeout = 5000) => {
12
- return new Promise((resolve) => {
13
- const start = Date.now();
14
- const check = () => {
15
- const lockFile = `/tmp/.X${display}-lock`;
16
- if (existsSync(lockFile)) {
17
- resolve(true);
18
- return;
19
- }
20
- if (Date.now() - start > timeout) {
21
- resolve(false);
22
- return;
23
- }
24
- setTimeout(check, 50);
25
- };
26
- check();
27
- });
28
- };
29
- const startXvfb = async (display) => {
30
- const xvfb = spawn("Xvfb", [`:${display}`, "-screen", "0", "1024x768x24"], {
31
- stdio: "ignore",
32
- detached: true,
33
- });
34
- xvfb.unref();
35
- const ready = await waitForDisplay(display);
36
- if (!ready) {
37
- xvfb.kill();
38
- return null;
39
- }
40
- return xvfb;
41
- };
42
- export const setup = async (project) => {
43
- const config = project.config;
44
- const maxWorkers = typeof config.maxWorkers === "number"
45
- ? config.maxWorkers
46
- : typeof config.maxWorkers === "string"
47
- ? Number.parseInt(config.maxWorkers, 10)
48
- : 4;
49
- const stateDir = process.env.GTKX_STATE_DIR;
50
- if (!stateDir) {
51
- throw new Error("Expected GTKX_STATE_DIR environment variable to be set");
52
- }
53
- currentStateDir = stateDir;
54
- if (existsSync(stateDir)) {
55
- rmSync(stateDir, { recursive: true });
56
- }
57
- mkdirSync(stateDir, { recursive: true });
58
- const baseDisplay = getBaseDisplay();
59
- const displays = [];
60
- const pids = [];
61
- for (let i = 0; i < maxWorkers; i++) {
62
- const display = baseDisplay + i;
63
- const xvfb = await startXvfb(display);
64
- if (xvfb) {
65
- xvfbProcesses.push(xvfb);
66
- displays.push(display);
67
- pids.push(xvfb.pid ?? 0);
68
- }
69
- }
70
- if (displays.length === 0) {
71
- throw new Error("Failed to start any Xvfb instances");
72
- }
73
- const state = { displays, pids };
74
- writeFileSync(join(stateDir, "state.json"), JSON.stringify(state));
75
- for (const display of displays) {
76
- writeFileSync(join(stateDir, `display-${display}.available`), "");
77
- }
78
- };
79
- export const teardown = async () => {
80
- for (const xvfb of xvfbProcesses) {
81
- try {
82
- xvfb.kill("SIGTERM");
83
- }
84
- catch { }
85
- }
86
- if (currentStateDir && existsSync(currentStateDir)) {
87
- rmSync(currentStateDir, { recursive: true });
88
- }
89
- };
File without changes