picopilot 0.1.5 → 0.2.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/cli.d.ts.map +1 -1
- package/dist/cli.js +205 -17
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts +3 -3
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +69 -44
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts +22 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +226 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/export.d.ts +3 -4
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +4 -9
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/gfx.d.ts +2 -2
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +14 -7
- package/dist/commands/gfx.js.map +1 -1
- package/dist/commands/lint.d.ts +2 -2
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +3 -2
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/minify.d.ts +2 -2
- package/dist/commands/minify.d.ts.map +1 -1
- package/dist/commands/minify.js +3 -2
- package/dist/commands/minify.js.map +1 -1
- package/dist/commands/pico8-env.d.ts +112 -0
- package/dist/commands/pico8-env.d.ts.map +1 -0
- package/dist/commands/pico8-env.js +118 -0
- package/dist/commands/pico8-env.js.map +1 -0
- package/dist/commands/playtest.d.ts +2 -2
- package/dist/commands/playtest.d.ts.map +1 -1
- package/dist/commands/playtest.js +4 -9
- package/dist/commands/playtest.js.map +1 -1
- package/dist/commands/run.d.ts +3 -4
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +52 -19
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/serve.d.ts +2 -2
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +4 -9
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/shrinko-env.d.ts +22 -0
- package/dist/commands/shrinko-env.d.ts.map +1 -0
- package/dist/commands/shrinko-env.js +15 -0
- package/dist/commands/shrinko-env.js.map +1 -0
- package/dist/commands/tokens.d.ts +4 -4
- package/dist/commands/tokens.d.ts.map +1 -1
- package/dist/commands/tokens.js +3 -2
- package/dist/commands/tokens.js.map +1 -1
- package/dist/commands/verify.d.ts +2 -2
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +3 -2
- package/dist/commands/verify.js.map +1 -1
- package/dist/engine/child-env.d.ts +14 -0
- package/dist/engine/child-env.d.ts.map +1 -0
- package/dist/engine/child-env.js +21 -0
- package/dist/engine/child-env.js.map +1 -0
- package/dist/engine/pico8/adapter.d.ts +23 -3
- package/dist/engine/pico8/adapter.d.ts.map +1 -1
- package/dist/engine/pico8/adapter.js.map +1 -1
- package/dist/engine/pico8/cleanup.d.ts +16 -0
- package/dist/engine/pico8/cleanup.d.ts.map +1 -0
- package/dist/engine/pico8/cleanup.js +74 -0
- package/dist/engine/pico8/cleanup.js.map +1 -0
- package/dist/engine/pico8/display.d.ts +149 -0
- package/dist/engine/pico8/display.d.ts.map +1 -0
- package/dist/engine/pico8/display.js +311 -0
- package/dist/engine/pico8/display.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -1
- package/dist/engine/pico8/drive.js +28 -2
- package/dist/engine/pico8/drive.js.map +1 -1
- package/dist/engine/pico8/harness.d.ts +8 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -1
- package/dist/engine/pico8/harness.js +9 -1
- package/dist/engine/pico8/harness.js.map +1 -1
- package/dist/engine/pico8/index.d.ts +3 -0
- package/dist/engine/pico8/index.d.ts.map +1 -1
- package/dist/engine/pico8/index.js +3 -0
- package/dist/engine/pico8/index.js.map +1 -1
- package/dist/engine/pico8/shell.d.ts +56 -3
- package/dist/engine/pico8/shell.d.ts.map +1 -1
- package/dist/engine/pico8/shell.js +188 -24
- package/dist/engine/pico8/shell.js.map +1 -1
- package/dist/engine/shrinko/shell.d.ts +3 -3
- package/dist/engine/shrinko/shell.js +3 -3
- package/package.json +2 -2
- package/src/cli.test.ts +229 -0
- package/src/cli.ts +230 -18
- package/src/commands/audio.test.ts +64 -1
- package/src/commands/audio.ts +86 -57
- package/src/commands/doctor.test.ts +149 -0
- package/src/commands/doctor.ts +284 -0
- package/src/commands/export.ts +9 -13
- package/src/commands/gfx.ts +19 -15
- package/src/commands/lint.ts +7 -6
- package/src/commands/minify.ts +7 -6
- package/src/commands/pico8-env.ts +192 -0
- package/src/commands/playtest.ts +8 -11
- package/src/commands/run.test.ts +110 -2
- package/src/commands/run.ts +68 -24
- package/src/commands/serve.ts +8 -11
- package/src/commands/shrinko-env.test.ts +68 -0
- package/src/commands/shrinko-env.ts +40 -0
- package/src/commands/tokens.ts +9 -6
- package/src/commands/verify.ts +7 -4
- package/src/engine/child-env.ts +22 -0
- package/src/engine/pico8/adapter.ts +25 -3
- package/src/engine/pico8/cleanup.test.ts +28 -0
- package/src/engine/pico8/cleanup.ts +76 -0
- package/src/engine/pico8/display.test.ts +210 -0
- package/src/engine/pico8/display.ts +429 -0
- package/src/engine/pico8/drive.test.ts +20 -0
- package/src/engine/pico8/drive.ts +25 -2
- package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
- package/src/engine/pico8/harness.test.ts +26 -0
- package/src/engine/pico8/harness.ts +18 -2
- package/src/engine/pico8/index.ts +3 -0
- package/src/engine/pico8/shell.test.ts +448 -9
- package/src/engine/pico8/shell.ts +255 -26
- package/src/engine/pico8/virtual-display.process.test.ts +206 -0
- package/src/engine/shrinko/shell.ts +3 -3
- package/src/mcp.process.test.ts +219 -0
- package/src/skills/picopilot-audio/SKILL.md +4 -8
- package/src/skills/picopilot-overview/SKILL.md +9 -1
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DISPLAY MODE seam (ADR-0017): how a PICO-8 `-run` session (a real
|
|
3
|
+
* audio+video session, today only the audio-record path) gets a display on a
|
|
4
|
+
* machine that may have none (a server, CI, an agent sandbox).
|
|
5
|
+
*
|
|
6
|
+
* - `native`: spawn PICO-8 as-is (it uses whatever DISPLAY/WAYLAND_DISPLAY the
|
|
7
|
+
* caller has, or fails to open a window if there is none).
|
|
8
|
+
* - `xvfb`: always run a `-run` session on a private virtual X display.
|
|
9
|
+
* - `auto`: the default. Use a virtual display only when the session needs one
|
|
10
|
+
* and none is available (no DISPLAY and no WAYLAND_DISPLAY); otherwise native.
|
|
11
|
+
*
|
|
12
|
+
* Headless `-x` passes (run screenshots, playtest, export, the label capture)
|
|
13
|
+
* never need a display, so they never consult this module and never pay the
|
|
14
|
+
* Xvfb startup cost.
|
|
15
|
+
*
|
|
16
|
+
* The virtual display prefers spawning `Xvfb -displayfd <fd>` directly: the X
|
|
17
|
+
* server picks a free display number itself and writes it back on the fd, so
|
|
18
|
+
* parallel runs never race for a fixed `:99`. When only the Debian `xvfb-run`
|
|
19
|
+
* script exists, it is the fallback (it wraps the PICO-8 command instead).
|
|
20
|
+
* Either way the virtual display lives exactly as long as the run.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {spawn} from 'node:child_process';
|
|
24
|
+
import {accessSync, constants, statSync} from 'node:fs';
|
|
25
|
+
import {delimiter, join} from 'node:path';
|
|
26
|
+
import {killOnAbort} from './cleanup.js';
|
|
27
|
+
|
|
28
|
+
/** The three display modes. `auto` is the default. */
|
|
29
|
+
export const DISPLAY_MODES = ['auto', 'xvfb', 'native'] as const;
|
|
30
|
+
|
|
31
|
+
/** A display mode: how a `-run` session gets a display (see the module doc). */
|
|
32
|
+
export type DisplayMode = (typeof DISPLAY_MODES)[number];
|
|
33
|
+
|
|
34
|
+
/** The default display mode when no flag, env var or config sets one. */
|
|
35
|
+
export const DEFAULT_DISPLAY_MODE: DisplayMode = 'auto';
|
|
36
|
+
|
|
37
|
+
/** The env var that sets the display mode (below the `--display` flag, above picopilot.json). */
|
|
38
|
+
export const DISPLAY_ENV_VAR = 'PICOPILOT_DISPLAY' as const;
|
|
39
|
+
|
|
40
|
+
/** The top-level picopilot.json key that sets the display mode (lowest precedence). */
|
|
41
|
+
export const DISPLAY_CONFIG_KEY = 'display' as const;
|
|
42
|
+
|
|
43
|
+
/** Whether `value` names a valid {@link DisplayMode}. */
|
|
44
|
+
export function isDisplayMode(value: unknown): value is DisplayMode {
|
|
45
|
+
return (
|
|
46
|
+
typeof value === 'string' &&
|
|
47
|
+
(DISPLAY_MODES as readonly string[]).includes(value)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The outcome of {@link resolveDisplayMode}: a mode and where it came from, or an invalid value. */
|
|
52
|
+
export type DisplayModeResolution =
|
|
53
|
+
| {
|
|
54
|
+
readonly ok: true;
|
|
55
|
+
readonly mode: DisplayMode;
|
|
56
|
+
readonly source: 'flag' | 'env' | 'config' | 'default';
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
readonly ok: false;
|
|
60
|
+
readonly source: 'flag' | 'env' | 'config';
|
|
61
|
+
readonly value: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolves the display mode with the precedence `flag > env > config > default`
|
|
66
|
+
* (the same order every picopilot setting uses). An empty/whitespace value is
|
|
67
|
+
* treated as unset; an unknown value is reported (never silently ignored), so a
|
|
68
|
+
* typo like `PICOPILOT_DISPLAY=xfvb` is a visible error rather than `auto`.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveDisplayMode(sources: {
|
|
71
|
+
readonly flag?: unknown;
|
|
72
|
+
readonly env?: unknown;
|
|
73
|
+
readonly config?: unknown;
|
|
74
|
+
}): DisplayModeResolution {
|
|
75
|
+
const ordered = [
|
|
76
|
+
['flag', sources.flag],
|
|
77
|
+
['env', sources.env],
|
|
78
|
+
['config', sources.config],
|
|
79
|
+
] as const;
|
|
80
|
+
for (const [source, raw] of ordered) {
|
|
81
|
+
if (raw === undefined || raw === null) continue;
|
|
82
|
+
const value = String(raw).trim();
|
|
83
|
+
if (value.length === 0) continue;
|
|
84
|
+
if (isDisplayMode(value)) return {ok: true, mode: value, source};
|
|
85
|
+
return {ok: false, source, value};
|
|
86
|
+
}
|
|
87
|
+
return {ok: true, mode: DEFAULT_DISPLAY_MODE, source: 'default'};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether the environment already offers a display (X11 `DISPLAY` or a Wayland socket). */
|
|
91
|
+
export function hasNativeDisplay(env: NodeJS.ProcessEnv): boolean {
|
|
92
|
+
const set = (v: string | undefined) => v !== undefined && v.trim() !== '';
|
|
93
|
+
return set(env.DISPLAY) || set(env.WAYLAND_DISPLAY);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether a `-run` session in this `mode` + `env` should run on a virtual
|
|
98
|
+
* display: always for `xvfb`, never for `native`, and for `auto` only when
|
|
99
|
+
* there is no native display.
|
|
100
|
+
*/
|
|
101
|
+
export function needsVirtualDisplay(
|
|
102
|
+
mode: DisplayMode,
|
|
103
|
+
env: NodeJS.ProcessEnv,
|
|
104
|
+
): boolean {
|
|
105
|
+
if (mode === 'xvfb') return true;
|
|
106
|
+
if (mode === 'native') return false;
|
|
107
|
+
return !hasNativeDisplay(env);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Finds an executable the way a shell would: a name containing `/` is checked
|
|
112
|
+
* as a path, anything else is searched on `env.PATH`. Returns the resolved path,
|
|
113
|
+
* or `undefined`. Pure lookup (no spawn), so it is cheap enough for `doctor` and
|
|
114
|
+
* for the failure path.
|
|
115
|
+
*/
|
|
116
|
+
export function findExecutable(
|
|
117
|
+
name: string,
|
|
118
|
+
env: NodeJS.ProcessEnv,
|
|
119
|
+
): string | undefined {
|
|
120
|
+
const isExec = (p: string): boolean => {
|
|
121
|
+
try {
|
|
122
|
+
if (!statSync(p).isFile()) return false;
|
|
123
|
+
accessSync(p, constants.X_OK);
|
|
124
|
+
return true;
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
if (name.includes('/')) return isExec(name) ? name : undefined;
|
|
130
|
+
for (const dir of (env.PATH ?? '').split(delimiter)) {
|
|
131
|
+
if (dir === '') continue;
|
|
132
|
+
const candidate = join(dir, name);
|
|
133
|
+
if (isExec(candidate)) return candidate;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Which virtual-display backend this machine offers, if any (direct Xvfb is preferred). */
|
|
139
|
+
export type VirtualDisplayBackend = 'Xvfb' | 'xvfb-run';
|
|
140
|
+
|
|
141
|
+
/** Detects the best available virtual-display backend on `env.PATH`. */
|
|
142
|
+
export function detectVirtualDisplayBackend(
|
|
143
|
+
env: NodeJS.ProcessEnv,
|
|
144
|
+
find: (
|
|
145
|
+
name: string,
|
|
146
|
+
env: NodeJS.ProcessEnv,
|
|
147
|
+
) => string | undefined = findExecutable,
|
|
148
|
+
): VirtualDisplayBackend | undefined {
|
|
149
|
+
if (find('Xvfb', env) !== undefined) return 'Xvfb';
|
|
150
|
+
if (find('xvfb-run', env) !== undefined) return 'xvfb-run';
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The remedy a `display-unavailable` error names. */
|
|
155
|
+
export const DISPLAY_REMEDY =
|
|
156
|
+
'install Xvfb (Debian/Ubuntu: apt install xvfb; Fedora: dnf install xorg-x11-server-Xvfb; NixOS: add xvfb-run or xorg.xvfb), or run under a desktop session (DISPLAY or WAYLAND_DISPLAY set)' as const;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The structured "this session needs a display and there is none" failure
|
|
160
|
+
* (mirrors {@link import('./adapter.js').Pico8NotFound}). `detail` says what was
|
|
161
|
+
* tried (Xvfb missing, or Xvfb failed to start and why).
|
|
162
|
+
*/
|
|
163
|
+
export interface DisplayUnavailable {
|
|
164
|
+
readonly ok: false;
|
|
165
|
+
readonly reason: 'display-unavailable';
|
|
166
|
+
readonly remedy: typeof DISPLAY_REMEDY;
|
|
167
|
+
readonly detail: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Builds a {@link DisplayUnavailable} value. */
|
|
171
|
+
export function displayUnavailable(detail: string): DisplayUnavailable {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: 'display-unavailable',
|
|
175
|
+
remedy: DISPLAY_REMEDY,
|
|
176
|
+
detail,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A running virtual display, scoped to ONE PICO-8 session. `env` holds the
|
|
182
|
+
* variables the child must see (DISPLAY for direct Xvfb); `wrap` rewrites the
|
|
183
|
+
* PICO-8 command (identity for direct Xvfb, `xvfb-run -a ...` for the fallback);
|
|
184
|
+
* `stop` tears the X server down and is idempotent.
|
|
185
|
+
*/
|
|
186
|
+
export interface VirtualDisplay {
|
|
187
|
+
readonly backend: VirtualDisplayBackend;
|
|
188
|
+
readonly env: Readonly<Record<string, string>>;
|
|
189
|
+
wrap(file: string, args: string[]): {file: string; args: string[]};
|
|
190
|
+
stop(): Promise<void>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A spawned Xvfb process, abstracted so tests inject a fake. `onDisplayFd`
|
|
195
|
+
* delivers what the server writes on its `-displayfd` pipe (the display number
|
|
196
|
+
* followed by a newline); `onExit` fires when it exits; `kill` sends a signal.
|
|
197
|
+
*/
|
|
198
|
+
export interface XvfbProcess {
|
|
199
|
+
readonly pid: number | undefined;
|
|
200
|
+
onDisplayFd(cb: (chunk: string) => void): void;
|
|
201
|
+
onStderr(cb: (chunk: string) => void): void;
|
|
202
|
+
onExit(cb: (code: number | null) => void): void;
|
|
203
|
+
onError(cb: (err: NodeJS.ErrnoException) => void): void;
|
|
204
|
+
kill(signal: NodeJS.Signals): void;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The Xvfb spawn seam: tests pass a fake that writes a display number (or never does). */
|
|
208
|
+
export type XvfbSpawner = (
|
|
209
|
+
file: string,
|
|
210
|
+
args: string[],
|
|
211
|
+
env: NodeJS.ProcessEnv,
|
|
212
|
+
) => XvfbProcess;
|
|
213
|
+
|
|
214
|
+
/** The fd number Xvfb writes its display number to (`-displayfd 3`). */
|
|
215
|
+
const DISPLAY_FD = 3;
|
|
216
|
+
|
|
217
|
+
/** The screen geometry for the virtual display (PICO-8's default window fits easily). */
|
|
218
|
+
const XVFB_SCREEN = '640x540x24';
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The real {@link XvfbSpawner}: stdio `[ignore, ignore, pipe, pipe]` so fd 3 is
|
|
222
|
+
* the display-number pipe. NOT detached, so a terminal Ctrl-C reaches it with
|
|
223
|
+
* the rest of picopilot's process group.
|
|
224
|
+
*/
|
|
225
|
+
export const spawnXvfb: XvfbSpawner = (file, args, env) => {
|
|
226
|
+
const child = spawn(file, args, {
|
|
227
|
+
env,
|
|
228
|
+
stdio: ['ignore', 'ignore', 'pipe', 'pipe'],
|
|
229
|
+
});
|
|
230
|
+
const fd = child.stdio[DISPLAY_FD] as NodeJS.ReadableStream | null;
|
|
231
|
+
fd?.setEncoding('utf8');
|
|
232
|
+
child.stderr?.setEncoding('utf8');
|
|
233
|
+
return {
|
|
234
|
+
pid: child.pid,
|
|
235
|
+
onDisplayFd(cb) {
|
|
236
|
+
fd?.on('data', cb);
|
|
237
|
+
},
|
|
238
|
+
onStderr(cb) {
|
|
239
|
+
child.stderr?.on('data', cb);
|
|
240
|
+
},
|
|
241
|
+
onExit(cb) {
|
|
242
|
+
child.on('exit', cb);
|
|
243
|
+
},
|
|
244
|
+
onError(cb) {
|
|
245
|
+
child.on('error', cb as (e: Error) => void);
|
|
246
|
+
},
|
|
247
|
+
kill(signal) {
|
|
248
|
+
try {
|
|
249
|
+
child.kill(signal);
|
|
250
|
+
} catch {
|
|
251
|
+
// already gone
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/** Dependencies for {@link startVirtualDisplay}, injectable for tests. */
|
|
258
|
+
export interface VirtualDisplayDeps {
|
|
259
|
+
/** Locates `Xvfb` / `xvfb-run` (defaults to a PATH search). */
|
|
260
|
+
readonly find?: (name: string, env: NodeJS.ProcessEnv) => string | undefined;
|
|
261
|
+
/** Spawns Xvfb (defaults to {@link spawnXvfb}). */
|
|
262
|
+
readonly spawnXvfb?: XvfbSpawner;
|
|
263
|
+
/** How long to wait for Xvfb to report its display number (ms). */
|
|
264
|
+
readonly startTimeoutMs?: number;
|
|
265
|
+
/** How long to wait after SIGTERM before SIGKILL on stop (ms). */
|
|
266
|
+
readonly stopGraceMs?: number;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The default time Xvfb gets to come up before the start is declared failed. */
|
|
270
|
+
const DEFAULT_XVFB_START_TIMEOUT_MS = 10_000;
|
|
271
|
+
|
|
272
|
+
/** The default time Xvfb gets to exit on SIGTERM before SIGKILL. */
|
|
273
|
+
const DEFAULT_XVFB_STOP_GRACE_MS = 2_000;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Starts a private virtual display for one PICO-8 session. Prefers `Xvfb
|
|
277
|
+
* -displayfd` (no fixed display number, so parallel runs never collide), falls
|
|
278
|
+
* back to wrapping the command in `xvfb-run -a`, and resolves the structured
|
|
279
|
+
* {@link DisplayUnavailable} when neither exists or Xvfb fails to start. Never
|
|
280
|
+
* throws. The returned display MUST be stopped by the caller (the shell adapter
|
|
281
|
+
* does it in a `finally`); a kill-on-abort hook also kills a direct Xvfb so an
|
|
282
|
+
* abrupt picopilot exit or signal never leaves one behind.
|
|
283
|
+
*/
|
|
284
|
+
export async function startVirtualDisplay(
|
|
285
|
+
env: NodeJS.ProcessEnv,
|
|
286
|
+
deps: VirtualDisplayDeps = {},
|
|
287
|
+
): Promise<{ok: true; display: VirtualDisplay} | DisplayUnavailable> {
|
|
288
|
+
const find = deps.find ?? findExecutable;
|
|
289
|
+
const xvfb = find('Xvfb', env);
|
|
290
|
+
if (xvfb !== undefined) {
|
|
291
|
+
return startDirectXvfb(xvfb, env, deps);
|
|
292
|
+
}
|
|
293
|
+
const xvfbRun = find('xvfb-run', env);
|
|
294
|
+
if (xvfbRun !== undefined) {
|
|
295
|
+
return {ok: true, display: xvfbRunDisplay(xvfbRun)};
|
|
296
|
+
}
|
|
297
|
+
return displayUnavailable(
|
|
298
|
+
'there is no display (DISPLAY and WAYLAND_DISPLAY are unset) and neither Xvfb nor xvfb-run is on PATH to provide a virtual one',
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The `xvfb-run` fallback: no server to manage here, the script starts Xvfb,
|
|
304
|
+
* runs the command with DISPLAY set, and kills Xvfb when the command exits. The
|
|
305
|
+
* whole thing runs in PICO-8's process group, so the adapter's group kill
|
|
306
|
+
* (sentinel / backstop) takes Xvfb down with it.
|
|
307
|
+
*/
|
|
308
|
+
function xvfbRunDisplay(xvfbRun: string): VirtualDisplay {
|
|
309
|
+
return {
|
|
310
|
+
backend: 'xvfb-run',
|
|
311
|
+
env: {},
|
|
312
|
+
wrap(file, args) {
|
|
313
|
+
return {
|
|
314
|
+
file: xvfbRun,
|
|
315
|
+
args: [
|
|
316
|
+
'-a',
|
|
317
|
+
'-s',
|
|
318
|
+
`-screen 0 ${XVFB_SCREEN} -nolisten tcp`,
|
|
319
|
+
file,
|
|
320
|
+
...args,
|
|
321
|
+
],
|
|
322
|
+
};
|
|
323
|
+
},
|
|
324
|
+
async stop() {},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Starts `Xvfb -displayfd 3` and waits for it to report the display number it picked. */
|
|
329
|
+
function startDirectXvfb(
|
|
330
|
+
file: string,
|
|
331
|
+
env: NodeJS.ProcessEnv,
|
|
332
|
+
deps: VirtualDisplayDeps,
|
|
333
|
+
): Promise<{ok: true; display: VirtualDisplay} | DisplayUnavailable> {
|
|
334
|
+
const spawner = deps.spawnXvfb ?? spawnXvfb;
|
|
335
|
+
const startTimeoutMs = deps.startTimeoutMs ?? DEFAULT_XVFB_START_TIMEOUT_MS;
|
|
336
|
+
const stopGraceMs = deps.stopGraceMs ?? DEFAULT_XVFB_STOP_GRACE_MS;
|
|
337
|
+
const args = [
|
|
338
|
+
'-displayfd',
|
|
339
|
+
String(DISPLAY_FD),
|
|
340
|
+
'-screen',
|
|
341
|
+
'0',
|
|
342
|
+
XVFB_SCREEN,
|
|
343
|
+
'-nolisten',
|
|
344
|
+
'tcp',
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
return new Promise((resolve) => {
|
|
348
|
+
let exited = false;
|
|
349
|
+
let settled = false;
|
|
350
|
+
let fdText = '';
|
|
351
|
+
let stderr = '';
|
|
352
|
+
const exitWaiters: (() => void)[] = [];
|
|
353
|
+
let timer: NodeJS.Timeout | undefined;
|
|
354
|
+
const proc = spawner(file, args, env);
|
|
355
|
+
|
|
356
|
+
// Safety net: if picopilot exits or is signalled mid-run, kill the server
|
|
357
|
+
// synchronously so no stray Xvfb is left behind.
|
|
358
|
+
const disposeAbort = killOnAbort(() => proc.kill('SIGKILL'));
|
|
359
|
+
|
|
360
|
+
const stop = (): Promise<void> => {
|
|
361
|
+
disposeAbort();
|
|
362
|
+
if (exited) return Promise.resolve();
|
|
363
|
+
return new Promise<void>((done) => {
|
|
364
|
+
exitWaiters.push(done);
|
|
365
|
+
proc.kill('SIGTERM');
|
|
366
|
+
const hard = setTimeout(() => {
|
|
367
|
+
if (!exited) proc.kill('SIGKILL');
|
|
368
|
+
}, stopGraceMs);
|
|
369
|
+
exitWaiters.push(() => clearTimeout(hard));
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const fail = (detail: string) => {
|
|
374
|
+
if (settled) return;
|
|
375
|
+
settled = true;
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
void stop().then(() => resolve(displayUnavailable(detail)));
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
proc.onExit(() => {
|
|
381
|
+
exited = true;
|
|
382
|
+
for (const w of exitWaiters.splice(0)) w();
|
|
383
|
+
if (!settled) {
|
|
384
|
+
disposeAbort();
|
|
385
|
+
fail(`Xvfb exited before reporting a display${stderrTail(stderr)}`);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
proc.onError((err) => {
|
|
389
|
+
exited = true;
|
|
390
|
+
for (const w of exitWaiters.splice(0)) w();
|
|
391
|
+
fail(`Xvfb failed to start (${err.code ?? err.message})`);
|
|
392
|
+
});
|
|
393
|
+
proc.onStderr((chunk) => {
|
|
394
|
+
stderr += chunk;
|
|
395
|
+
});
|
|
396
|
+
proc.onDisplayFd((chunk) => {
|
|
397
|
+
if (settled) return;
|
|
398
|
+
fdText += chunk;
|
|
399
|
+
const m = /^\s*(\d+)\s*\n/.exec(fdText);
|
|
400
|
+
if (m === null) return;
|
|
401
|
+
settled = true;
|
|
402
|
+
clearTimeout(timer);
|
|
403
|
+
const display = `:${m[1]}`;
|
|
404
|
+
resolve({
|
|
405
|
+
ok: true,
|
|
406
|
+
display: {
|
|
407
|
+
backend: 'Xvfb',
|
|
408
|
+
env: {DISPLAY: display},
|
|
409
|
+
wrap: (f, a) => ({file: f, args: a}),
|
|
410
|
+
stop,
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
timer = setTimeout(
|
|
416
|
+
() =>
|
|
417
|
+
fail(
|
|
418
|
+
`Xvfb did not report a display within ${startTimeoutMs}ms${stderrTail(stderr)}`,
|
|
419
|
+
),
|
|
420
|
+
startTimeoutMs,
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The last few lines of Xvfb's stderr, as a `: ...` suffix (or empty). */
|
|
426
|
+
function stderrTail(stderr: string): string {
|
|
427
|
+
const lines = stderr.trim().split('\n').filter(Boolean).slice(-3);
|
|
428
|
+
return lines.length === 0 ? '' : `: ${lines.join(' | ')}`;
|
|
429
|
+
}
|
|
@@ -290,6 +290,26 @@ describe('resolveIncludes (#include inlining so the driven cart is self-containe
|
|
|
290
290
|
);
|
|
291
291
|
});
|
|
292
292
|
|
|
293
|
+
it('includes only the __lua__ of a .p8 file, or one tab of it with :n', () => {
|
|
294
|
+
const cart = [
|
|
295
|
+
'pico-8 cartridge // http://www.pico-8.com',
|
|
296
|
+
'version 42',
|
|
297
|
+
'__lua__',
|
|
298
|
+
'tab0=1',
|
|
299
|
+
'-->8',
|
|
300
|
+
'tab1=1',
|
|
301
|
+
'__gfx__',
|
|
302
|
+
'0000',
|
|
303
|
+
'',
|
|
304
|
+
].join('\n');
|
|
305
|
+
const read = (p: string) => (p === 'lib.p8' ? cart : undefined);
|
|
306
|
+
expect(resolveIncludes('#include lib.p8', read)).toBe(
|
|
307
|
+
'tab0=1\n-->8\ntab1=1',
|
|
308
|
+
);
|
|
309
|
+
expect(resolveIncludes('#include lib.p8:1', read)).toBe('tab1=1');
|
|
310
|
+
expect(resolveIncludes('#include lib.p8:0', read)).toBe('tab0=1');
|
|
311
|
+
});
|
|
312
|
+
|
|
293
313
|
it('guards against an include cycle (leaves the repeat verbatim)', () => {
|
|
294
314
|
const files: Record<string, string> = {'a.lua': '#include a.lua'};
|
|
295
315
|
// Does not infinite-loop; the self-reference is left as-is.
|
|
@@ -449,13 +449,36 @@ export function resolveIncludes(
|
|
|
449
449
|
if (m === null) return line;
|
|
450
450
|
const path = m[1] as string;
|
|
451
451
|
if (seen.has(path)) return line; // cycle guard: leave it
|
|
452
|
-
|
|
453
|
-
|
|
452
|
+
// `#include other.p8` takes that cart's code, `other.p8:n` only tab n.
|
|
453
|
+
const p8 = /^(.+\.p8)(?::(\d+))?$/i.exec(path);
|
|
454
|
+
const raw = readInclude(p8 !== null ? (p8[1] as string) : path);
|
|
455
|
+
if (raw === undefined) return line; // unreadable: leave verbatim
|
|
456
|
+
const content = p8 !== null ? p8Code(raw, p8[2]) : raw;
|
|
457
|
+
if (content === undefined) return line;
|
|
454
458
|
return resolveIncludes(content, readInclude, new Set([...seen, path]));
|
|
455
459
|
})
|
|
456
460
|
.join('\n');
|
|
457
461
|
}
|
|
458
462
|
|
|
463
|
+
/**
|
|
464
|
+
* The code an `#include` of a `.p8` cart pulls in: its `__lua__` section, or
|
|
465
|
+
* only tab `tab` (tabs are separated by `-->8` lines). `undefined` when the
|
|
466
|
+
* text does not parse as a cart or the tab does not exist.
|
|
467
|
+
*/
|
|
468
|
+
function p8Code(cartText: string, tab: string | undefined): string | undefined {
|
|
469
|
+
let lua: string | undefined;
|
|
470
|
+
try {
|
|
471
|
+
lua = Cart.parse(cartText).getSection('lua');
|
|
472
|
+
} catch {
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
if (lua === undefined) return undefined;
|
|
476
|
+
const body = lua.replace(/\n$/, '');
|
|
477
|
+
if (tab === undefined) return body;
|
|
478
|
+
const tabs = body.split(/\r?\n-->8\r?\n/);
|
|
479
|
+
return tabs[Number(tab)];
|
|
480
|
+
}
|
|
481
|
+
|
|
459
482
|
/** The default base filename SHOT names each screenshot (`play0.png`, ...). */
|
|
460
483
|
export const SHOT_BASENAME = 'play' as const;
|
|
461
484
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type {XvfbProcess} from './display.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test support (excluded from the build): a controllable fake Xvfb. A test makes
|
|
5
|
+
* it report a display number, write stderr, exit, or fail to spawn, and reads
|
|
6
|
+
* back the signals it received, so virtual-display teardown is provable without
|
|
7
|
+
* a real X server.
|
|
8
|
+
*/
|
|
9
|
+
export class FakeXvfb implements XvfbProcess {
|
|
10
|
+
readonly pid = 4242;
|
|
11
|
+
signals: string[] = [];
|
|
12
|
+
exited = false;
|
|
13
|
+
args: string[] = [];
|
|
14
|
+
private fdCb: (c: string) => void = () => {};
|
|
15
|
+
private exitCb: (code: number | null) => void = () => {};
|
|
16
|
+
private errorCb: (e: NodeJS.ErrnoException) => void = () => {};
|
|
17
|
+
private stderrCb: (c: string) => void = () => {};
|
|
18
|
+
/** Exit as soon as SIGTERM arrives (a well-behaved server). */
|
|
19
|
+
exitOnTerm = true;
|
|
20
|
+
|
|
21
|
+
onDisplayFd(cb: (c: string) => void) {
|
|
22
|
+
this.fdCb = cb;
|
|
23
|
+
}
|
|
24
|
+
onStderr(cb: (c: string) => void) {
|
|
25
|
+
this.stderrCb = cb;
|
|
26
|
+
}
|
|
27
|
+
onExit(cb: (code: number | null) => void) {
|
|
28
|
+
this.exitCb = cb;
|
|
29
|
+
}
|
|
30
|
+
onError(cb: (e: NodeJS.ErrnoException) => void) {
|
|
31
|
+
this.errorCb = cb;
|
|
32
|
+
}
|
|
33
|
+
kill(signal: NodeJS.Signals) {
|
|
34
|
+
this.signals.push(signal);
|
|
35
|
+
if (this.exited) return;
|
|
36
|
+
if (signal === 'SIGKILL' || this.exitOnTerm) this.exit(null);
|
|
37
|
+
}
|
|
38
|
+
// drivers
|
|
39
|
+
reportDisplay(n: number) {
|
|
40
|
+
this.fdCb(`${n}\n`);
|
|
41
|
+
}
|
|
42
|
+
writeStderr(s: string) {
|
|
43
|
+
this.stderrCb(s);
|
|
44
|
+
}
|
|
45
|
+
exit(code: number | null) {
|
|
46
|
+
if (this.exited) return;
|
|
47
|
+
this.exited = true;
|
|
48
|
+
setImmediate(() => this.exitCb(code));
|
|
49
|
+
}
|
|
50
|
+
fail(code: string) {
|
|
51
|
+
const e = new Error(code) as NodeJS.ErrnoException;
|
|
52
|
+
e.code = code;
|
|
53
|
+
this.errorCb(e);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -179,3 +179,29 @@ describe('buildRecordHarness', () => {
|
|
|
179
179
|
}
|
|
180
180
|
});
|
|
181
181
|
});
|
|
182
|
+
|
|
183
|
+
describe('buildRecordHarness: #include carts (the `picopilot init` scaffold)', () => {
|
|
184
|
+
const scaffold = `${HEADER}__lua__\n#include main.lua\n`;
|
|
185
|
+
const mainLua = 'function _update() x=1 end\nfunction _draw() cls() end\n';
|
|
186
|
+
|
|
187
|
+
it('inlines `#include main.lua` so the harness loads from any folder', () => {
|
|
188
|
+
const h = buildRecordHarness(scaffold, {
|
|
189
|
+
readInclude: (p) => (p === 'main.lua' ? mainLua : undefined),
|
|
190
|
+
});
|
|
191
|
+
const lua = Cart.parse(h.cartText).getSection('lua') ?? '';
|
|
192
|
+
// No directive is left for PICO-8 to resolve relative to the harness.
|
|
193
|
+
expect(lua).not.toMatch(/#include/);
|
|
194
|
+
expect(lua).toContain('function _update() x=1 end');
|
|
195
|
+
// The recorder still wraps the (now inlined) cart _update.
|
|
196
|
+
expect(lua.indexOf('x=1')).toBeLessThan(
|
|
197
|
+
lua.indexOf('__pp_rec_update=_update'),
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('without a resolver the directive is left as-is (the old, broken-in-a-temp-dir behaviour)', () => {
|
|
202
|
+
const h = buildRecordHarness(scaffold);
|
|
203
|
+
expect(Cart.parse(h.cartText).getSection('lua')).toContain(
|
|
204
|
+
'#include main.lua',
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
RECORD_WAV_BASENAME,
|
|
21
21
|
} from './adapter.js';
|
|
22
22
|
import {SFX_ROW_LENGTH} from '../audio/sfx.js';
|
|
23
|
+
import {resolveIncludes} from './drive.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* A render target: a single SFX slot, a single music PATTERN, or the whole song
|
|
@@ -301,11 +302,23 @@ export function buildRenderHarness(
|
|
|
301
302
|
* calls the original. This is the record analogue of the debug skill's "add
|
|
302
303
|
* capture lines, run, revert" recipe, done on a THROWAWAY copy.
|
|
303
304
|
*
|
|
305
|
+
* The harness runs from a different folder than the source cart (an isolated
|
|
306
|
+
* temp dir), so a relative `#include main.lua` (picopilot's standard scaffold)
|
|
307
|
+
* would not resolve there and the harness would fail to load. Pass
|
|
308
|
+
* `readInclude` (bound to the SOURCE cart's folder, see `makeReadInclude`) and
|
|
309
|
+
* every `#include` is inlined, exactly as the playtest drive transform does, so
|
|
310
|
+
* the harness is self-contained wherever it runs.
|
|
311
|
+
*
|
|
304
312
|
* @throws {HarnessError} an unparseable cart.
|
|
305
313
|
*/
|
|
306
314
|
export function buildRecordHarness(
|
|
307
315
|
cartText: string,
|
|
308
|
-
options: {
|
|
316
|
+
options: {
|
|
317
|
+
seconds?: number;
|
|
318
|
+
sentinel?: string;
|
|
319
|
+
wavBasename?: string;
|
|
320
|
+
readInclude?: (includePath: string) => string | undefined;
|
|
321
|
+
} = {},
|
|
309
322
|
): RenderHarness {
|
|
310
323
|
let cart: Cart;
|
|
311
324
|
try {
|
|
@@ -322,7 +335,10 @@ export function buildRecordHarness(
|
|
|
322
335
|
const sentinel = options.sentinel ?? DONE_SENTINEL;
|
|
323
336
|
const wavBasename = options.wavBasename ?? RECORD_WAV_BASENAME;
|
|
324
337
|
|
|
325
|
-
const existing =
|
|
338
|
+
const existing = resolveIncludes(
|
|
339
|
+
cart.getSection('lua') ?? '',
|
|
340
|
+
options.readInclude,
|
|
341
|
+
);
|
|
326
342
|
// Append the recorder AFTER the cart's own code. It wraps _update so it works
|
|
327
343
|
// whether or not the cart defines one, and never edits the cart's own logic.
|
|
328
344
|
const recorder = [
|
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
* - `adapter`: the `Pico8Adapter` interface, `Pico8Result`, the structured
|
|
7
7
|
* `pico8-not-found` value, and the run-report shapes.
|
|
8
8
|
* - `sentinel`: the pure sentinel-watch core (CI-testable without the binary).
|
|
9
|
+
* - `display`: the display mode (auto/xvfb/native) + the private virtual
|
|
10
|
+
* display a `-run` session uses on a display-less box (ADR-0017).
|
|
9
11
|
* - `shell`: the v1 adapter shelling out to native `pico8 -x`, with an
|
|
10
12
|
* injectable spawn seam for isolation-testing.
|
|
11
13
|
*/
|
|
12
14
|
export * from './adapter.js';
|
|
15
|
+
export * from './display.js';
|
|
13
16
|
export * from './drive.js';
|
|
14
17
|
export * from './harness.js';
|
|
15
18
|
export * from './sentinel.js';
|