@gtkx/vitest 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.
Files changed (58) hide show
  1. package/README.md +137 -34
  2. package/dist/headless-display.d.ts +31 -0
  3. package/dist/headless-display.d.ts.map +1 -0
  4. package/dist/headless-display.js +335 -0
  5. package/dist/headless-display.js.map +1 -0
  6. package/dist/headless-globals.d.ts +10 -0
  7. package/dist/headless-globals.d.ts.map +1 -0
  8. package/dist/headless-globals.js +13 -0
  9. package/dist/headless-globals.js.map +1 -0
  10. package/dist/index.d.ts +19 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +54 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/install-headless-shutdown.d.ts +3 -0
  15. package/dist/install-headless-shutdown.d.ts.map +1 -0
  16. package/dist/install-headless-shutdown.js +12 -0
  17. package/dist/install-headless-shutdown.js.map +1 -0
  18. package/dist/notification-service.d.ts +3 -0
  19. package/dist/notification-service.d.ts.map +1 -0
  20. package/dist/notification-service.js +48 -0
  21. package/dist/notification-service.js.map +1 -0
  22. package/dist/resolve-executable.d.ts +3 -0
  23. package/dist/resolve-executable.d.ts.map +1 -0
  24. package/dist/resolve-executable.js +26 -0
  25. package/dist/resolve-executable.js.map +1 -0
  26. package/dist/virtual-seat.d.ts +3 -0
  27. package/dist/virtual-seat.d.ts.map +1 -0
  28. package/dist/virtual-seat.js +155 -0
  29. package/dist/virtual-seat.js.map +1 -0
  30. package/dist/worker-preload.d.ts +2 -0
  31. package/dist/worker-preload.d.ts.map +1 -0
  32. package/dist/worker-preload.js +7 -0
  33. package/dist/worker-preload.js.map +1 -0
  34. package/dist/worker-setup.d.ts +2 -0
  35. package/dist/worker-setup.d.ts.map +1 -0
  36. package/dist/worker-setup.js +3 -0
  37. package/dist/worker-setup.js.map +1 -0
  38. package/package.json +14 -7
  39. package/src/dbus-native.d.ts +17 -0
  40. package/src/headless-display.ts +507 -0
  41. package/src/headless-globals.ts +22 -0
  42. package/src/index.ts +72 -1
  43. package/src/install-headless-shutdown.ts +15 -0
  44. package/src/notification-service.ts +57 -0
  45. package/src/resolve-executable.ts +32 -0
  46. package/src/virtual-seat.ts +214 -0
  47. package/src/worker-preload.ts +8 -0
  48. package/src/worker-setup.ts +3 -0
  49. package/dist/plugin.d.ts +0 -22
  50. package/dist/plugin.d.ts.map +0 -1
  51. package/dist/plugin.js +0 -36
  52. package/dist/plugin.js.map +0 -1
  53. package/dist/setup.d.ts +0 -2
  54. package/dist/setup.d.ts.map +0 -1
  55. package/dist/setup.js +0 -44
  56. package/dist/setup.js.map +0 -1
  57. package/src/plugin.ts +0 -41
  58. package/src/setup.ts +0 -52
@@ -0,0 +1,507 @@
1
+ import { type ChildProcess, spawn, spawnSync, type StdioOptions } 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
+ import { resolveExecutable } from "./resolve-executable.js";
8
+ import { startVirtualSeat } from "./virtual-seat.js";
9
+
10
+ /**
11
+ * Wayland compositors that can back a headless display.
12
+ */
13
+ type CompositorId = "sway" | "weston";
14
+
15
+ /**
16
+ * Settings for the per-worker headless Wayland display.
17
+ */
18
+ 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
+ type EnvSnapshot = Record<string, string | undefined>;
26
+
27
+ type CompositorDescriptor = {
28
+ socket: string;
29
+ env: Record<string, string>;
30
+ needsVirtualSeat: boolean;
31
+ start: (runtimeDir: string, width: string, height: string) => ChildProcess;
32
+ };
33
+
34
+ type SpawnedCompositor = {
35
+ child: ChildProcess;
36
+ socket: string;
37
+ needsVirtualSeat: boolean;
38
+ };
39
+
40
+ type WaitForSocketOptions = {
41
+ label: string;
42
+ timeout?: number;
43
+ child?: ChildProcess;
44
+ signal?: AbortSignal;
45
+ };
46
+
47
+ type StderrCapture = {
48
+ read: () => string;
49
+ stop: () => void;
50
+ };
51
+
52
+ type ChildHandlers = {
53
+ exit: (code: number | null, signal: NodeJS.Signals | null) => void;
54
+ error: (cause: Error) => void;
55
+ };
56
+
57
+ type DisplaySockets = {
58
+ compositor: SpawnedCompositor;
59
+ compositorSocketPath: string;
60
+ busChild: ChildProcess;
61
+ busSocketPath: string;
62
+ };
63
+
64
+ type SocketWatch = {
65
+ path: string;
66
+ options: WaitForSocketOptions;
67
+ resolve: () => void;
68
+ reject: (error: Error) => void;
69
+ };
70
+
71
+ const DEFAULT_HEADLESS_SIZE = "1024x768";
72
+ const DEFAULT_HEADLESS_COMPOSITOR: CompositorId = "sway";
73
+
74
+ const BUS_CONFIG_DOCTYPE =
75
+ '<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN" ' +
76
+ '"https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">';
77
+
78
+ const PARENT_DEATH_SCRIPT = 'trap \'kill -9 "$child" 2>/dev/null\' TERM; "$@" & child=$!; wait "$child"';
79
+ const hasWestonFakeSeat = createWestonFakeSeatProbe();
80
+
81
+ const compositorRegistry: Record<CompositorId, CompositorDescriptor> = {
82
+ sway: {
83
+ socket: "wayland-1",
84
+ needsVirtualSeat: true,
85
+ env: {
86
+ WLR_BACKENDS: "headless",
87
+ WLR_RENDERER: "pixman",
88
+ WLR_RENDERER_ALLOW_SOFTWARE: "1",
89
+ WLR_LIBINPUT_NO_DEVICES: "1",
90
+ WLR_HEADLESS_OUTPUTS: "1",
91
+ },
92
+ start: (runtimeDir, width, height) => {
93
+ const configPath = join(runtimeDir, "sway.conf");
94
+
95
+ writeFileSync(
96
+ configPath,
97
+ [
98
+ "xwayland disable",
99
+ "default_border none",
100
+ "default_floating_border none",
101
+ `output HEADLESS-1 resolution ${width}x${height}`,
102
+ "output HEADLESS-1 bg #000000 solid_color",
103
+ 'for_window [app_id=".*"] floating enable, border none',
104
+ 'for_window [title=".*"] floating enable, border none',
105
+ "",
106
+ ].join("\n"),
107
+ );
108
+
109
+ return spawnWithParentDeathSignal("sway", ["-c", configPath], ["ignore", "ignore", "pipe"]);
110
+ },
111
+ },
112
+ weston: {
113
+ socket: "wayland-0",
114
+ needsVirtualSeat: false,
115
+ env: {},
116
+ start: (_runtimeDir, width, height) =>
117
+ spawnWithParentDeathSignal(
118
+ "weston",
119
+ [
120
+ "--backend=headless",
121
+ "--renderer=pixman",
122
+ ...(hasWestonFakeSeat() ? ["--fake-seat"] : []),
123
+ `--width=${width}`,
124
+ `--height=${height}`,
125
+ "--socket=wayland-0",
126
+ ],
127
+ ["ignore", "ignore", "pipe"],
128
+ ),
129
+ },
130
+ };
131
+
132
+ const STATIC_HEADLESS_ENV = {
133
+ GDK_BACKEND: "wayland",
134
+ GDK_DISABLE: "vulkan",
135
+ GDK_DEBUG: "no-vsync",
136
+ GSK_RENDERER: "cairo",
137
+ GTK_A11Y: "test",
138
+ LIBGL_ALWAYS_SOFTWARE: "1",
139
+ GST_GL_WINDOW: "none",
140
+ GSETTINGS_BACKEND: "memory",
141
+ ALSOFT_DRIVERS: "null",
142
+ ALSOFT_LOGLEVEL: "0",
143
+ };
144
+
145
+ const resolveHeadlessOptions = (provided: Partial<HeadlessOptions>): HeadlessOptions => ({
146
+ size: provided.size ?? DEFAULT_HEADLESS_SIZE,
147
+ compositor: provided.compositor ?? DEFAULT_HEADLESS_COMPOSITOR,
148
+ });
149
+
150
+ const applyEnv = (snapshot: EnvSnapshot, values: Record<string, string>): void => {
151
+ for (const [name, value] of Object.entries(values)) {
152
+ if (!Object.hasOwn(snapshot, name)) {
153
+ snapshot[name] = process.env[name];
154
+ }
155
+
156
+ process.env[name] = value;
157
+ }
158
+ };
159
+
160
+ const restoreEnv = (snapshot: EnvSnapshot): void => {
161
+ for (const [name, previous] of Object.entries(snapshot)) {
162
+ if (previous === undefined) {
163
+ Reflect.deleteProperty(process.env, name);
164
+ } else {
165
+ process.env[name] = previous;
166
+ }
167
+ }
168
+ };
169
+
170
+ const spawnWithParentDeathSignal = (command: string, args: string[], stdio: StdioOptions): ChildProcess => {
171
+ const child = spawn(
172
+ resolveExecutable("setpriv"),
173
+ ["--pdeathsig", "SIGTERM", "sh", "-c", PARENT_DEATH_SCRIPT, "sh", command, ...args],
174
+ { stdio },
175
+ );
176
+
177
+ child.unref();
178
+
179
+ return child;
180
+ };
181
+
182
+ function createWestonFakeSeatProbe(): () => boolean {
183
+ let isSupported: boolean | undefined;
184
+
185
+ return () => {
186
+ if (isSupported === undefined) {
187
+ const help = spawnSync(resolveExecutable("weston"), ["--help"], { encoding: "utf8" });
188
+ isSupported = `${help.stdout}${help.stderr}`.includes("--fake-seat");
189
+ }
190
+
191
+ return isSupported;
192
+ };
193
+ }
194
+
195
+ const isCompositorId = (value: string): value is CompositorId => Object.hasOwn(compositorRegistry, value);
196
+
197
+ const readHeadlessOptions = (params: URLSearchParams): Partial<HeadlessOptions> => {
198
+ const options: Partial<HeadlessOptions> = {};
199
+ const size = params.get("size");
200
+
201
+ if (size !== null) {
202
+ options.size = size;
203
+ }
204
+
205
+ const compositor = params.get("compositor");
206
+
207
+ if (compositor !== null && isCompositorId(compositor)) {
208
+ options.compositor = compositor;
209
+ }
210
+
211
+ return options;
212
+ };
213
+
214
+ const startCompositor = (runtimeDir: string, options: HeadlessOptions, env: EnvSnapshot): SpawnedCompositor => {
215
+ const descriptor = compositorRegistry[options.compositor];
216
+ const [width = "", height = ""] = options.size.split("x", 2);
217
+ applyEnv(env, descriptor.env);
218
+
219
+ return {
220
+ child: descriptor.start(runtimeDir, width, height),
221
+ socket: descriptor.socket,
222
+ needsVirtualSeat: descriptor.needsVirtualSeat,
223
+ };
224
+ };
225
+
226
+ const noVirtualSeat = (): void => undefined;
227
+
228
+ const attachVirtualSeat = (compositor: SpawnedCompositor, socketPath: string): Promise<() => void> =>
229
+ compositor.needsVirtualSeat ? startVirtualSeat(socketPath) : Promise.resolve(noVirtualSeat);
230
+
231
+ const writeBusConfig = (busConfigPath: string, busSocketPath: string): void => {
232
+ writeFileSync(
233
+ busConfigPath,
234
+ [
235
+ BUS_CONFIG_DOCTYPE,
236
+ "<busconfig>",
237
+ " <type>session</type>",
238
+ ` <listen>unix:path=${busSocketPath}</listen>`,
239
+ " <auth>EXTERNAL</auth>",
240
+ ' <policy context="default">',
241
+ ' <allow send_destination="*" eavesdrop="true"/>',
242
+ ' <allow eavesdrop="true"/>',
243
+ ' <allow own="*"/>',
244
+ " </policy>",
245
+ "</busconfig>",
246
+ ].join("\n"),
247
+ );
248
+ };
249
+
250
+ const captureStderr = (child: ChildProcess | undefined): StderrCapture => {
251
+ const stderr = child?.stderr ?? null;
252
+ let log = "";
253
+ stderr?.setEncoding("utf8");
254
+
255
+ stderr?.on("data", (chunk: string) => {
256
+ log += chunk;
257
+ });
258
+
259
+ return {
260
+ read: () => log,
261
+ stop: () => {
262
+ stderr?.removeAllListeners("data");
263
+ stderr?.resume();
264
+
265
+ if (stderr instanceof Socket) {
266
+ stderr.unref();
267
+ }
268
+ },
269
+ };
270
+ };
271
+
272
+ const trackChild = (child: ChildProcess, handlers: ChildHandlers): (() => void) => {
273
+ child.on("exit", handlers.exit);
274
+ child.on("error", handlers.error);
275
+
276
+ return () => {
277
+ child.removeListener("exit", handlers.exit);
278
+ child.removeListener("error", handlers.error);
279
+ };
280
+ };
281
+
282
+ const exitedMessage = (label: string, path: string, code: number | null, signal: NodeJS.Signals | null): string =>
283
+ `${label} exited (code ${String(code)}, signal ${signal ?? "null"}) before ${path} appeared`;
284
+
285
+ const runCleanups = (cleanups: (() => void)[]): void => {
286
+ for (const cleanup of cleanups) {
287
+ cleanup();
288
+ }
289
+
290
+ cleanups.length = 0;
291
+ };
292
+
293
+ const pollForPath = (path: string, onFound: () => void): NodeJS.Timeout =>
294
+ setInterval(() => {
295
+ if (existsSync(path)) {
296
+ onFound();
297
+ }
298
+ }, 50);
299
+
300
+ const stderrSuffix = (child: ChildProcess | undefined, stderr: StderrCapture): string =>
301
+ child ? `\n${stderr.read()}` : "";
302
+
303
+ const hasAlreadyAborted = (signal: AbortSignal | undefined, onAbort: () => void, cleanups: (() => void)[]): boolean => {
304
+ if (signal === undefined) {
305
+ return false;
306
+ }
307
+
308
+ if (signal.aborted) {
309
+ return true;
310
+ }
311
+
312
+ signal.addEventListener("abort", onAbort);
313
+
314
+ cleanups.push(() => {
315
+ signal.removeEventListener("abort", onAbort);
316
+ });
317
+
318
+ return false;
319
+ };
320
+
321
+ const watchForSocket = ({ path, options, resolve, reject }: SocketWatch): void => {
322
+ const { label, timeout = 15_000, child, signal } = options;
323
+ const stderr = captureStderr(child);
324
+ const cleanups: (() => void)[] = [stderr.stop];
325
+
326
+ const stop = (): void => {
327
+ runCleanups(cleanups);
328
+ };
329
+
330
+ const fail = (message: string): void => {
331
+ stop();
332
+ reject(new Error(message));
333
+ };
334
+
335
+ const onExit = (code: number | null, terminationSignal: NodeJS.Signals | null): void => {
336
+ fail(`${exitedMessage(label, path, code, terminationSignal)}\n${stderr.read()}`);
337
+ };
338
+
339
+ const onError = (cause: Error): void => {
340
+ fail(`${label} failed to spawn: ${cause.message}\n${stderr.read()}`);
341
+ };
342
+
343
+ const onAbort = (): void => {
344
+ fail(`${label} startup aborted before ${path} appeared`);
345
+ };
346
+
347
+ const poll = pollForPath(path, () => {
348
+ stop();
349
+ resolve();
350
+ });
351
+
352
+ const timer = setTimeout(() => {
353
+ fail(`${label} did not become available within ${String(timeout)}ms${stderrSuffix(child, stderr)}`);
354
+ }, timeout);
355
+
356
+ cleanups.push(() => {
357
+ clearInterval(poll);
358
+ clearTimeout(timer);
359
+ });
360
+
361
+ if (child) {
362
+ cleanups.push(trackChild(child, { exit: onExit, error: onError }));
363
+ }
364
+
365
+ if (hasAlreadyAborted(signal, onAbort, cleanups)) {
366
+ onAbort();
367
+ }
368
+ };
369
+
370
+ const waitForSocket = (path: string, options: WaitForSocketOptions): Promise<void> =>
371
+ new Promise((resolve, reject) => {
372
+ watchForSocket({ path, options, resolve, reject });
373
+ });
374
+
375
+ const captureCompositorStderr = (child: ChildProcess, logPath: string): string[] => {
376
+ const captured: string[] = [];
377
+ const stderr = child.stderr;
378
+
379
+ if (stderr !== null) {
380
+ stderr.setEncoding("utf8");
381
+ const logStream = createWriteStream(logPath);
382
+ logStream.on("error", (): void => undefined);
383
+
384
+ stderr.on("data", (chunk: string) => {
385
+ captured.push(chunk);
386
+ logStream.write(chunk);
387
+ });
388
+ }
389
+
390
+ return captured;
391
+ };
392
+
393
+ const compositorExitMessage = (
394
+ code: number | null,
395
+ signal: NodeJS.Signals | null,
396
+ capturedStderr: string[],
397
+ ): string =>
398
+ `[gtkx] the headless compositor exited (code ${String(code)}, signal ${signal ?? "null"}); ` +
399
+ `every Wayland client in this worker has been severed.\n${capturedStderr.join("")}`;
400
+
401
+ const watchCompositorExit = (child: ChildProcess, capturedStderr: string[]): (() => void) => {
402
+ const report = (code: number | null, signal: NodeJS.Signals | null): void => {
403
+ process.stderr.write(compositorExitMessage(code, signal, capturedStderr));
404
+ };
405
+
406
+ child.on("exit", report);
407
+
408
+ return () => child.removeListener("exit", report);
409
+ };
410
+
411
+ const waitForDisplaySockets = async (sockets: DisplaySockets): Promise<void> => {
412
+ const { compositor, compositorSocketPath, busChild, busSocketPath } = sockets;
413
+ const abort = new AbortController();
414
+
415
+ try {
416
+ await Promise.all([
417
+ waitForSocket(compositorSocketPath, {
418
+ label: "Compositor",
419
+ child: compositor.child,
420
+ signal: abort.signal,
421
+ }),
422
+ waitForSocket(busSocketPath, { label: "D-Bus session bus", child: busChild, signal: abort.signal }),
423
+ ]);
424
+ } finally {
425
+ abort.abort();
426
+ }
427
+ };
428
+
429
+ const killSpawned = (children: ChildProcess[]): void => {
430
+ for (const child of children) {
431
+ child.kill("SIGTERM");
432
+ }
433
+ };
434
+
435
+ const makeTeardown = (stops: (() => void)[]): (() => void) => {
436
+ let isTorndown = false;
437
+
438
+ return (): void => {
439
+ if (isTorndown) {
440
+ return;
441
+ }
442
+
443
+ isTorndown = true;
444
+ runCleanups(stops);
445
+ };
446
+ };
447
+
448
+ const startHeadlessDisplay = async (options: HeadlessOptions): Promise<() => void> => {
449
+ const env: EnvSnapshot = {};
450
+ const runtimeDir = mkdtempSync(join(tmpdir(), "gtkx-xdg-"));
451
+ chmodSync(runtimeDir, 0o700);
452
+ const spawned: ChildProcess[] = [];
453
+
454
+ const removeRuntime = (): void => {
455
+ restoreEnv(env);
456
+ rmSync(runtimeDir, { recursive: true, force: true });
457
+ };
458
+
459
+ try {
460
+ applyEnv(env, { XDG_RUNTIME_DIR: runtimeDir });
461
+ const busConfigPath = join(runtimeDir, "session.conf");
462
+ const busSocketPath = join(runtimeDir, "bus");
463
+ writeBusConfig(busConfigPath, busSocketPath);
464
+
465
+ const busChild = spawnWithParentDeathSignal(
466
+ "dbus-daemon",
467
+ [`--config-file=${busConfigPath}`],
468
+ ["ignore", "ignore", "pipe"],
469
+ );
470
+
471
+ spawned.push(busChild);
472
+ applyEnv(env, { DBUS_SESSION_BUS_ADDRESS: `unix:path=${busSocketPath}` });
473
+ const compositor = startCompositor(runtimeDir, options, env);
474
+ spawned.push(compositor.child);
475
+ applyEnv(env, { WAYLAND_DISPLAY: compositor.socket });
476
+ const compositorSocketPath = join(runtimeDir, compositor.socket);
477
+ await waitForDisplaySockets({ compositor, compositorSocketPath, busChild, busSocketPath });
478
+ const stopVirtualSeat = await attachVirtualSeat(compositor, compositorSocketPath);
479
+ const stopNotifications = await startNotificationService(`unix:path=${busSocketPath}`);
480
+ const capturedStderr = captureCompositorStderr(compositor.child, join(runtimeDir, "compositor.stderr.log"));
481
+ const stopExitWatch = watchCompositorExit(compositor.child, capturedStderr);
482
+
483
+ return makeTeardown([
484
+ stopExitWatch,
485
+ () => {
486
+ killSpawned(spawned);
487
+ },
488
+ stopVirtualSeat,
489
+ stopNotifications,
490
+ removeRuntime,
491
+ ]);
492
+ } catch (error) {
493
+ killSpawned(spawned);
494
+ removeRuntime();
495
+ throw error;
496
+ }
497
+ };
498
+
499
+ export {
500
+ DEFAULT_HEADLESS_SIZE,
501
+ STATIC_HEADLESS_ENV,
502
+ resolveHeadlessOptions,
503
+ readHeadlessOptions,
504
+ startHeadlessDisplay,
505
+ type CompositorId,
506
+ type HeadlessOptions,
507
+ };
@@ -0,0 +1,22 @@
1
+ declare global {
2
+ var gtkxHeadlessTeardown: (() => void) | undefined;
3
+ var gtkxHeadlessShutdownInstalled: boolean | undefined;
4
+ }
5
+
6
+ const defineGlobal = (key: string, value: unknown): void => {
7
+ Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
8
+ };
9
+
10
+ const setHeadlessTeardown = (teardown: (() => void) | undefined): void => {
11
+ defineGlobal("gtkxHeadlessTeardown", teardown);
12
+ };
13
+
14
+ const headlessTeardown = (): (() => void) | undefined => globalThis.gtkxHeadlessTeardown;
15
+
16
+ const setHeadlessShutdownInstalled = (installed: boolean | undefined): void => {
17
+ defineGlobal("gtkxHeadlessShutdownInstalled", installed);
18
+ };
19
+
20
+ const isHeadlessShutdownInstalled = (): boolean => globalThis.gtkxHeadlessShutdownInstalled === true;
21
+
22
+ export { setHeadlessTeardown, headlessTeardown, setHeadlessShutdownInstalled, isHeadlessShutdownInstalled };
package/src/index.ts CHANGED
@@ -1 +1,72 @@
1
- export { default } from "./plugin.js";
1
+ import type { Plugin } from "vitest/config";
2
+ import createConfigPlugin from "@gtkx/config/vite-plugin";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { type HeadlessOptions, STATIC_HEADLESS_ENV } from "./headless-display.js";
7
+
8
+ /**
9
+ * Options accepted by the {@link gtkx} Vitest plugin. Every headless display
10
+ * setting is optional and falls back to a built-in default when omitted.
11
+ */
12
+ type GtkxPluginOptions = Partial<HeadlessOptions>;
13
+
14
+ const GTKX_INLINE_DEPS: RegExp[] = [/@gtkx\/(?!native)/, /[/\\]\.gtkx[/\\]/];
15
+
16
+ const workerPreloadUrl = (): URL => {
17
+ const sibling = join(import.meta.dirname, "worker-preload.js");
18
+ const path = existsSync(sibling) ? sibling : join(import.meta.dirname, "..", "dist", "worker-preload.js");
19
+
20
+ return pathToFileURL(path);
21
+ };
22
+
23
+ const headlessPreloadSpecifier = (options: GtkxPluginOptions): string => {
24
+ const url = workerPreloadUrl();
25
+
26
+ for (const [key, value] of Object.entries(options)) {
27
+ url.searchParams.set(key, value);
28
+ }
29
+
30
+ return url.href;
31
+ };
32
+
33
+ const workerSetupPath = (): string => {
34
+ const sibling = join(import.meta.dirname, "worker-setup.js");
35
+
36
+ return existsSync(sibling) ? sibling : join(import.meta.dirname, "..", "dist", "worker-setup.js");
37
+ };
38
+
39
+ /**
40
+ * Vitest plugin that runs each test worker against its own isolated headless
41
+ * Wayland display. It configures the forks pool, injects the worker preload and
42
+ * setup files, and sets the environment needed for headless GTK4 rendering.
43
+ *
44
+ * @param options Headless display settings (size, compositor) forwarded to each worker.
45
+ * @returns A Vitest config plugin.
46
+ */
47
+ const gtkx = (options: GtkxPluginOptions = {}): Plugin =>
48
+ createConfigPlugin({
49
+ name: "gtkx:vitest",
50
+ config() {
51
+ return {
52
+ test: {
53
+ globals: true,
54
+ execArgv: ["--import", headlessPreloadSpecifier(options)],
55
+ setupFiles: [workerSetupPath()],
56
+ testTimeout: 30_000,
57
+ hookTimeout: 30_000,
58
+ pool: "forks",
59
+ env: STATIC_HEADLESS_ENV,
60
+ server: {
61
+ deps: {
62
+ inline: GTKX_INLINE_DEPS,
63
+ },
64
+ },
65
+ },
66
+ };
67
+ },
68
+ });
69
+
70
+ export default gtkx;
71
+ export { type CompositorId, type HeadlessOptions } from "./headless-display.js";
72
+ export { type GtkxPluginOptions };
@@ -0,0 +1,15 @@
1
+ import { installGracefulShutdown } from "@gtkx/utils";
2
+ import { headlessTeardown, isHeadlessShutdownInstalled, setHeadlessShutdownInstalled } from "./headless-globals.js";
3
+
4
+ const installHeadlessShutdown = (): void => {
5
+ const teardown = headlessTeardown();
6
+
7
+ if (teardown === undefined || isHeadlessShutdownInstalled()) {
8
+ return;
9
+ }
10
+
11
+ setHeadlessShutdownInstalled(true);
12
+ installGracefulShutdown({ onSignal: teardown });
13
+ };
14
+
15
+ export { installHeadlessShutdown };
@@ -0,0 +1,57 @@
1
+ import { type InterfaceDescriptor, sessionBus } from "@homebridge/dbus-native";
2
+
3
+ const NOTIFICATIONS_NAME = "org.freedesktop.Notifications";
4
+ const NOTIFICATIONS_PATH = "/org/freedesktop/Notifications";
5
+
6
+ const DESCRIPTOR: InterfaceDescriptor = {
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
+
24
+ const startNotificationService = async (busAddress: string): Promise<() => void> => {
25
+ const bus = sessionBus({ busAddress });
26
+ bus.exportInterface(new NotificationService(), NOTIFICATIONS_PATH, DESCRIPTOR);
27
+
28
+ await new Promise<void>((resolve, reject) => {
29
+ bus.requestName(NOTIFICATIONS_NAME, 0, (error) => {
30
+ if (error) {
31
+ reject(error);
32
+ } else {
33
+ resolve();
34
+ }
35
+ });
36
+ });
37
+
38
+ return () => bus.connection.stream.destroy();
39
+ };
40
+
41
+ class NotificationService extends EventTarget {
42
+ private lastId = 0;
43
+
44
+ CloseNotification = (): void => undefined;
45
+
46
+ Notify(): number {
47
+ this.lastId += 1;
48
+
49
+ return this.lastId;
50
+ }
51
+
52
+ GetCapabilities(): string[] {
53
+ return ["body", "actions"];
54
+ }
55
+ }
56
+
57
+ export { startNotificationService };