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.
Files changed (126) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +205 -17
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/audio.d.ts +3 -3
  5. package/dist/commands/audio.d.ts.map +1 -1
  6. package/dist/commands/audio.js +69 -44
  7. package/dist/commands/audio.js.map +1 -1
  8. package/dist/commands/doctor.d.ts +22 -0
  9. package/dist/commands/doctor.d.ts.map +1 -0
  10. package/dist/commands/doctor.js +226 -0
  11. package/dist/commands/doctor.js.map +1 -0
  12. package/dist/commands/export.d.ts +3 -4
  13. package/dist/commands/export.d.ts.map +1 -1
  14. package/dist/commands/export.js +4 -9
  15. package/dist/commands/export.js.map +1 -1
  16. package/dist/commands/gfx.d.ts +2 -2
  17. package/dist/commands/gfx.d.ts.map +1 -1
  18. package/dist/commands/gfx.js +14 -7
  19. package/dist/commands/gfx.js.map +1 -1
  20. package/dist/commands/lint.d.ts +2 -2
  21. package/dist/commands/lint.d.ts.map +1 -1
  22. package/dist/commands/lint.js +3 -2
  23. package/dist/commands/lint.js.map +1 -1
  24. package/dist/commands/minify.d.ts +2 -2
  25. package/dist/commands/minify.d.ts.map +1 -1
  26. package/dist/commands/minify.js +3 -2
  27. package/dist/commands/minify.js.map +1 -1
  28. package/dist/commands/pico8-env.d.ts +112 -0
  29. package/dist/commands/pico8-env.d.ts.map +1 -0
  30. package/dist/commands/pico8-env.js +118 -0
  31. package/dist/commands/pico8-env.js.map +1 -0
  32. package/dist/commands/playtest.d.ts +2 -2
  33. package/dist/commands/playtest.d.ts.map +1 -1
  34. package/dist/commands/playtest.js +4 -9
  35. package/dist/commands/playtest.js.map +1 -1
  36. package/dist/commands/run.d.ts +3 -4
  37. package/dist/commands/run.d.ts.map +1 -1
  38. package/dist/commands/run.js +52 -19
  39. package/dist/commands/run.js.map +1 -1
  40. package/dist/commands/serve.d.ts +2 -2
  41. package/dist/commands/serve.d.ts.map +1 -1
  42. package/dist/commands/serve.js +4 -9
  43. package/dist/commands/serve.js.map +1 -1
  44. package/dist/commands/shrinko-env.d.ts +22 -0
  45. package/dist/commands/shrinko-env.d.ts.map +1 -0
  46. package/dist/commands/shrinko-env.js +15 -0
  47. package/dist/commands/shrinko-env.js.map +1 -0
  48. package/dist/commands/tokens.d.ts +4 -4
  49. package/dist/commands/tokens.d.ts.map +1 -1
  50. package/dist/commands/tokens.js +3 -2
  51. package/dist/commands/tokens.js.map +1 -1
  52. package/dist/commands/verify.d.ts +2 -2
  53. package/dist/commands/verify.d.ts.map +1 -1
  54. package/dist/commands/verify.js +3 -2
  55. package/dist/commands/verify.js.map +1 -1
  56. package/dist/engine/child-env.d.ts +14 -0
  57. package/dist/engine/child-env.d.ts.map +1 -0
  58. package/dist/engine/child-env.js +21 -0
  59. package/dist/engine/child-env.js.map +1 -0
  60. package/dist/engine/pico8/adapter.d.ts +23 -3
  61. package/dist/engine/pico8/adapter.d.ts.map +1 -1
  62. package/dist/engine/pico8/adapter.js.map +1 -1
  63. package/dist/engine/pico8/cleanup.d.ts +16 -0
  64. package/dist/engine/pico8/cleanup.d.ts.map +1 -0
  65. package/dist/engine/pico8/cleanup.js +74 -0
  66. package/dist/engine/pico8/cleanup.js.map +1 -0
  67. package/dist/engine/pico8/display.d.ts +149 -0
  68. package/dist/engine/pico8/display.d.ts.map +1 -0
  69. package/dist/engine/pico8/display.js +311 -0
  70. package/dist/engine/pico8/display.js.map +1 -0
  71. package/dist/engine/pico8/drive.d.ts.map +1 -1
  72. package/dist/engine/pico8/drive.js +28 -2
  73. package/dist/engine/pico8/drive.js.map +1 -1
  74. package/dist/engine/pico8/harness.d.ts +8 -0
  75. package/dist/engine/pico8/harness.d.ts.map +1 -1
  76. package/dist/engine/pico8/harness.js +9 -1
  77. package/dist/engine/pico8/harness.js.map +1 -1
  78. package/dist/engine/pico8/index.d.ts +3 -0
  79. package/dist/engine/pico8/index.d.ts.map +1 -1
  80. package/dist/engine/pico8/index.js +3 -0
  81. package/dist/engine/pico8/index.js.map +1 -1
  82. package/dist/engine/pico8/shell.d.ts +56 -3
  83. package/dist/engine/pico8/shell.d.ts.map +1 -1
  84. package/dist/engine/pico8/shell.js +188 -24
  85. package/dist/engine/pico8/shell.js.map +1 -1
  86. package/dist/engine/shrinko/shell.d.ts +3 -3
  87. package/dist/engine/shrinko/shell.js +3 -3
  88. package/package.json +2 -2
  89. package/src/cli.test.ts +229 -0
  90. package/src/cli.ts +230 -18
  91. package/src/commands/audio.test.ts +64 -1
  92. package/src/commands/audio.ts +86 -57
  93. package/src/commands/doctor.test.ts +149 -0
  94. package/src/commands/doctor.ts +284 -0
  95. package/src/commands/export.ts +9 -13
  96. package/src/commands/gfx.ts +19 -15
  97. package/src/commands/lint.ts +7 -6
  98. package/src/commands/minify.ts +7 -6
  99. package/src/commands/pico8-env.ts +192 -0
  100. package/src/commands/playtest.ts +8 -11
  101. package/src/commands/run.test.ts +110 -2
  102. package/src/commands/run.ts +68 -24
  103. package/src/commands/serve.ts +8 -11
  104. package/src/commands/shrinko-env.test.ts +68 -0
  105. package/src/commands/shrinko-env.ts +40 -0
  106. package/src/commands/tokens.ts +9 -6
  107. package/src/commands/verify.ts +7 -4
  108. package/src/engine/child-env.ts +22 -0
  109. package/src/engine/pico8/adapter.ts +25 -3
  110. package/src/engine/pico8/cleanup.test.ts +28 -0
  111. package/src/engine/pico8/cleanup.ts +76 -0
  112. package/src/engine/pico8/display.test.ts +210 -0
  113. package/src/engine/pico8/display.ts +429 -0
  114. package/src/engine/pico8/drive.test.ts +20 -0
  115. package/src/engine/pico8/drive.ts +25 -2
  116. package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
  117. package/src/engine/pico8/harness.test.ts +26 -0
  118. package/src/engine/pico8/harness.ts +18 -2
  119. package/src/engine/pico8/index.ts +3 -0
  120. package/src/engine/pico8/shell.test.ts +448 -9
  121. package/src/engine/pico8/shell.ts +255 -26
  122. package/src/engine/pico8/virtual-display.process.test.ts +206 -0
  123. package/src/engine/shrinko/shell.ts +3 -3
  124. package/src/mcp.process.test.ts +219 -0
  125. package/src/skills/picopilot-audio/SKILL.md +4 -8
  126. package/src/skills/picopilot-overview/SKILL.md +9 -1
@@ -14,21 +14,29 @@ import {
14
14
  buildRenderHarness,
15
15
  DONE_SENTINEL,
16
16
  HarnessError,
17
- type Pico8Adapter,
17
+ type Pico8RecordResult,
18
18
  type RenderTarget,
19
- ShellPico8Adapter,
20
19
  } from '../engine/pico8/index.js';
20
+ import {makeReadInclude} from './cart-includes.js';
21
+ import {
22
+ createShellAdapterFactory,
23
+ DISPLAY_OPTION,
24
+ displayUnavailableError,
25
+ PICO8_ENV,
26
+ type Pico8AdapterFactory,
27
+ pico8NotFoundError,
28
+ resolveCommandDisplay,
29
+ } from './pico8-env.js';
21
30
 
22
31
  /**
23
32
  * Injects the PICO-8 adapter the audio commands use (defaults to the shell
24
33
  * adapter over native `pico8 -run`). Mirrors `run`'s `Pico8AdapterFactory` seam:
25
34
  * a test passes a fake to drive the record orchestration + the absent path
26
- * WITHOUT the paid binary, and to isolate the child `env`.
35
+ * WITHOUT the paid binary.
27
36
  */
28
- export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
37
+ export type {Pico8AdapterFactory} from './pico8-env.js';
29
38
 
30
- const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
31
- new ShellPico8Adapter({env});
39
+ const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
32
40
 
33
41
  /**
34
42
  * The default record backstop: kill a capture that never signals after this many
@@ -39,7 +47,7 @@ const DEFAULT_BACKSTOP_MS = 30_000;
39
47
 
40
48
  /** The honest one-liner both `record` and `render` carry (ADR-0009): not offline. */
41
49
  const HONEST_CAVEAT =
42
- 'This is a REAL-TIME recording of playback (needs a real audio+video session, not headless), NOT a deterministic offline export.';
50
+ 'This is a REAL-TIME recording of playback in a -run session, NOT a deterministic offline export. Works headless: with no display, picopilot runs the session on a private virtual display (ADR-0017, --display).';
43
51
 
44
52
  /** Resolves a path argument to an absolute path (relative to cwd). */
45
53
  function resolvePath(p: string): string {
@@ -64,14 +72,8 @@ function chooseWavDir(given: string | undefined): string {
64
72
  return dir;
65
73
  }
66
74
 
67
- /** The env schema both audio subcommands share (locates the binary, in isolation). */
68
- const AUDIO_ENV = z.object({
69
- PICO8_PATH: z
70
- .string()
71
- .optional()
72
- .describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
73
- PATH: z.string().optional().describe('Used to locate `pico8`.'),
74
- });
75
+ /** The env schema both audio subcommands share (locates the binary). */
76
+ const AUDIO_ENV = PICO8_ENV;
75
77
 
76
78
  /** The structured output shape shared by `record` and `render`. */
77
79
  const AUDIO_OUTPUT = z.object({
@@ -79,7 +81,7 @@ const AUDIO_OUTPUT = z.object({
79
81
  .string()
80
82
  .optional()
81
83
  .describe(
82
- 'Absolute path of the recorded WAV, or omitted if no audio was captured (e.g. a headless session with no audio device).',
84
+ 'Absolute path of the recorded WAV, or omitted if no audio was captured (see noAudioReason).',
83
85
  ),
84
86
  captured: z
85
87
  .boolean()
@@ -98,41 +100,60 @@ const AUDIO_OUTPUT = z.object({
98
100
  .number()
99
101
  .describe('The record window in seconds the capture ran for.'),
100
102
  printh: z.string().describe('Captured stdout from the session.'),
103
+ display: z
104
+ .enum(['native', 'Xvfb', 'xvfb-run'])
105
+ .optional()
106
+ .describe(
107
+ 'Which display the -run session used: native, or a private virtual display (Xvfb / xvfb-run).',
108
+ ),
109
+ noAudioReason: z
110
+ .string()
111
+ .optional()
112
+ .describe(
113
+ 'When no WAV was captured: why (backstop fired / PICO-8 exited before the done-sentinel / empty capture).',
114
+ ),
115
+ pico8LogTail: z
116
+ .string()
117
+ .optional()
118
+ .describe(
119
+ "When no WAV was captured: the tail of PICO-8's own log.txt (load errors, SDL init failures).",
120
+ ),
101
121
  });
102
122
 
103
- /** The message a `pico8-not-found` error carries (mirrors `run`): exact remedy + needs. */
104
- function pico8NotFoundMessage(notFound: {
105
- remedy: string;
106
- needs: readonly string[];
107
- }): string {
108
- return `PICO-8 is not installed. ${notFound.remedy} (needs: ${notFound.needs.join(', ')})`;
109
- }
110
-
111
123
  /**
112
- * The advisory CTA when a record/render run captured NO audio (a headless /
113
- * no-audio-device session): the orchestration worked, there is just nothing to
114
- * hear (ADR-0009's live-tier caveat), so it is a nudge, not a hard error.
124
+ * The advisory CTA when a record/render run captured NO audio. The report's
125
+ * `noAudioReason` + `pico8LogTail` say why; this points at the next step.
115
126
  */
116
- function noAudioCta(caveat: string) {
127
+ function noAudioCta(reason: string | undefined) {
117
128
  return {
118
- description: `No audio was captured (${caveat}) Recording needs a real audio+video session:`,
129
+ description: `No audio was captured${reason !== undefined ? ` (${reason})` : ''}. Next:`,
119
130
  commands: [
120
131
  {
121
- command: 'audio render --sfx <n>',
132
+ command: 'verify',
122
133
  description:
123
- 'Run on a developer machine with a display + audio device (not headless CI).',
134
+ 'Run the static gate: a cart that fails to load or errors never reaches audio_end.',
135
+ },
136
+ {
137
+ command: 'doctor',
138
+ description: 'Check PICO-8 and display capabilities on this machine.',
124
139
  },
125
140
  ],
126
141
  };
127
142
  }
128
143
 
144
+ /** Maps a failed record result onto the structured `error(...)` payload. */
145
+ function recordFailure(
146
+ what: string,
147
+ result: Exclude<Pico8RecordResult, {ok: true}>,
148
+ ) {
149
+ return result.reason === 'display-unavailable'
150
+ ? displayUnavailableError(what, result)
151
+ : pico8NotFoundError(result);
152
+ }
153
+
129
154
  /** Shapes an adapter record report into the structured output object. */
130
155
  function recordOutput(
131
- report: {
132
- wavPath: string | undefined;
133
- printh: string;
134
- exitReason: 'sentinel' | 'timeout' | 'exit';
135
- },
156
+ report: Extract<Pico8RecordResult, {ok: true}>['value'],
136
157
  harness: {seconds: number; description: string},
137
158
  ) {
138
159
  return {
@@ -142,6 +163,13 @@ function recordOutput(
142
163
  target: harness.description,
143
164
  seconds: harness.seconds,
144
165
  printh: report.printh,
166
+ ...(report.display !== undefined ? {display: report.display} : {}),
167
+ ...(report.noWavReason !== undefined
168
+ ? {noAudioReason: report.noWavReason}
169
+ : {}),
170
+ ...(report.pico8LogTail !== undefined
171
+ ? {pico8LogTail: report.pico8LogTail}
172
+ : {}),
145
173
  };
146
174
  }
147
175
 
@@ -154,7 +182,7 @@ function recordOutput(
154
182
  function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
155
183
  const audio = Cli.create('audio', {
156
184
  description:
157
- 'Get a WAV out of a cart by RECORDING a running session (ADR-0009). PICO-8 has no working offline WAV export, so both commands are REAL-TIME recordings that need a real audio+video session (not headless), NOT deterministic offline exports. Requires PICO-8.',
185
+ 'Get a WAV out of a cart by RECORDING a running session (ADR-0009). PICO-8 has no working offline WAV export, so both commands are REAL-TIME recordings of a -run session, NOT deterministic offline exports. They work headless: with no display, picopilot runs the session on a private virtual display (ADR-0017). Requires PICO-8.',
158
186
  });
159
187
 
160
188
  // --- audio record <cart> ---------------------------------------------------
@@ -192,6 +220,7 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
192
220
  .describe(
193
221
  'Safety backstop: kill PICO-8 after this many ms if it never signals.',
194
222
  ),
223
+ display: DISPLAY_OPTION,
195
224
  }),
196
225
  env: AUDIO_ENV,
197
226
  output: AUDIO_OUTPUT,
@@ -203,7 +232,7 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
203
232
  options: {seconds: 4},
204
233
  },
205
234
  ],
206
- async run({args, options, env, error, ok}) {
235
+ async run({args, options, env, error, ok, var: vars}) {
207
236
  const cartPath = resolvePath(args.cart);
208
237
  if (!existsSync(cartPath)) {
209
238
  return error({
@@ -215,9 +244,12 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
215
244
 
216
245
  let harness: ReturnType<typeof buildRecordHarness>;
217
246
  try {
247
+ // `#include`s are inlined against the SOURCE cart's folder: the harness
248
+ // runs from the isolated wavDir, where `main.lua` does not exist.
218
249
  harness = buildRecordHarness(readFileSync(cartPath, 'utf8'), {
219
250
  seconds: options.seconds,
220
251
  sentinel: options.sentinel,
252
+ readInclude: makeReadInclude(cartPath),
221
253
  });
222
254
  } catch (e) {
223
255
  if (e instanceof HarnessError) {
@@ -232,7 +264,11 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
232
264
  const harnessCart = join(wavDir, 'record-harness.p8');
233
265
  writeFileSync(harnessCart, harness.cartText);
234
266
 
235
- const adapter = adapterFactory(env as NodeJS.ProcessEnv);
267
+ const display = resolveCommandDisplay(options.display, vars);
268
+ if (!display.ok) return error(display.error);
269
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
270
+ display: display.mode,
271
+ });
236
272
  const result = await adapter.record({
237
273
  cartPath: harnessCart,
238
274
  wavDir,
@@ -241,18 +277,12 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
241
277
  backstopMs: options.backstopMs,
242
278
  });
243
279
 
244
- if (!result.ok) {
245
- return error({
246
- code: 'pico8-not-found',
247
- message: pico8NotFoundMessage(result),
248
- exitCode: 1,
249
- });
250
- }
280
+ if (!result.ok) return error(recordFailure('audio record', result));
251
281
 
252
282
  const out = recordOutput(result.value, harness);
253
283
  return ok(
254
284
  out,
255
- out.captured ? undefined : {cta: noAudioCta(HONEST_CAVEAT)},
285
+ out.captured ? undefined : {cta: noAudioCta(out.noAudioReason)},
256
286
  );
257
287
  },
258
288
  });
@@ -312,6 +342,7 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
312
342
  .describe(
313
343
  'Safety backstop: kill PICO-8 after this many ms if it never signals.',
314
344
  ),
345
+ display: DISPLAY_OPTION,
315
346
  }),
316
347
  env: AUDIO_ENV,
317
348
  output: AUDIO_OUTPUT,
@@ -324,7 +355,7 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
324
355
  options: {pattern: 2, seconds: 6},
325
356
  },
326
357
  ],
327
- async run({args, options, env, error, ok}) {
358
+ async run({args, options, env, error, ok, var: vars}) {
328
359
  if (options.sfx !== undefined && options.pattern !== undefined) {
329
360
  return error({
330
361
  code: 'ambiguous-target',
@@ -368,7 +399,11 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
368
399
  const harnessCart = join(wavDir, 'render-harness.p8');
369
400
  writeFileSync(harnessCart, harness.cartText);
370
401
 
371
- const adapter = adapterFactory(env as NodeJS.ProcessEnv);
402
+ const display = resolveCommandDisplay(options.display, vars);
403
+ if (!display.ok) return error(display.error);
404
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
405
+ display: display.mode,
406
+ });
372
407
  const result = await adapter.record({
373
408
  cartPath: harnessCart,
374
409
  wavDir,
@@ -377,18 +412,12 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
377
412
  backstopMs: options.backstopMs,
378
413
  });
379
414
 
380
- if (!result.ok) {
381
- return error({
382
- code: 'pico8-not-found',
383
- message: pico8NotFoundMessage(result),
384
- exitCode: 1,
385
- });
386
- }
415
+ if (!result.ok) return error(recordFailure('audio render', result));
387
416
 
388
417
  const out = recordOutput(result.value, harness);
389
418
  return ok(
390
419
  out,
391
- out.captured ? undefined : {cta: noAudioCta(HONEST_CAVEAT)},
420
+ out.captured ? undefined : {cta: noAudioCta(out.noAudioReason)},
392
421
  );
393
422
  },
394
423
  });
@@ -0,0 +1,149 @@
1
+ import {Cli} from 'incur';
2
+ import {describe, expect, it} from 'vitest';
3
+ import type {Pico8Adapter, RunOptions} from '../engine/pico8/index.js';
4
+ import {pico8NotFound} from '../engine/pico8/index.js';
5
+ import {shrinkoNotFound} from '../engine/shrinko/index.js';
6
+ import type {ShrinkoAdapter} from '../engine/shrinko/index.js';
7
+ import {type DoctorDeps, registerDoctor} from './doctor.js';
8
+
9
+ /**
10
+ * `doctor` reports what this machine can do, from injected probes: a fake
11
+ * PICO-8 adapter (prints a version id, or is absent), a fake shrinko, a fake
12
+ * executable lookup and a controlled parent env (DISPLAY or not).
13
+ */
14
+
15
+ function pico8Present(versionId = 43): Pico8Adapter {
16
+ return {
17
+ async run(_o: RunOptions) {
18
+ return {
19
+ ok: true,
20
+ value: {
21
+ screenshots: [],
22
+ printh: `__PICOPILOT_PICO8_VERSION__${versionId}\n`,
23
+ exitReason: 'sentinel',
24
+ },
25
+ };
26
+ },
27
+ async record() {
28
+ throw new Error('unused');
29
+ },
30
+ async drive() {
31
+ throw new Error('unused');
32
+ },
33
+ async export() {
34
+ throw new Error('unused');
35
+ },
36
+ };
37
+ }
38
+
39
+ const pico8Absent: Pico8Adapter = {
40
+ ...pico8Present(),
41
+ async run() {
42
+ return pico8NotFound();
43
+ },
44
+ };
45
+
46
+ function shrinko(present: boolean): ShrinkoAdapter {
47
+ return {
48
+ async count() {
49
+ return present
50
+ ? {ok: true, value: {tokens: 1, chars: 1, compressed: 1}}
51
+ : shrinkoNotFound();
52
+ },
53
+ } as unknown as ShrinkoAdapter;
54
+ }
55
+
56
+ async function doctor(deps: DoctorDeps, argv: string[] = []) {
57
+ const cli = Cli.create('picopilot', {version: '0.0.0'});
58
+ registerDoctor(cli, deps);
59
+ let stdout = '';
60
+ let exitCode = 0;
61
+ await cli.serve(['doctor', ...argv, '--json'], {
62
+ stdout(s) {
63
+ stdout += s;
64
+ },
65
+ exit(code) {
66
+ exitCode = code;
67
+ },
68
+ env: {PICO8_PATH: '/opt/pico-8-0.2.7/pico8'},
69
+ });
70
+ return {out: JSON.parse(stdout), exitCode};
71
+ }
72
+
73
+ const finds =
74
+ (...names: string[]) =>
75
+ (n: string) =>
76
+ names.some((x) => n === x || n.endsWith(`/${x}`)) ? n : undefined;
77
+
78
+ describe('picopilot doctor', () => {
79
+ it('headless server with Xvfb: everything is available, -run sessions go virtual', async () => {
80
+ const {out, exitCode} = await doctor({
81
+ pico8: () => pico8Present(43),
82
+ shrinko: () => shrinko(true),
83
+ parentEnv: {},
84
+ find: finds('pico8', 'Xvfb'),
85
+ });
86
+ expect(exitCode).toBe(0);
87
+ expect(out.pico8).toMatchObject({
88
+ found: true,
89
+ runs: true,
90
+ versionId: 43,
91
+ version: '0.2.7',
92
+ });
93
+ expect(out.shrinko8.found).toBe(true);
94
+ expect(out.display).toEqual({
95
+ mode: 'auto',
96
+ native: false,
97
+ virtual: 'Xvfb',
98
+ runSessions: 'xvfb',
99
+ });
100
+ expect(out.unavailable).toEqual([]);
101
+ expect(out.available).toContain('audio record');
102
+ });
103
+
104
+ it('no display and no Xvfb: only the -run commands are unavailable, missing "display"', async () => {
105
+ const {out} = await doctor({
106
+ pico8: () => pico8Present(),
107
+ shrinko: () => shrinko(true),
108
+ parentEnv: {},
109
+ find: finds('pico8'),
110
+ });
111
+ expect(out.display.runSessions).toBe('none');
112
+ expect(
113
+ out.unavailable.map((u: {command: string}) => u.command).sort(),
114
+ ).toEqual(['audio record', 'audio render', 'run --record-audio'].sort());
115
+ expect(out.unavailable[0].missing).toEqual(['display']);
116
+ expect(out.available).toContain('run');
117
+ expect(out.available).toContain('playtest');
118
+ expect(out.available).toContain('export');
119
+ });
120
+
121
+ it('a desktop (DISPLAY set): -run sessions are native', async () => {
122
+ const {out} = await doctor({
123
+ pico8: () => pico8Present(),
124
+ shrinko: () => shrinko(true),
125
+ parentEnv: {DISPLAY: ':0'},
126
+ find: finds('pico8'),
127
+ });
128
+ expect(out.display.native).toBe(true);
129
+ expect(out.display.runSessions).toBe('native');
130
+ });
131
+
132
+ it('no PICO-8 and no shrinko8: reports both as missing, still exits 0 (the report is the result)', async () => {
133
+ const {out, exitCode} = await doctor({
134
+ pico8: () => pico8Absent,
135
+ shrinko: () => shrinko(false),
136
+ parentEnv: {},
137
+ find: () => undefined,
138
+ });
139
+ expect(exitCode).toBe(0);
140
+ expect(out.pico8).toEqual({found: false, runs: false});
141
+ expect(out.shrinko8.found).toBe(false);
142
+ expect(out.available).toEqual([
143
+ 'init',
144
+ 'gfx show / set / render',
145
+ 'sfx',
146
+ 'music',
147
+ ]);
148
+ });
149
+ });