@gtkx/vitest 0.20.0 → 1.0.0-rc.1

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/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,kBAAkB,MAAM,0BAA0B,CAAC;AAE1D,OAAO,EAA2C,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAIrG,MAAM,CAAC,MAAM,gBAAgB,GAAa,CAAC,mBAAmB,EAAE,kBAAkB,CAAC,CAAC;AAQpF,MAAM,gBAAgB,GAAG,GAAQ,EAAE;IAC/B,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,EAAE,mBAAmB,CAAC,CAAC;IAC/D,MAAM,IAAI,GAAG,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,mBAAmB,CAAC,CAAC;IAC1G,OAAO,aAAa,CAAC,IAAI,CAAC,CAAC;AAC/B,CAAC,CAAC;AAEF,MAAM,wBAAwB,GAAG,CAAC,OAA0B,EAAU,EAAE;IACpE,MAAM,GAAG,GAAG,gBAAgB,EAAE,CAAC;IAC/B,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACjD,IAAI,KAAK,KAAK,SAAS;YAAE,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAC9D,CAAC;IACD,OAAO,GAAG,CAAC,IAAI,CAAC;AACpB,CAAC,CAAC;AAEF,MAAM,eAAe,GAAG,GAAW,EAAE;IACjC,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,EAAE,iBAAiB,CAAC,CAAC;IAC7D,OAAO,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,iBAAiB,CAAC,CAAC;AACtG,CAAC,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,IAAI,GAAG,CAAC,OAAO,GAAsB,EAAE,EAAU,EAAE,CACrD,kBAAkB,CAAC;IACf,IAAI,EAAE,aAAa;IACnB,MAAM;QACF,OAAO;YACH,IAAI,EAAE;gBACF,OAAO,EAAE,IAAI;gBACb,QAAQ,EAAE,CAAC,UAAU,EAAE,wBAAwB,CAAC,OAAO,CAAC,CAAC;gBACzD,UAAU,EAAE,CAAC,eAAe,EAAE,CAAC;gBAC/B,WAAW,EAAE,KAAK;gBAClB,WAAW,EAAE,KAAK;gBAClB,IAAI,EAAE,OAAO;gBACb,GAAG,EAAE,mBAAmB;gBACxB,MAAM,EAAE;oBACJ,IAAI,EAAE;wBACF,MAAM,EAAE,gBAAgB;qBAC3B;iBACJ;aACJ;SACJ,CAAC;IACN,CAAC;CACJ,CAAC,CAAC;AAEP,eAAe,IAAI,CAAC","sourcesContent":["import { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\n\nimport createConfigPlugin from \"@gtkx/config/vite-plugin\";\nimport type { Plugin } from \"vitest/config\";\nimport { type CompositorId, type HeadlessOptions, STATIC_HEADLESS_ENV } from \"./headless-display.js\";\n\nexport type { CompositorId, HeadlessOptions };\n\nexport const GTKX_INLINE_DEPS: RegExp[] = [/@gtkx\\/(?!native)/, /[/\\\\]\\.gtkx[/\\\\]/];\n\n/**\n * Options accepted by the {@link gtkx} Vitest plugin. Every headless display\n * setting is optional and falls back to a built-in default when omitted.\n */\nexport type GtkxPluginOptions = Partial<HeadlessOptions>;\n\nconst workerPreloadUrl = (): URL => {\n const sibling = join(import.meta.dirname, \"worker-preload.js\");\n const path = existsSync(sibling) ? sibling : join(import.meta.dirname, \"..\", \"dist\", \"worker-preload.js\");\n return pathToFileURL(path);\n};\n\nconst headlessPreloadSpecifier = (options: GtkxPluginOptions): string => {\n const url = workerPreloadUrl();\n for (const [key, value] of Object.entries(options)) {\n if (value !== undefined) url.searchParams.set(key, value);\n }\n return url.href;\n};\n\nconst workerSetupPath = (): string => {\n const sibling = join(import.meta.dirname, \"worker-setup.js\");\n return existsSync(sibling) ? sibling : join(import.meta.dirname, \"..\", \"dist\", \"worker-setup.js\");\n};\n\n/**\n * Vitest plugin that runs each test worker against its own isolated headless\n * Wayland display. It configures the forks pool, injects the worker preload and\n * setup files, and sets the environment needed for headless GTK4 rendering.\n *\n * @param options Headless display settings (size, compositor) forwarded to each worker.\n * @returns A Vitest config plugin.\n */\nconst gtkx = (options: GtkxPluginOptions = {}): Plugin =>\n createConfigPlugin({\n name: \"gtkx:vitest\",\n config() {\n return {\n test: {\n globals: true,\n execArgv: [\"--import\", headlessPreloadSpecifier(options)],\n setupFiles: [workerSetupPath()],\n testTimeout: 30000,\n hookTimeout: 30000,\n pool: \"forks\",\n env: STATIC_HEADLESS_ENV,\n server: {\n deps: {\n inline: GTKX_INLINE_DEPS,\n },\n },\n },\n };\n },\n });\n\nexport default gtkx;\n"]}
@@ -0,0 +1,2 @@
1
+ export declare const startNotificationService: (busAddress: string) => Promise<() => void>;
2
+ //# sourceMappingURL=notification-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification-service.d.ts","sourceRoot":"","sources":["../src/notification-service.ts"],"names":[],"mappings":"AAyCA,eAAO,MAAM,wBAAwB,eAAsB,MAAM,KAAG,OAAO,CAAC,MAAM,IAAI,CAYrF,CAAC"}
@@ -0,0 +1,47 @@
1
+ /// <reference path="./dbus-native.d.ts" />
2
+ import { EventEmitter } from "node:events";
3
+ import { sessionBus } from "@homebridge/dbus-native";
4
+ const NOTIFICATIONS_NAME = "org.freedesktop.Notifications";
5
+ const NOTIFICATIONS_PATH = "/org/freedesktop/Notifications";
6
+ const DESCRIPTOR = {
7
+ name: NOTIFICATIONS_NAME,
8
+ methods: {
9
+ Notify: [
10
+ "susssasa{sv}i",
11
+ "u",
12
+ ["app_name", "replaces_id", "app_icon", "summary", "body", "actions", "hints", "expire_timeout"],
13
+ ["id"],
14
+ ],
15
+ CloseNotification: ["u", "", ["id"], []],
16
+ GetCapabilities: ["", "as", [], ["capabilities"]],
17
+ },
18
+ signals: {
19
+ NotificationClosed: ["uu", "id", "reason"],
20
+ ActionInvoked: ["us", "id", "action_key"],
21
+ },
22
+ };
23
+ class NotificationService extends EventEmitter {
24
+ lastId = 0;
25
+ Notify() {
26
+ this.lastId += 1;
27
+ return this.lastId;
28
+ }
29
+ CloseNotification() { }
30
+ GetCapabilities() {
31
+ return ["body", "actions"];
32
+ }
33
+ }
34
+ export const startNotificationService = async (busAddress) => {
35
+ const bus = sessionBus({ busAddress });
36
+ bus.exportInterface(new NotificationService(), NOTIFICATIONS_PATH, DESCRIPTOR);
37
+ await new Promise((resolve, reject) => {
38
+ bus.requestName(NOTIFICATIONS_NAME, 0, (error) => {
39
+ if (error)
40
+ reject(error);
41
+ else
42
+ resolve();
43
+ });
44
+ });
45
+ return () => bus.connection.stream.destroy();
46
+ };
47
+ //# sourceMappingURL=notification-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification-service.js","sourceRoot":"","sources":["../src/notification-service.ts"],"names":[],"mappings":"AAAA,2CAA2C;AAE3C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAA4B,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAE/E,MAAM,kBAAkB,GAAG,+BAA+B,CAAC;AAC3D,MAAM,kBAAkB,GAAG,gCAAgC,CAAC;AAE5D,MAAM,UAAU,GAAwB;IACpC,IAAI,EAAE,kBAAkB;IACxB,OAAO,EAAE;QACL,MAAM,EAAE;YACJ,eAAe;YACf,GAAG;YACH,CAAC,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,gBAAgB,CAAC;YAChG,CAAC,IAAI,CAAC;SACT;QACD,iBAAiB,EAAE,CAAC,GAAG,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;QACxC,eAAe,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,cAAc,CAAC,CAAC;KACpD;IACD,OAAO,EAAE;QACL,kBAAkB,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,CAAC;QAC1C,aAAa,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,YAAY,CAAC;KAC5C;CACJ,CAAC;AAEF,MAAM,mBAAoB,SAAQ,YAAY;IAClC,MAAM,GAAG,CAAC,CAAC;IAEnB,MAAM;QACF,IAAI,CAAC,MAAM,IAAI,CAAC,CAAC;QACjB,OAAO,IAAI,CAAC,MAAM,CAAC;IACvB,CAAC;IAED,iBAAiB,KAAU,CAAC;IAE5B,eAAe;QACX,OAAO,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAC/B,CAAC;CACJ;AAED,MAAM,CAAC,MAAM,wBAAwB,GAAG,KAAK,EAAE,UAAkB,EAAuB,EAAE;IACtF,MAAM,GAAG,GAAG,UAAU,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC;IACvC,GAAG,CAAC,eAAe,CAAC,IAAI,mBAAmB,EAAE,EAAE,kBAAkB,EAAE,UAAU,CAAC,CAAC;IAE/E,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACxC,GAAG,CAAC,WAAW,CAAC,kBAAkB,EAAE,CAAC,EAAE,CAAC,KAAK,EAAE,EAAE;YAC7C,IAAI,KAAK;gBAAE,MAAM,CAAC,KAAK,CAAC,CAAC;;gBACpB,OAAO,EAAE,CAAC;QACnB,CAAC,CAAC,CAAC;IACP,CAAC,CAAC,CAAC;IAEH,OAAO,GAAG,EAAE,CAAC,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;AACjD,CAAC,CAAC","sourcesContent":["/// <reference path=\"./dbus-native.d.ts\" />\n\nimport { EventEmitter } from \"node:events\";\nimport { type InterfaceDescriptor, sessionBus } from \"@homebridge/dbus-native\";\n\nconst NOTIFICATIONS_NAME = \"org.freedesktop.Notifications\";\nconst NOTIFICATIONS_PATH = \"/org/freedesktop/Notifications\";\n\nconst DESCRIPTOR: InterfaceDescriptor = {\n name: NOTIFICATIONS_NAME,\n methods: {\n Notify: [\n \"susssasa{sv}i\",\n \"u\",\n [\"app_name\", \"replaces_id\", \"app_icon\", \"summary\", \"body\", \"actions\", \"hints\", \"expire_timeout\"],\n [\"id\"],\n ],\n CloseNotification: [\"u\", \"\", [\"id\"], []],\n GetCapabilities: [\"\", \"as\", [], [\"capabilities\"]],\n },\n signals: {\n NotificationClosed: [\"uu\", \"id\", \"reason\"],\n ActionInvoked: [\"us\", \"id\", \"action_key\"],\n },\n};\n\nclass NotificationService extends EventEmitter {\n private lastId = 0;\n\n Notify(): number {\n this.lastId += 1;\n return this.lastId;\n }\n\n CloseNotification(): void {}\n\n GetCapabilities(): string[] {\n return [\"body\", \"actions\"];\n }\n}\n\nexport const startNotificationService = async (busAddress: string): Promise<() => void> => {\n const bus = sessionBus({ busAddress });\n bus.exportInterface(new NotificationService(), NOTIFICATIONS_PATH, DESCRIPTOR);\n\n await new Promise<void>((resolve, reject) => {\n bus.requestName(NOTIFICATIONS_NAME, 0, (error) => {\n if (error) reject(error);\n else resolve();\n });\n });\n\n return () => bus.connection.stream.destroy();\n};\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=worker-preload.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-preload.d.ts","sourceRoot":"","sources":["../src/worker-preload.ts"],"names":[],"mappings":""}
@@ -0,0 +1,6 @@
1
+ import { readHeadlessOptions, resolveHeadlessOptions, startHeadlessDisplay } from "./headless-display.js";
2
+ const options = readHeadlessOptions(new URL(import.meta.url).searchParams);
3
+ const teardown = await startHeadlessDisplay(resolveHeadlessOptions(options));
4
+ process.on("exit", teardown);
5
+ globalThis.gtkxHeadlessTeardown = teardown;
6
+ //# sourceMappingURL=worker-preload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-preload.js","sourceRoot":"","sources":["../src/worker-preload.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAE1G,MAAM,OAAO,GAAG,mBAAmB,CAAC,IAAI,GAAG,CAAC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,YAAY,CAAC,CAAC;AAC3E,MAAM,QAAQ,GAAG,MAAM,oBAAoB,CAAC,sBAAsB,CAAC,OAAO,CAAC,CAAC,CAAC;AAE7E,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;AAC7B,UAAU,CAAC,oBAAoB,GAAG,QAAQ,CAAC","sourcesContent":["import { readHeadlessOptions, resolveHeadlessOptions, startHeadlessDisplay } from \"./headless-display.js\";\n\nconst options = readHeadlessOptions(new URL(import.meta.url).searchParams);\nconst teardown = await startHeadlessDisplay(resolveHeadlessOptions(options));\n\nprocess.on(\"exit\", teardown);\nglobalThis.gtkxHeadlessTeardown = teardown;\n"]}
@@ -0,0 +1,6 @@
1
+ declare global {
2
+ var gtkxHeadlessTeardown: (() => void) | undefined;
3
+ var gtkxHeadlessShutdownInstalled: boolean | undefined;
4
+ }
5
+ export declare const installHeadlessShutdown: () => void;
6
+ //# sourceMappingURL=worker-setup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-setup.d.ts","sourceRoot":"","sources":["../src/worker-setup.ts"],"names":[],"mappings":"AAEA,OAAO,CAAC,MAAM,CAAC;IACX,IAAI,oBAAoB,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC;IACnD,IAAI,6BAA6B,EAAE,OAAO,GAAG,SAAS,CAAC;CAC1D;AAED,eAAO,MAAM,uBAAuB,QAAO,IAK1C,CAAC"}
@@ -0,0 +1,10 @@
1
+ import { installGracefulShutdown } from "@gtkx/utils";
2
+ export const installHeadlessShutdown = () => {
3
+ const teardown = globalThis.gtkxHeadlessTeardown;
4
+ if (teardown === undefined || globalThis.gtkxHeadlessShutdownInstalled === true)
5
+ return;
6
+ globalThis.gtkxHeadlessShutdownInstalled = true;
7
+ installGracefulShutdown({ onSignal: teardown });
8
+ };
9
+ installHeadlessShutdown();
10
+ //# sourceMappingURL=worker-setup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-setup.js","sourceRoot":"","sources":["../src/worker-setup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AAOtD,MAAM,CAAC,MAAM,uBAAuB,GAAG,GAAS,EAAE;IAC9C,MAAM,QAAQ,GAAG,UAAU,CAAC,oBAAoB,CAAC;IACjD,IAAI,QAAQ,KAAK,SAAS,IAAI,UAAU,CAAC,6BAA6B,KAAK,IAAI;QAAE,OAAO;IACxF,UAAU,CAAC,6BAA6B,GAAG,IAAI,CAAC;IAChD,uBAAuB,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC,CAAC;AACpD,CAAC,CAAC;AAEF,uBAAuB,EAAE,CAAC","sourcesContent":["import { installGracefulShutdown } from \"@gtkx/utils\";\n\ndeclare global {\n var gtkxHeadlessTeardown: (() => void) | undefined;\n var gtkxHeadlessShutdownInstalled: boolean | undefined;\n}\n\nexport const installHeadlessShutdown = (): void => {\n const teardown = globalThis.gtkxHeadlessTeardown;\n if (teardown === undefined || globalThis.gtkxHeadlessShutdownInstalled === true) return;\n globalThis.gtkxHeadlessShutdownInstalled = true;\n installGracefulShutdown({ onSignal: teardown });\n};\n\ninstallHeadlessShutdown();\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gtkx/vitest",
3
- "version": "0.20.0",
4
- "description": "Vitest plugin for GTKX applications with Xvfb display isolation",
3
+ "version": "1.0.0-rc.1",
4
+ "description": "Vitest plugin for GTKX applications with headless Wayland display isolation",
5
5
  "keywords": [
6
6
  "gtkx",
7
7
  "gtk",
@@ -9,7 +9,7 @@
9
9
  "vitest",
10
10
  "testing",
11
11
  "plugin",
12
- "xvfb",
12
+ "wayland",
13
13
  "linux",
14
14
  "desktop"
15
15
  ],
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "https://github.com/gtkx-org/gtkx.git",
22
+ "url": "git+https://github.com/gtkx-org/gtkx.git",
23
23
  "directory": "packages/vitest"
24
24
  },
25
25
  "license": "MPL-2.0",
@@ -37,14 +37,23 @@
37
37
  "dist",
38
38
  "src"
39
39
  ],
40
+ "engines": {
41
+ "node": ">=24"
42
+ },
43
+ "dependencies": {
44
+ "@homebridge/dbus-native": "^0.7.7",
45
+ "@gtkx/config": "1.0.0-rc.1",
46
+ "@gtkx/utils": "1.0.0-rc.1"
47
+ },
40
48
  "devDependencies": {
41
- "vitest": "^4.0.18"
49
+ "vitest": "^4.1.10"
42
50
  },
43
51
  "peerDependencies": {
44
52
  "vitest": ">=4"
45
53
  },
46
54
  "scripts": {
47
- "build": "tsc -b && cp ../../README.md .",
48
- "typecheck": "tsc -b --emitDeclarationOnly"
55
+ "build": "tsc -b tsconfig.lib.json",
56
+ "typecheck": "tsc -b --emitDeclarationOnly",
57
+ "release": "tsx ../../scripts/release-package.ts"
49
58
  }
50
59
  }
@@ -0,0 +1,17 @@
1
+ import "@homebridge/dbus-native";
2
+
3
+ declare module "@homebridge/dbus-native" {
4
+ export type InterfaceDescriptor = {
5
+ name: string;
6
+ methods: { [member: string]: [string, string, string[], string[]] };
7
+ signals?: { [member: string]: [string, ...string[]] };
8
+ properties?: { [name: string]: string };
9
+ };
10
+
11
+ export interface MessageBus {
12
+ exportInterface(implementation: object, path: string, descriptor: InterfaceDescriptor): void;
13
+ requestName(name: string, flags: number, callback: (error: Error | null, result?: number) => void): void;
14
+ }
15
+
16
+ export function sessionBus(options?: { busAddress?: string }): MessageBus;
17
+ }
@@ -0,0 +1,330 @@
1
+ import { type ChildProcess, type StdioOptions, spawn, spawnSync } from "node:child_process";
2
+ import { chmodSync, createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { Socket } from "node:net";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { startNotificationService } from "./notification-service.js";
7
+
8
+ /**
9
+ * Wayland compositors that can back a headless display.
10
+ */
11
+ export type CompositorId = "sway" | "weston";
12
+
13
+ export const DEFAULT_HEADLESS_SIZE = "1024x768";
14
+
15
+ /**
16
+ * Settings for the per-worker headless Wayland display.
17
+ */
18
+ export type HeadlessOptions = {
19
+ /** Output resolution as a "WIDTHxHEIGHT" string, for example "1024x768". */
20
+ size: string;
21
+ /** Wayland compositor used to back the headless display. */
22
+ compositor: CompositorId;
23
+ };
24
+
25
+ const DEFAULT_HEADLESS_COMPOSITOR: CompositorId = "weston";
26
+
27
+ export const resolveHeadlessOptions = (provided: Partial<HeadlessOptions>): HeadlessOptions => ({
28
+ size: provided.size ?? DEFAULT_HEADLESS_SIZE,
29
+ compositor: provided.compositor ?? DEFAULT_HEADLESS_COMPOSITOR,
30
+ });
31
+
32
+ type EnvSnapshot = { [name: string]: string | undefined };
33
+
34
+ const applyEnv = (snapshot: EnvSnapshot, values: { [name: string]: string }): void => {
35
+ for (const [name, value] of Object.entries(values)) {
36
+ if (!(name in snapshot)) snapshot[name] = process.env[name];
37
+ process.env[name] = value;
38
+ }
39
+ };
40
+
41
+ const restoreEnv = (snapshot: EnvSnapshot): void => {
42
+ for (const [name, previous] of Object.entries(snapshot)) {
43
+ if (previous === undefined) delete process.env[name];
44
+ else process.env[name] = previous;
45
+ }
46
+ };
47
+
48
+ const spawnWithParentDeathSignal = (command: string, args: string[], stdio: StdioOptions): ChildProcess => {
49
+ const child = spawn("setpriv", ["--pdeathsig", "SIGKILL", command, ...args], { stdio });
50
+ child.unref();
51
+ return child;
52
+ };
53
+
54
+ let westonFakeSeatSupport: boolean | undefined;
55
+
56
+ const westonSupportsFakeSeat = (): boolean => {
57
+ if (westonFakeSeatSupport === undefined) {
58
+ const help = spawnSync("weston", ["--help"], { encoding: "utf8" });
59
+ westonFakeSeatSupport = `${help.stdout}${help.stderr}`.includes("--fake-seat");
60
+ }
61
+ return westonFakeSeatSupport;
62
+ };
63
+
64
+ type CompositorDescriptor = {
65
+ socket: string;
66
+ env: { [name: string]: string };
67
+ start: (runtimeDir: string, width: string, height: string) => ChildProcess;
68
+ };
69
+
70
+ const compositorRegistry: { [K in CompositorId]: CompositorDescriptor } = {
71
+ sway: {
72
+ socket: "wayland-1",
73
+ env: {
74
+ WLR_BACKENDS: "headless",
75
+ WLR_RENDERER: "pixman",
76
+ WLR_RENDERER_ALLOW_SOFTWARE: "1",
77
+ WLR_LIBINPUT_NO_DEVICES: "1",
78
+ WLR_HEADLESS_OUTPUTS: "1",
79
+ },
80
+ start: (runtimeDir, width, height) => {
81
+ const configPath = join(runtimeDir, "sway.conf");
82
+ writeFileSync(
83
+ configPath,
84
+ [
85
+ "xwayland disable",
86
+ "default_border none",
87
+ "default_floating_border none",
88
+ `output HEADLESS-1 resolution ${width}x${height}`,
89
+ "output HEADLESS-1 bg #000000 solid_color",
90
+ 'for_window [app_id=".*"] floating enable, border none',
91
+ 'for_window [title=".*"] floating enable, border none',
92
+ "",
93
+ ].join("\n"),
94
+ );
95
+ return spawnWithParentDeathSignal("sway", ["-c", configPath], ["ignore", "ignore", "pipe"]);
96
+ },
97
+ },
98
+ weston: {
99
+ socket: "wayland-0",
100
+ env: {},
101
+ start: (_runtimeDir, width, height) =>
102
+ spawnWithParentDeathSignal(
103
+ "weston",
104
+ [
105
+ "--backend=headless",
106
+ "--renderer=pixman",
107
+ ...(westonSupportsFakeSeat() ? ["--fake-seat"] : []),
108
+ `--width=${width}`,
109
+ `--height=${height}`,
110
+ "--socket=wayland-0",
111
+ ],
112
+ ["ignore", "ignore", "pipe"],
113
+ ),
114
+ },
115
+ };
116
+
117
+ const isCompositorId = (value: string): value is CompositorId => Object.hasOwn(compositorRegistry, value);
118
+
119
+ export const readHeadlessOptions = (params: URLSearchParams): Partial<HeadlessOptions> => {
120
+ const options: Partial<HeadlessOptions> = {};
121
+ const size = params.get("size");
122
+ if (size !== null) options.size = size;
123
+ const compositor = params.get("compositor");
124
+ if (compositor !== null && isCompositorId(compositor)) options.compositor = compositor;
125
+ return options;
126
+ };
127
+
128
+ type SpawnedCompositor = {
129
+ child: ChildProcess;
130
+ socket: string;
131
+ };
132
+
133
+ const startCompositor = (runtimeDir: string, options: HeadlessOptions, env: EnvSnapshot): SpawnedCompositor => {
134
+ const descriptor = compositorRegistry[options.compositor];
135
+
136
+ const [width = "", height = ""] = options.size.split("x");
137
+ applyEnv(env, descriptor.env);
138
+
139
+ return { child: descriptor.start(runtimeDir, width, height), socket: descriptor.socket };
140
+ };
141
+
142
+ const writeBusConfig = (busConfigPath: string, busSocketPath: string): void => {
143
+ writeFileSync(
144
+ busConfigPath,
145
+ `<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
146
+ <busconfig>
147
+ <type>session</type>
148
+ <listen>unix:path=${busSocketPath}</listen>
149
+ <auth>EXTERNAL</auth>
150
+ <policy context="default">
151
+ <allow send_destination="*" eavesdrop="true"/>
152
+ <allow eavesdrop="true"/>
153
+ <allow own="*"/>
154
+ </policy>
155
+ </busconfig>`,
156
+ );
157
+ };
158
+
159
+ type WaitForSocketOptions = {
160
+ label: string;
161
+ timeout?: number;
162
+ child?: ChildProcess;
163
+ signal?: AbortSignal;
164
+ };
165
+
166
+ const waitForSocket = (path: string, { label, timeout = 15000, child, signal }: WaitForSocketOptions): Promise<void> =>
167
+ new Promise((resolve, reject) => {
168
+ let log = "";
169
+ const stderr = child?.stderr ?? null;
170
+ stderr?.setEncoding("utf8");
171
+ stderr?.on("data", (chunk: string) => {
172
+ log += chunk;
173
+ });
174
+ let timer: ReturnType<typeof setTimeout>;
175
+ let poll: ReturnType<typeof setInterval>;
176
+ const stopListening = (): void => {
177
+ clearTimeout(timer);
178
+ clearInterval(poll);
179
+ child?.removeListener("exit", onExit);
180
+ child?.removeListener("error", onError);
181
+ signal?.removeEventListener("abort", onAbort);
182
+ stderr?.removeAllListeners("data");
183
+ stderr?.resume();
184
+ if (stderr instanceof Socket) stderr.unref();
185
+ };
186
+ const onExit = (code: number | null, terminationSignal: NodeJS.Signals | null): void => {
187
+ stopListening();
188
+ reject(
189
+ new Error(
190
+ `${label} exited (code ${code ?? "null"}, signal ${terminationSignal ?? "null"}) before ${path} appeared\n${log}`,
191
+ ),
192
+ );
193
+ };
194
+ const onError = (cause: Error): void => {
195
+ stopListening();
196
+ reject(new Error(`${label} failed to spawn: ${cause.message}\n${log}`));
197
+ };
198
+ const onAbort = (): void => {
199
+ stopListening();
200
+ reject(new Error(`${label} startup aborted before ${path} appeared`));
201
+ };
202
+ poll = setInterval(() => {
203
+ if (existsSync(path)) {
204
+ stopListening();
205
+ resolve();
206
+ }
207
+ }, 50);
208
+ timer = setTimeout(() => {
209
+ stopListening();
210
+ const suffix = child ? `\n${log}` : "";
211
+ reject(new Error(`${label} did not become available within ${timeout}ms${suffix}`));
212
+ }, timeout);
213
+ child?.on("exit", onExit);
214
+ child?.on("error", onError);
215
+ if (signal) {
216
+ if (signal.aborted) {
217
+ onAbort();
218
+ return;
219
+ }
220
+ signal.addEventListener("abort", onAbort);
221
+ }
222
+ });
223
+
224
+ export const STATIC_HEADLESS_ENV = {
225
+ GDK_BACKEND: "wayland",
226
+ GDK_DISABLE: "vulkan",
227
+ GDK_DEBUG: "no-vsync",
228
+ GSK_RENDERER: "cairo",
229
+ GTK_A11Y: "test",
230
+ LIBGL_ALWAYS_SOFTWARE: "1",
231
+ GST_GL_WINDOW: "none",
232
+ GSETTINGS_BACKEND: "memory",
233
+ ALSOFT_DRIVERS: "null",
234
+ ALSOFT_LOGLEVEL: "0",
235
+ };
236
+
237
+ const captureCompositorStderr = (child: ChildProcess, logPath: string): string[] => {
238
+ const captured: string[] = [];
239
+ const stderr = child.stderr;
240
+ if (stderr !== null) {
241
+ stderr.setEncoding("utf8");
242
+ const logStream = createWriteStream(logPath);
243
+ logStream.on("error", () => {});
244
+ stderr.on("data", (chunk: string) => {
245
+ captured.push(chunk);
246
+ logStream.write(chunk);
247
+ });
248
+ }
249
+ return captured;
250
+ };
251
+
252
+ const reportUnexpectedCompositorExit = (child: ChildProcess, capturedStderr: string[]): void => {
253
+ if (child.exitCode === null && child.signalCode === null) return;
254
+ process.stderr.write(
255
+ `[gtkx] headless compositor died before teardown (code ${child.exitCode ?? "null"}, ` +
256
+ `signal ${child.signalCode ?? "null"}); the worker's Wayland client was severed.\n${capturedStderr.join("")}`,
257
+ );
258
+ };
259
+
260
+ const makeTeardown = (
261
+ compositor: ChildProcess,
262
+ capturedStderr: string[],
263
+ stopNotifications: () => void,
264
+ removeRuntime: () => void,
265
+ ): (() => void) => {
266
+ let torndown = false;
267
+ return (): void => {
268
+ if (torndown) return;
269
+ torndown = true;
270
+ reportUnexpectedCompositorExit(compositor, capturedStderr);
271
+ stopNotifications();
272
+ removeRuntime();
273
+ };
274
+ };
275
+
276
+ export const startHeadlessDisplay = async (options: HeadlessOptions): Promise<() => void> => {
277
+ const env: EnvSnapshot = {};
278
+ const runtimeDir = mkdtempSync(join(tmpdir(), "gtkx-xdg-"));
279
+ chmodSync(runtimeDir, 0o700);
280
+ const spawned: ChildProcess[] = [];
281
+
282
+ const removeRuntime = (): void => {
283
+ restoreEnv(env);
284
+ rmSync(runtimeDir, { recursive: true, force: true });
285
+ };
286
+
287
+ try {
288
+ applyEnv(env, { XDG_RUNTIME_DIR: runtimeDir });
289
+
290
+ const busConfigPath = join(runtimeDir, "session.conf");
291
+ const busSocketPath = join(runtimeDir, "bus");
292
+ writeBusConfig(busConfigPath, busSocketPath);
293
+
294
+ const busChild = spawnWithParentDeathSignal(
295
+ "dbus-daemon",
296
+ [`--config-file=${busConfigPath}`],
297
+ ["ignore", "ignore", "pipe"],
298
+ );
299
+ spawned.push(busChild);
300
+ applyEnv(env, { DBUS_SESSION_BUS_ADDRESS: `unix:path=${busSocketPath}` });
301
+
302
+ const compositor = startCompositor(runtimeDir, options, env);
303
+ spawned.push(compositor.child);
304
+
305
+ applyEnv(env, { WAYLAND_DISPLAY: compositor.socket });
306
+
307
+ const abort = new AbortController();
308
+ try {
309
+ await Promise.all([
310
+ waitForSocket(join(runtimeDir, compositor.socket), {
311
+ label: "Compositor",
312
+ child: compositor.child,
313
+ signal: abort.signal,
314
+ }),
315
+ waitForSocket(busSocketPath, { label: "D-Bus session bus", child: busChild, signal: abort.signal }),
316
+ ]);
317
+ } finally {
318
+ abort.abort();
319
+ }
320
+
321
+ const stopNotifications = await startNotificationService(`unix:path=${busSocketPath}`);
322
+
323
+ const capturedStderr = captureCompositorStderr(compositor.child, join(runtimeDir, "weston.stderr.log"));
324
+ return makeTeardown(compositor.child, capturedStderr, stopNotifications, removeRuntime);
325
+ } catch (cause) {
326
+ for (const child of spawned) child.kill("SIGKILL");
327
+ removeRuntime();
328
+ throw cause;
329
+ }
330
+ };
package/src/index.ts CHANGED
@@ -1 +1,69 @@
1
- export { default } from "./plugin.js";
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ import createConfigPlugin from "@gtkx/config/vite-plugin";
6
+ import type { Plugin } from "vitest/config";
7
+ import { type CompositorId, type HeadlessOptions, STATIC_HEADLESS_ENV } from "./headless-display.js";
8
+
9
+ export type { CompositorId, HeadlessOptions };
10
+
11
+ export const GTKX_INLINE_DEPS: RegExp[] = [/@gtkx\/(?!native)/, /[/\\]\.gtkx[/\\]/];
12
+
13
+ /**
14
+ * Options accepted by the {@link gtkx} Vitest plugin. Every headless display
15
+ * setting is optional and falls back to a built-in default when omitted.
16
+ */
17
+ export type GtkxPluginOptions = Partial<HeadlessOptions>;
18
+
19
+ const workerPreloadUrl = (): URL => {
20
+ const sibling = join(import.meta.dirname, "worker-preload.js");
21
+ const path = existsSync(sibling) ? sibling : join(import.meta.dirname, "..", "dist", "worker-preload.js");
22
+ return pathToFileURL(path);
23
+ };
24
+
25
+ const headlessPreloadSpecifier = (options: GtkxPluginOptions): string => {
26
+ const url = workerPreloadUrl();
27
+ for (const [key, value] of Object.entries(options)) {
28
+ if (value !== undefined) url.searchParams.set(key, value);
29
+ }
30
+ return url.href;
31
+ };
32
+
33
+ const workerSetupPath = (): string => {
34
+ const sibling = join(import.meta.dirname, "worker-setup.js");
35
+ return existsSync(sibling) ? sibling : join(import.meta.dirname, "..", "dist", "worker-setup.js");
36
+ };
37
+
38
+ /**
39
+ * Vitest plugin that runs each test worker against its own isolated headless
40
+ * Wayland display. It configures the forks pool, injects the worker preload and
41
+ * setup files, and sets the environment needed for headless GTK4 rendering.
42
+ *
43
+ * @param options Headless display settings (size, compositor) forwarded to each worker.
44
+ * @returns A Vitest config plugin.
45
+ */
46
+ const gtkx = (options: GtkxPluginOptions = {}): Plugin =>
47
+ createConfigPlugin({
48
+ name: "gtkx:vitest",
49
+ config() {
50
+ return {
51
+ test: {
52
+ globals: true,
53
+ execArgv: ["--import", headlessPreloadSpecifier(options)],
54
+ setupFiles: [workerSetupPath()],
55
+ testTimeout: 30000,
56
+ hookTimeout: 30000,
57
+ pool: "forks",
58
+ env: STATIC_HEADLESS_ENV,
59
+ server: {
60
+ deps: {
61
+ inline: GTKX_INLINE_DEPS,
62
+ },
63
+ },
64
+ },
65
+ };
66
+ },
67
+ });
68
+
69
+ export default gtkx;
@@ -0,0 +1,54 @@
1
+ /// <reference path="./dbus-native.d.ts" />
2
+
3
+ import { EventEmitter } from "node:events";
4
+ import { type InterfaceDescriptor, sessionBus } from "@homebridge/dbus-native";
5
+
6
+ const NOTIFICATIONS_NAME = "org.freedesktop.Notifications";
7
+ const NOTIFICATIONS_PATH = "/org/freedesktop/Notifications";
8
+
9
+ const DESCRIPTOR: InterfaceDescriptor = {
10
+ name: NOTIFICATIONS_NAME,
11
+ methods: {
12
+ Notify: [
13
+ "susssasa{sv}i",
14
+ "u",
15
+ ["app_name", "replaces_id", "app_icon", "summary", "body", "actions", "hints", "expire_timeout"],
16
+ ["id"],
17
+ ],
18
+ CloseNotification: ["u", "", ["id"], []],
19
+ GetCapabilities: ["", "as", [], ["capabilities"]],
20
+ },
21
+ signals: {
22
+ NotificationClosed: ["uu", "id", "reason"],
23
+ ActionInvoked: ["us", "id", "action_key"],
24
+ },
25
+ };
26
+
27
+ class NotificationService extends EventEmitter {
28
+ private lastId = 0;
29
+
30
+ Notify(): number {
31
+ this.lastId += 1;
32
+ return this.lastId;
33
+ }
34
+
35
+ CloseNotification(): void {}
36
+
37
+ GetCapabilities(): string[] {
38
+ return ["body", "actions"];
39
+ }
40
+ }
41
+
42
+ export const startNotificationService = async (busAddress: string): Promise<() => void> => {
43
+ const bus = sessionBus({ busAddress });
44
+ bus.exportInterface(new NotificationService(), NOTIFICATIONS_PATH, DESCRIPTOR);
45
+
46
+ await new Promise<void>((resolve, reject) => {
47
+ bus.requestName(NOTIFICATIONS_NAME, 0, (error) => {
48
+ if (error) reject(error);
49
+ else resolve();
50
+ });
51
+ });
52
+
53
+ return () => bus.connection.stream.destroy();
54
+ };
@@ -0,0 +1,7 @@
1
+ import { readHeadlessOptions, resolveHeadlessOptions, startHeadlessDisplay } from "./headless-display.js";
2
+
3
+ const options = readHeadlessOptions(new URL(import.meta.url).searchParams);
4
+ const teardown = await startHeadlessDisplay(resolveHeadlessOptions(options));
5
+
6
+ process.on("exit", teardown);
7
+ globalThis.gtkxHeadlessTeardown = teardown;