@gtkx/vitest 2.0.0-beta.1 → 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/README.md +43 -40
- package/dist/headless-config.d.ts +7 -0
- package/dist/headless-config.d.ts.map +1 -0
- package/dist/headless-config.js +44 -0
- package/dist/headless-config.js.map +1 -0
- package/dist/headless-display.d.ts.map +1 -1
- package/dist/headless-display.js +134 -73
- package/dist/headless-display.js.map +1 -1
- package/dist/headless.d.ts +3 -0
- package/dist/headless.d.ts.map +1 -0
- package/dist/headless.js +3 -0
- package/dist/headless.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -18
- package/dist/index.js.map +1 -1
- package/dist/reap-headless-displays.d.ts +10 -0
- package/dist/reap-headless-displays.d.ts.map +1 -0
- package/dist/reap-headless-displays.js +211 -0
- package/dist/reap-headless-displays.js.map +1 -0
- package/dist/worker-preload.js +9 -5
- package/dist/worker-preload.js.map +1 -1
- package/package.json +14 -6
- package/src/headless-config.ts +61 -0
- package/src/headless-display.ts +195 -90
- package/src/headless.ts +14 -0
- package/src/index.ts +20 -5
- package/src/reap-headless-displays.ts +309 -0
- package/src/worker-preload.ts +10 -5
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gtkx/vitest",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
4
|
-
"description": "Vitest plugin for
|
|
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",
|
|
@@ -32,6 +34,12 @@
|
|
|
32
34
|
"import": "./dist/index.js",
|
|
33
35
|
"require": null,
|
|
34
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"
|
|
35
43
|
}
|
|
36
44
|
},
|
|
37
45
|
"sideEffects": false,
|
|
@@ -53,15 +61,15 @@
|
|
|
53
61
|
"node": ">=26.7.0"
|
|
54
62
|
},
|
|
55
63
|
"dependencies": {
|
|
56
|
-
"@gtkx/config": "2.0.0-beta.
|
|
57
|
-
"@gtkx/utils": "2.0.0-beta.
|
|
64
|
+
"@gtkx/config": "2.0.0-beta.10",
|
|
65
|
+
"@gtkx/utils": "2.0.0-beta.10",
|
|
58
66
|
"@homebridge/dbus-native": "^0.7.9"
|
|
59
67
|
},
|
|
60
68
|
"devDependencies": {
|
|
61
|
-
"vitest": "^
|
|
69
|
+
"vitest": "^5.0.0"
|
|
62
70
|
},
|
|
63
71
|
"peerDependencies": {
|
|
64
|
-
"vitest": ">=
|
|
72
|
+
"vitest": ">=5"
|
|
65
73
|
},
|
|
66
74
|
"scripts": {
|
|
67
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
|
+
};
|
package/src/headless-display.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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
|
|
50
|
-
|
|
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
|
|
61
|
-
|
|
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
|
|
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
|
|
141
|
-
|
|
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
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
...
|
|
367
|
+
...monitors.map((monitor) => monitor.subscribe(fail)),
|
|
356
368
|
);
|
|
357
369
|
};
|
|
358
370
|
|
|
359
|
-
const
|
|
371
|
+
const waitForSockets = (options: WaitForSocketsOptions): Promise<void> =>
|
|
360
372
|
new Promise((resolve, reject) => {
|
|
361
|
-
|
|
373
|
+
watchForSockets({ options, resolve, reject });
|
|
362
374
|
});
|
|
363
375
|
|
|
364
|
-
const captureCompositorStderr = (child: ChildProcess, logPath: 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
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
+
captured: CapturedStderr,
|
|
386
418
|
): string =>
|
|
387
419
|
`[gtkx] the headless compositor exited (code ${String(code)}, signal ${signal ?? "null"}); ` +
|
|
388
|
-
|
|
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,
|
|
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,
|
|
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 =
|
|
401
|
-
|
|
402
|
-
|
|
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 =
|
|
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
|
|
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
|
},
|
package/src/headless.ts
ADDED
|
@@ -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:
|
|
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
|
-
|
|
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: ["--disable-sigusr1", "--import", headlessPreloadSpecifier(
|
|
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";
|