@gtkx/vitest 1.6.0 → 2.0.0-beta.10

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/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@gtkx/vitest",
3
- "version": "1.6.0",
4
- "description": "Vitest plugin for GTK apps with headless Wayland isolation.",
3
+ "version": "2.0.0-beta.10",
4
+ "description": "Vitest plugin for GTKX GNOME apps with headless Wayland isolation.",
5
5
  "keywords": [
6
6
  "gtkx",
7
7
  "gtk",
8
8
  "gtk4",
9
+ "adwaita",
10
+ "gnome",
9
11
  "vitest",
10
12
  "testing",
11
13
  "plugin",
@@ -29,7 +31,15 @@
29
31
  "./package.json": "./package.json",
30
32
  ".": {
31
33
  "types": "./dist/index.d.ts",
34
+ "import": "./dist/index.js",
35
+ "require": null,
32
36
  "default": "./dist/index.js"
37
+ },
38
+ "./headless": {
39
+ "types": "./dist/headless.d.ts",
40
+ "import": "./dist/headless.js",
41
+ "require": null,
42
+ "default": "./dist/headless.js"
33
43
  }
34
44
  },
35
45
  "sideEffects": false,
@@ -48,18 +58,18 @@
48
58
  }
49
59
  },
50
60
  "engines": {
51
- "node": ">=24"
61
+ "node": ">=26.7.0"
52
62
  },
53
63
  "dependencies": {
54
- "@homebridge/dbus-native": "^0.7.9",
55
- "@gtkx/utils": "1.6.0",
56
- "@gtkx/config": "1.6.0"
64
+ "@gtkx/config": "2.0.0-beta.10",
65
+ "@gtkx/utils": "2.0.0-beta.10",
66
+ "@homebridge/dbus-native": "^0.7.9"
57
67
  },
58
68
  "devDependencies": {
59
- "vitest": "^4.1.11"
69
+ "vitest": "^5.0.0"
60
70
  },
61
71
  "peerDependencies": {
62
- "vitest": ">=4"
72
+ "vitest": ">=5"
63
73
  },
64
74
  "scripts": {
65
75
  "release": "tsx ../../scripts/release-package.ts"
@@ -0,0 +1,61 @@
1
+ const BUS_CONFIG_DOCTYPE =
2
+ '<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN" ' +
3
+ '"https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">';
4
+
5
+ const HEADLESS_RUNTIME_MARKER = ".gtkx-headless-runtime";
6
+
7
+ const SWAY_CONFIG_LINES = [
8
+ /^xwayland disable$/,
9
+ /^default_border none$/,
10
+ /^default_floating_border none$/,
11
+ /^output HEADLESS-1 resolution [1-9]\d*x[1-9]\d*$/,
12
+ /^output HEADLESS-1 bg #000000 solid_color$/,
13
+ /^for_window \[app_id="\.\*"\] floating enable, border none$/,
14
+ /^for_window \[title="\.\*"\] floating enable, border none$/,
15
+ /^$/,
16
+ ];
17
+
18
+ const createSwayConfig = (width: string, height: string): string =>
19
+ [
20
+ "xwayland disable",
21
+ "default_border none",
22
+ "default_floating_border none",
23
+ `output HEADLESS-1 resolution ${width}x${height}`,
24
+ "output HEADLESS-1 bg #000000 solid_color",
25
+ 'for_window [app_id=".*"] floating enable, border none',
26
+ 'for_window [title=".*"] floating enable, border none',
27
+ "",
28
+ ].join("\n");
29
+
30
+ const createBusConfig = (busSocketPath: string): string =>
31
+ [
32
+ BUS_CONFIG_DOCTYPE,
33
+ "<busconfig>",
34
+ " <type>session</type>",
35
+ ` <listen>unix:path=${busSocketPath}</listen>`,
36
+ " <auth>EXTERNAL</auth>",
37
+ ' <policy context="default">',
38
+ ' <allow send_destination="*" eavesdrop="true"/>',
39
+ ' <allow eavesdrop="true"/>',
40
+ ' <allow own="*"/>',
41
+ " </policy>",
42
+ "</busconfig>",
43
+ ].join("\n");
44
+
45
+ const createHeadlessRuntimeMarker = (runtimeDir: string): string =>
46
+ ["gtkx-headless-runtime-v1", `runtime=${runtimeDir}`, ""].join("\n");
47
+
48
+ const isSwayConfig = (value: string): boolean => {
49
+ const lines = value.split("\n");
50
+
51
+ return lines.length === SWAY_CONFIG_LINES.length &&
52
+ SWAY_CONFIG_LINES.every((pattern, index) => pattern.test(lines[index] ?? "invalid"));
53
+ };
54
+
55
+ export {
56
+ createBusConfig,
57
+ createHeadlessRuntimeMarker,
58
+ createSwayConfig,
59
+ HEADLESS_RUNTIME_MARKER,
60
+ isSwayConfig,
61
+ };
@@ -1,9 +1,20 @@
1
- import { resolveExecutable, spawnWithParentDeathSignal } from "@gtkx/utils";
1
+ import {
2
+ isProcessAlive,
3
+ resolveExecutable,
4
+ spawnWithParentDeathSignal,
5
+ spawnWithParentDeathSupervisor,
6
+ } from "@gtkx/utils";
2
7
  import { type ChildProcess, spawnSync } from "node:child_process";
3
- import { chmodSync, createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
- import { Socket } from "node:net";
8
+ import { chmodSync, closeSync, existsSync, mkdtempSync, openSync, rmSync, writeFileSync, writeSync } from "node:fs";
9
+ import { connect, Socket } from "node:net";
5
10
  import { tmpdir } from "node:os";
6
11
  import { join } from "node:path";
12
+ import {
13
+ createBusConfig,
14
+ createHeadlessRuntimeMarker,
15
+ createSwayConfig,
16
+ HEADLESS_RUNTIME_MARKER,
17
+ } from "./headless-config.ts";
7
18
  import { startNotificationService } from "./notification-service.ts";
8
19
  import { startVirtualSeat } from "./virtual-seat.ts";
9
20
 
@@ -42,13 +53,13 @@ type ChildMonitor = {
42
53
  path: string;
43
54
  read: () => string;
44
55
  failure: () => string | undefined;
56
+ isRunning: () => boolean;
45
57
  subscribe: (notify: (failure: string) => void) => () => void;
46
58
  stop: () => void;
47
59
  };
48
60
 
49
- type WaitForSocketOptions = {
50
- monitor: ChildMonitor;
51
- guards?: ChildMonitor[];
61
+ type WaitForSocketsOptions = {
62
+ monitors: ChildMonitor[];
52
63
  timeout?: number;
53
64
  };
54
65
 
@@ -57,18 +68,21 @@ type DisplaySockets = {
57
68
  busMonitor: ChildMonitor;
58
69
  };
59
70
 
60
- type SocketWatch = {
61
- options: WaitForSocketOptions;
71
+ type CapturedStderr = {
72
+ chunks: string[];
73
+ logPath: string;
74
+ stop: () => void;
75
+ };
76
+
77
+ type SocketsWatch = {
78
+ options: WaitForSocketsOptions;
62
79
  resolve: () => void;
63
80
  reject: (error: Error) => void;
64
81
  };
65
82
 
66
83
  const DEFAULT_HEADLESS_SIZE = "1024x768";
67
84
  const DEFAULT_HEADLESS_COMPOSITOR: CompositorId = "sway";
68
-
69
- const BUS_CONFIG_DOCTYPE =
70
- '<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN" ' +
71
- '"https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">';
85
+ const HEADLESS_SIZE_PATTERN = /^[1-9]\d*x[1-9]\d*$/;
72
86
 
73
87
  const hasWestonFakeSeat = createWestonFakeSeatProbe();
74
88
 
@@ -88,19 +102,13 @@ const compositorRegistry: Record<CompositorId, CompositorDescriptor> = {
88
102
 
89
103
  writeFileSync(
90
104
  configPath,
91
- [
92
- "xwayland disable",
93
- "default_border none",
94
- "default_floating_border none",
95
- `output HEADLESS-1 resolution ${width}x${height}`,
96
- "output HEADLESS-1 bg #000000 solid_color",
97
- 'for_window [app_id=".*"] floating enable, border none',
98
- 'for_window [title=".*"] floating enable, border none',
99
- "",
100
- ].join("\n"),
105
+ createSwayConfig(width, height),
101
106
  );
102
107
 
103
- return spawnWithParentDeathSignal("sway", ["-c", configPath], { stdio: ["ignore", "ignore", "pipe"] });
108
+ return spawnWithParentDeathSupervisor("sway", ["-c", configPath], {
109
+ stdio: ["ignore", "ignore", "pipe"],
110
+ cleanupDirectory: runtimeDir,
111
+ });
104
112
  },
105
113
  },
106
114
  weston: {
@@ -118,7 +126,7 @@ const compositorRegistry: Record<CompositorId, CompositorDescriptor> = {
118
126
  `--height=${height}`,
119
127
  "--socket=wayland-0",
120
128
  ],
121
- { stdio: ["ignore", "ignore", "pipe"] },
129
+ { stdio: ["ignore", "ignore", "pipe"], cleanupDirectories: [_runtimeDir] },
122
130
  ),
123
131
  },
124
132
  };
@@ -136,10 +144,18 @@ const STATIC_HEADLESS_ENV = {
136
144
  ALSOFT_LOGLEVEL: "0",
137
145
  };
138
146
 
139
- const resolveHeadlessOptions = (provided: Partial<HeadlessOptions>): HeadlessOptions => ({
140
- size: provided.size ?? DEFAULT_HEADLESS_SIZE,
141
- compositor: provided.compositor ?? DEFAULT_HEADLESS_COMPOSITOR,
142
- });
147
+ const resolveHeadlessOptions = (provided: Partial<HeadlessOptions>): HeadlessOptions => {
148
+ const size = provided.size ?? DEFAULT_HEADLESS_SIZE;
149
+
150
+ if (!HEADLESS_SIZE_PATTERN.test(size)) {
151
+ throw new Error(`Invalid headless display size: ${size}`);
152
+ }
153
+
154
+ return {
155
+ size,
156
+ compositor: provided.compositor ?? DEFAULT_HEADLESS_COMPOSITOR,
157
+ };
158
+ };
143
159
 
144
160
  const applyEnv = (snapshot: EnvSnapshot, values: Record<string, string>): void => {
145
161
  for (const [name, value] of Object.entries(values)) {
@@ -207,26 +223,8 @@ const startCompositor = (runtimeDir: string, options: HeadlessOptions, env: EnvS
207
223
 
208
224
  const noVirtualSeat = (): void => undefined;
209
225
 
210
- const attachVirtualSeat = (compositor: SpawnedCompositor, socketPath: string): Promise<() => void> =>
211
- compositor.requiresVirtualSeat ? startVirtualSeat(socketPath) : Promise.resolve(noVirtualSeat);
212
-
213
226
  const writeBusConfig = (busConfigPath: string, busSocketPath: string): void => {
214
- writeFileSync(
215
- busConfigPath,
216
- [
217
- BUS_CONFIG_DOCTYPE,
218
- "<busconfig>",
219
- " <type>session</type>",
220
- ` <listen>unix:path=${busSocketPath}</listen>`,
221
- " <auth>EXTERNAL</auth>",
222
- ' <policy context="default">',
223
- ' <allow send_destination="*" eavesdrop="true"/>',
224
- ' <allow eavesdrop="true"/>',
225
- ' <allow own="*"/>',
226
- " </policy>",
227
- "</busconfig>",
228
- ].join("\n"),
229
- );
227
+ writeFileSync(busConfigPath, createBusConfig(busSocketPath));
230
228
  };
231
229
 
232
230
  const exitedMessage = (label: string, path: string, code: number | null, signal: NodeJS.Signals | null): string =>
@@ -266,6 +264,7 @@ const monitorChild = (child: ChildProcess, label: string, path: string): ChildMo
266
264
  path,
267
265
  read: () => log,
268
266
  failure: () => failure,
267
+ isRunning: () => child.exitCode === null && child.signalCode === null && isProcessAlive(child.pid),
269
268
  subscribe: (notify) => {
270
269
  subscribers.add(notify);
271
270
 
@@ -292,12 +291,13 @@ const runCleanups = (cleanups: (() => void)[]): void => {
292
291
  cleanups.length = 0;
293
292
  };
294
293
 
295
- const pollForPath = (path: string, onFound: () => void): NodeJS.Timeout =>
296
- setInterval(() => {
297
- if (existsSync(path)) {
298
- onFound();
299
- }
300
- }, 50);
294
+ const isMonitorReady = (monitor: ChildMonitor): boolean => monitor.isRunning() && existsSync(monitor.path);
295
+
296
+ const pendingMonitors = (monitors: ChildMonitor[]): ChildMonitor[] =>
297
+ monitors.filter((monitor) => !isMonitorReady(monitor));
298
+
299
+ const isEveryMonitorReady = (monitors: ChildMonitor[]): boolean =>
300
+ monitors.every((monitor) => isMonitorReady(monitor));
301
301
 
302
302
  const firstFailure = (monitors: ChildMonitor[]): string | undefined => {
303
303
  for (const monitor of monitors) {
@@ -311,10 +311,15 @@ const firstFailure = (monitors: ChildMonitor[]): string | undefined => {
311
311
  return undefined;
312
312
  };
313
313
 
314
- const watchForSocket = ({ options, resolve, reject }: SocketWatch): void => {
315
- const { monitor, guards = [], timeout = 15_000 } = options;
316
- const watched = [monitor, ...guards];
317
- const cleanups: (() => void)[] = [monitor.stop];
314
+ const SOCKET_TIMEOUT_MS = 15_000;
315
+
316
+ const missingMessage = (pending: ChildMonitor[], timeout: number): string =>
317
+ `${pending.map((monitor) => monitor.label).join(", ")} did not become available within ${String(timeout)}ms\n` +
318
+ pending.map((monitor) => monitor.read()).join("");
319
+
320
+ const watchForSockets = ({ options, resolve, reject }: SocketsWatch): void => {
321
+ const { monitors, timeout = SOCKET_TIMEOUT_MS } = options;
322
+ const cleanups: (() => void)[] = monitors.map((monitor) => monitor.stop);
318
323
 
319
324
  const stop = (): void => {
320
325
  runCleanups(cleanups);
@@ -322,15 +327,10 @@ const watchForSocket = ({ options, resolve, reject }: SocketWatch): void => {
322
327
 
323
328
  const fail = (message: string): void => {
324
329
  stop();
325
-
326
- for (const guard of guards) {
327
- guard.stop();
328
- }
329
-
330
330
  reject(new Error(message));
331
331
  };
332
332
 
333
- const alreadyFailed = firstFailure(watched);
333
+ const alreadyFailed = firstFailure(monitors);
334
334
 
335
335
  if (alreadyFailed !== undefined) {
336
336
  fail(alreadyFailed);
@@ -338,13 +338,25 @@ const watchForSocket = ({ options, resolve, reject }: SocketWatch): void => {
338
338
  return;
339
339
  }
340
340
 
341
- const poll = pollForPath(monitor.path, () => {
341
+ const settle = (): void => {
342
342
  stop();
343
343
  resolve();
344
- });
344
+ };
345
+
346
+ if (isEveryMonitorReady(monitors)) {
347
+ settle();
348
+
349
+ return;
350
+ }
351
+
352
+ const poll = setInterval(() => {
353
+ if (isEveryMonitorReady(monitors)) {
354
+ settle();
355
+ }
356
+ }, 10);
345
357
 
346
358
  const timer = setTimeout(() => {
347
- fail(`${monitor.label} did not become available within ${String(timeout)}ms\n${monitor.read()}`);
359
+ fail(missingMessage(pendingMonitors(monitors), timeout));
348
360
  }, timeout);
349
361
 
350
362
  cleanups.push(
@@ -352,44 +364,65 @@ const watchForSocket = ({ options, resolve, reject }: SocketWatch): void => {
352
364
  clearInterval(poll);
353
365
  clearTimeout(timer);
354
366
  },
355
- ...watched.map((entry) => entry.subscribe(fail)),
367
+ ...monitors.map((monitor) => monitor.subscribe(fail)),
356
368
  );
357
369
  };
358
370
 
359
- const waitForSocket = (options: WaitForSocketOptions): Promise<void> =>
371
+ const waitForSockets = (options: WaitForSocketsOptions): Promise<void> =>
360
372
  new Promise((resolve, reject) => {
361
- watchForSocket({ options, resolve, reject });
373
+ watchForSockets({ options, resolve, reject });
362
374
  });
363
375
 
364
- const captureCompositorStderr = (child: ChildProcess, logPath: string): string[] => {
376
+ const captureCompositorStderr = (child: ChildProcess, logPath: string): CapturedStderr => {
365
377
  const captured: string[] = [];
366
378
  const stderr = child.stderr;
367
379
 
368
- if (stderr !== null) {
369
- stderr.setEncoding("utf8");
370
- const logStream = createWriteStream(logPath);
371
- logStream.on("error", (): void => undefined);
372
-
373
- stderr.on("data", (chunk: string) => {
374
- captured.push(chunk);
375
- logStream.write(chunk);
376
- });
380
+ if (stderr === null) {
381
+ return { chunks: captured, logPath, stop: noVirtualSeat };
377
382
  }
378
383
 
379
- return captured;
384
+ stderr.setEncoding("utf8");
385
+ const descriptor = openSync(logPath, "a");
386
+ let isOpen = true;
387
+
388
+ const onData = (chunk: string): void => {
389
+ captured.push(chunk);
390
+
391
+ try {
392
+ writeSync(descriptor, chunk);
393
+ } catch {
394
+ return;
395
+ }
396
+ };
397
+
398
+ stderr.on("data", onData);
399
+
400
+ return {
401
+ chunks: captured,
402
+ logPath,
403
+ stop: () => {
404
+ stderr.removeListener("data", onData);
405
+
406
+ if (isOpen) {
407
+ isOpen = false;
408
+ closeSync(descriptor);
409
+ }
410
+ },
411
+ };
380
412
  };
381
413
 
382
414
  const compositorExitMessage = (
383
415
  code: number | null,
384
416
  signal: NodeJS.Signals | null,
385
- capturedStderr: string[],
417
+ captured: CapturedStderr,
386
418
  ): string =>
387
419
  `[gtkx] the headless compositor exited (code ${String(code)}, signal ${signal ?? "null"}); ` +
388
- `every Wayland client in this worker has been severed.\n${capturedStderr.join("")}`;
420
+ "every Wayland client in this worker has been severed. " +
421
+ `Its stderr log is kept at ${captured.logPath} until this worker exits.\n${captured.chunks.join("")}`;
389
422
 
390
- const watchCompositorExit = (child: ChildProcess, capturedStderr: string[]): (() => void) => {
423
+ const watchCompositorExit = (child: ChildProcess, captured: CapturedStderr): (() => void) => {
391
424
  const report = (code: number | null, signal: NodeJS.Signals | null): void => {
392
- process.stderr.write(compositorExitMessage(code, signal, capturedStderr));
425
+ process.stderr.write(compositorExitMessage(code, signal, captured));
393
426
  };
394
427
 
395
428
  child.on("exit", report);
@@ -397,9 +430,63 @@ const watchCompositorExit = (child: ChildProcess, capturedStderr: string[]): (()
397
430
  return () => child.removeListener("exit", report);
398
431
  };
399
432
 
400
- const waitForDisplaySockets = async ({ compositorMonitor, busMonitor }: DisplaySockets): Promise<void> => {
401
- await waitForSocket({ monitor: busMonitor, guards: [compositorMonitor] });
402
- await waitForSocket({ monitor: compositorMonitor });
433
+ const waitForDisplaySockets = ({ compositorMonitor, busMonitor }: DisplaySockets): Promise<void> =>
434
+ waitForSockets({ monitors: [busMonitor, compositorMonitor] });
435
+
436
+ const CONNECT_RETRY_MS = 10;
437
+
438
+ const pause = (duration: number): Promise<void> =>
439
+ new Promise((resolve) => {
440
+ setTimeout(resolve, duration);
441
+ });
442
+
443
+ const connectFailure = (path: string): Promise<Error | undefined> =>
444
+ new Promise((resolve) => {
445
+ const socket = connect(path);
446
+
447
+ socket.once("connect", () => {
448
+ socket.destroy();
449
+ resolve(undefined);
450
+ });
451
+
452
+ socket.once("error", (cause: Error) => {
453
+ socket.destroy();
454
+ resolve(cause);
455
+ });
456
+ });
457
+
458
+ const unreachableMessage = (monitor: ChildMonitor, cause: Error): string =>
459
+ `${monitor.label} did not accept a connection on ${monitor.path}: ${cause.message}\n${monitor.read()}`;
460
+
461
+ const waitUntilConnectable = async (monitor: ChildMonitor): Promise<void> => {
462
+ const deadline = Date.now() + SOCKET_TIMEOUT_MS;
463
+
464
+ for (;;) {
465
+ const failure = await connectFailure(monitor.path);
466
+
467
+ if (failure === undefined) {
468
+ return;
469
+ }
470
+
471
+ if (!monitor.isRunning() || Date.now() >= deadline) {
472
+ throw new Error(unreachableMessage(monitor, failure));
473
+ }
474
+
475
+ await pause(CONNECT_RETRY_MS);
476
+ }
477
+ };
478
+
479
+ const attachCompositorClient = async (
480
+ compositor: SpawnedCompositor,
481
+ monitor: ChildMonitor,
482
+ ): Promise<() => void> => {
483
+ if (compositor.requiresVirtualSeat) {
484
+ return startVirtualSeat(monitor.path);
485
+ }
486
+
487
+ await waitUntilConnectable(monitor);
488
+
489
+ return noVirtualSeat;
403
490
  };
404
491
 
405
492
  const killSpawned = (children: ChildProcess[]): void => {
@@ -421,10 +508,25 @@ const makeTeardown = (stops: (() => void)[]): (() => void) => {
421
508
  };
422
509
  };
423
510
 
511
+ const createHeadlessRuntimeDirectory = (): string => {
512
+ const runtimeDir = mkdtempSync(join(tmpdir(), "gtkx-xdg-"));
513
+
514
+ try {
515
+ chmodSync(runtimeDir, 0o700);
516
+ const markerPath = join(runtimeDir, HEADLESS_RUNTIME_MARKER);
517
+ writeFileSync(markerPath, createHeadlessRuntimeMarker(runtimeDir), { flag: "wx", mode: 0o600 });
518
+ chmodSync(markerPath, 0o600);
519
+
520
+ return runtimeDir;
521
+ } catch (error) {
522
+ rmSync(runtimeDir, { recursive: true, force: true });
523
+ throw error;
524
+ }
525
+ };
526
+
424
527
  const startHeadlessDisplay = async (options: HeadlessOptions): Promise<() => void> => {
425
528
  const env: EnvSnapshot = {};
426
- const runtimeDir = mkdtempSync(join(tmpdir(), "gtkx-xdg-"));
427
- chmodSync(runtimeDir, 0o700);
529
+ const runtimeDir = createHeadlessRuntimeDirectory();
428
530
  const spawned: ChildProcess[] = [];
429
531
 
430
532
  const removeRuntime = (): void => {
@@ -433,6 +535,7 @@ const startHeadlessDisplay = async (options: HeadlessOptions): Promise<() => voi
433
535
  };
434
536
 
435
537
  try {
538
+ applyEnv(env, STATIC_HEADLESS_ENV);
436
539
  applyEnv(env, { XDG_RUNTIME_DIR: runtimeDir });
437
540
  const busConfigPath = join(runtimeDir, "session.conf");
438
541
  const busSocketPath = join(runtimeDir, "bus");
@@ -440,6 +543,7 @@ const startHeadlessDisplay = async (options: HeadlessOptions): Promise<() => voi
440
543
 
441
544
  const busChild = spawnWithParentDeathSignal("dbus-daemon", [`--config-file=${busConfigPath}`], {
442
545
  stdio: ["ignore", "ignore", "pipe"],
546
+ cleanupDirectories: [runtimeDir],
443
547
  });
444
548
 
445
549
  busChild.unref();
@@ -453,13 +557,14 @@ const startHeadlessDisplay = async (options: HeadlessOptions): Promise<() => voi
453
557
  const compositorMonitor = monitorChild(compositor.child, "Compositor", compositorSocketPath);
454
558
  applyEnv(env, { WAYLAND_DISPLAY: compositor.socket });
455
559
  await waitForDisplaySockets({ compositorMonitor, busMonitor });
456
- const stopVirtualSeat = await attachVirtualSeat(compositor, compositorSocketPath);
560
+ const stopVirtualSeat = await attachCompositorClient(compositor, compositorMonitor);
457
561
  const stopNotifications = await startNotificationService(`unix:path=${busSocketPath}`);
458
562
  const capturedStderr = captureCompositorStderr(compositor.child, join(runtimeDir, "compositor.stderr.log"));
459
563
  const stopExitWatch = watchCompositorExit(compositor.child, capturedStderr);
460
564
 
461
565
  return makeTeardown([
462
566
  stopExitWatch,
567
+ capturedStderr.stop,
463
568
  () => {
464
569
  killSpawned(spawned);
465
570
  },
@@ -0,0 +1,14 @@
1
+ export {
2
+ readHeadlessOptions,
3
+ resolveHeadlessOptions,
4
+ startHeadlessDisplay,
5
+ STATIC_HEADLESS_ENV,
6
+ type CompositorId,
7
+ type HeadlessOptions,
8
+ } from "./headless-display.js";
9
+ export {
10
+ findStaleHeadlessDisplays,
11
+ reapStaleHeadlessDisplays,
12
+ reapStaleHeadlessDisplaysAtStartup,
13
+ type StaleHeadlessDisplay,
14
+ } from "./reap-headless-displays.js";
package/src/index.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  import type { Plugin } from "vitest/config";
2
+ import { assertSupportedNodeVersion, createConfigLoader } from "@gtkx/config/internal";
2
3
  import createConfigPlugin from "@gtkx/config/vite-plugin";
3
4
  import { existsSync } from "node:fs";
4
5
  import { join } from "node:path";
5
6
  import { pathToFileURL } from "node:url";
6
7
  import { type HeadlessOptions, STATIC_HEADLESS_ENV } from "./headless-display.ts";
8
+ import { reapStaleHeadlessDisplaysAtStartup } from "./reap-headless-displays.ts";
7
9
 
8
10
  /**
9
11
  * Options accepted by the GTKX Vitest plugin. Every headless display
10
12
  * setting is optional and falls back to a built-in default when omitted.
11
13
  */
12
- type PluginOptions = Partial<HeadlessOptions>;
14
+ type PluginOptions = Partial<HeadlessOptions> & Partial<Record<"configFile", string | undefined>>;
13
15
 
14
16
  const GTKX_INLINE_DEPS: RegExp[] = [/@gtkx\/(?!native)/, /[/\\]\.gtkx[/\\]/];
15
17
  const DEFAULT_TIMEOUT = 30_000;
@@ -21,7 +23,7 @@ const workerPreloadUrl = (): URL => {
21
23
  return pathToFileURL(path);
22
24
  };
23
25
 
24
- const headlessPreloadSpecifier = (options: PluginOptions): string => {
26
+ const headlessPreloadSpecifier = (options: Partial<HeadlessOptions>): string => {
25
27
  const url = workerPreloadUrl();
26
28
 
27
29
  for (const [key, value] of Object.entries(options)) {
@@ -36,17 +38,29 @@ const headlessPreloadSpecifier = (options: PluginOptions): string => {
36
38
  * Wayland display. It configures the forks pool, injects the worker preload and
37
39
  * setup files, and sets the environment needed for headless GTK4 rendering.
38
40
  *
41
+ * Each worker's compositor, session bus, and private runtime directory are torn
42
+ * down when the worker exits, and a guard process tears them down as well when
43
+ * the worker or the Vitest process that launched it is killed with `SIGKILL`.
44
+ * Creating the plugin reaps stale `gtkx-xdg-*` runtime directories left behind
45
+ * by earlier runs, which `gtkx cleanup` also does on demand.
46
+ *
39
47
  * @param options Headless display settings (size, compositor) forwarded to each worker.
40
48
  * @returns A Vitest config plugin.
41
49
  */
42
- const gtkx = (options: PluginOptions = {}): Plugin =>
43
- createConfigPlugin({
50
+ const gtkx = (options: PluginOptions = {}): Plugin => {
51
+ assertSupportedNodeVersion();
52
+ reapStaleHeadlessDisplaysAtStartup();
53
+ const { configFile, ...headlessOptions } = options;
54
+ const loadConfig = createConfigLoader({ configFile });
55
+
56
+ return createConfigPlugin({
44
57
  name: "gtkx:vitest",
58
+ loadConfig,
45
59
  config(config) {
46
60
  return {
47
61
  test: {
48
62
  globals: true,
49
- execArgv: ["--import", headlessPreloadSpecifier(options)],
63
+ execArgv: ["--disable-sigusr1", "--import", headlessPreloadSpecifier(headlessOptions)],
50
64
  testTimeout: config.test?.testTimeout ?? DEFAULT_TIMEOUT,
51
65
  hookTimeout: config.test?.hookTimeout ?? DEFAULT_TIMEOUT,
52
66
  pool: "forks",
@@ -60,6 +74,7 @@ const gtkx = (options: PluginOptions = {}): Plugin =>
60
74
  };
61
75
  },
62
76
  });
77
+ };
63
78
 
64
79
  export default gtkx;
65
80
  export { type CompositorId, type HeadlessOptions } from "./headless-display.ts";