picopilot 0.1.4 → 0.2.0

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 (172) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +111 -16
  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 +61 -44
  7. package/dist/commands/audio.js.map +1 -1
  8. package/dist/commands/cart-includes.d.ts +11 -0
  9. package/dist/commands/cart-includes.d.ts.map +1 -0
  10. package/dist/commands/cart-includes.js +26 -0
  11. package/dist/commands/cart-includes.js.map +1 -0
  12. package/dist/commands/doctor.d.ts +22 -0
  13. package/dist/commands/doctor.d.ts.map +1 -0
  14. package/dist/commands/doctor.js +222 -0
  15. package/dist/commands/doctor.js.map +1 -0
  16. package/dist/commands/export.d.ts +3 -4
  17. package/dist/commands/export.d.ts.map +1 -1
  18. package/dist/commands/export.js +66 -86
  19. package/dist/commands/export.js.map +1 -1
  20. package/dist/commands/gfx.d.ts +2 -2
  21. package/dist/commands/gfx.d.ts.map +1 -1
  22. package/dist/commands/gfx.js +3 -2
  23. package/dist/commands/gfx.js.map +1 -1
  24. package/dist/commands/label-cart.d.ts +72 -0
  25. package/dist/commands/label-cart.d.ts.map +1 -0
  26. package/dist/commands/label-cart.js +228 -0
  27. package/dist/commands/label-cart.js.map +1 -0
  28. package/dist/commands/lint.d.ts +2 -2
  29. package/dist/commands/lint.d.ts.map +1 -1
  30. package/dist/commands/lint.js +3 -2
  31. package/dist/commands/lint.js.map +1 -1
  32. package/dist/commands/minify.d.ts +2 -2
  33. package/dist/commands/minify.d.ts.map +1 -1
  34. package/dist/commands/minify.js +3 -2
  35. package/dist/commands/minify.js.map +1 -1
  36. package/dist/commands/pico8-env.d.ts +76 -0
  37. package/dist/commands/pico8-env.d.ts.map +1 -0
  38. package/dist/commands/pico8-env.js +71 -0
  39. package/dist/commands/pico8-env.js.map +1 -0
  40. package/dist/commands/playtest.d.ts +2 -2
  41. package/dist/commands/playtest.d.ts.map +1 -1
  42. package/dist/commands/playtest.js +6 -32
  43. package/dist/commands/playtest.js.map +1 -1
  44. package/dist/commands/run.d.ts +3 -4
  45. package/dist/commands/run.d.ts.map +1 -1
  46. package/dist/commands/run.js +46 -19
  47. package/dist/commands/run.js.map +1 -1
  48. package/dist/commands/serve.d.ts +2 -2
  49. package/dist/commands/serve.d.ts.map +1 -1
  50. package/dist/commands/serve.js +65 -19
  51. package/dist/commands/serve.js.map +1 -1
  52. package/dist/commands/shrinko-env.d.ts +22 -0
  53. package/dist/commands/shrinko-env.d.ts.map +1 -0
  54. package/dist/commands/shrinko-env.js +15 -0
  55. package/dist/commands/shrinko-env.js.map +1 -0
  56. package/dist/commands/tokens.d.ts +4 -4
  57. package/dist/commands/tokens.d.ts.map +1 -1
  58. package/dist/commands/tokens.js +3 -2
  59. package/dist/commands/tokens.js.map +1 -1
  60. package/dist/commands/verify.d.ts +2 -2
  61. package/dist/commands/verify.d.ts.map +1 -1
  62. package/dist/commands/verify.js +3 -2
  63. package/dist/commands/verify.js.map +1 -1
  64. package/dist/engine/child-env.d.ts +14 -0
  65. package/dist/engine/child-env.d.ts.map +1 -0
  66. package/dist/engine/child-env.js +21 -0
  67. package/dist/engine/child-env.js.map +1 -0
  68. package/dist/engine/gfx/label.d.ts +15 -9
  69. package/dist/engine/gfx/label.d.ts.map +1 -1
  70. package/dist/engine/gfx/label.js +130 -38
  71. package/dist/engine/gfx/label.js.map +1 -1
  72. package/dist/engine/pico8/adapter.d.ts +23 -3
  73. package/dist/engine/pico8/adapter.d.ts.map +1 -1
  74. package/dist/engine/pico8/adapter.js.map +1 -1
  75. package/dist/engine/pico8/cleanup.d.ts +16 -0
  76. package/dist/engine/pico8/cleanup.d.ts.map +1 -0
  77. package/dist/engine/pico8/cleanup.js +74 -0
  78. package/dist/engine/pico8/cleanup.js.map +1 -0
  79. package/dist/engine/pico8/display.d.ts +149 -0
  80. package/dist/engine/pico8/display.d.ts.map +1 -0
  81. package/dist/engine/pico8/display.js +311 -0
  82. package/dist/engine/pico8/display.js.map +1 -0
  83. package/dist/engine/pico8/drive.d.ts.map +1 -1
  84. package/dist/engine/pico8/drive.js +28 -2
  85. package/dist/engine/pico8/drive.js.map +1 -1
  86. package/dist/engine/pico8/harness.d.ts +8 -0
  87. package/dist/engine/pico8/harness.d.ts.map +1 -1
  88. package/dist/engine/pico8/harness.js +9 -1
  89. package/dist/engine/pico8/harness.js.map +1 -1
  90. package/dist/engine/pico8/index.d.ts +3 -0
  91. package/dist/engine/pico8/index.d.ts.map +1 -1
  92. package/dist/engine/pico8/index.js +3 -0
  93. package/dist/engine/pico8/index.js.map +1 -1
  94. package/dist/engine/pico8/session-daemon-entry.d.ts +1 -1
  95. package/dist/engine/pico8/session-daemon-entry.js +1 -1
  96. package/dist/engine/pico8/session-daemon-main.d.ts +1 -1
  97. package/dist/engine/pico8/session-daemon-main.js +1 -1
  98. package/dist/engine/pico8/session-daemon.d.ts +1 -1
  99. package/dist/engine/pico8/session-daemon.d.ts.map +1 -1
  100. package/dist/engine/pico8/session-daemon.js +19 -19
  101. package/dist/engine/pico8/session-daemon.js.map +1 -1
  102. package/dist/engine/pico8/session.d.ts +1 -1
  103. package/dist/engine/pico8/session.js +1 -1
  104. package/dist/engine/pico8/shell.d.ts +56 -3
  105. package/dist/engine/pico8/shell.d.ts.map +1 -1
  106. package/dist/engine/pico8/shell.js +188 -24
  107. package/dist/engine/pico8/shell.js.map +1 -1
  108. package/dist/engine/pico8/supervisor.d.ts +1 -1
  109. package/dist/engine/pico8/supervisor.js +1 -1
  110. package/dist/engine/shrinko/adapter.d.ts +1 -1
  111. package/dist/engine/shrinko/shell.d.ts +3 -3
  112. package/dist/engine/shrinko/shell.js +3 -3
  113. package/package.json +2 -1
  114. package/src/cli.test.ts +188 -0
  115. package/src/cli.ts +122 -16
  116. package/src/commands/audio.test.ts +76 -1
  117. package/src/commands/audio.ts +79 -57
  118. package/src/commands/cart-includes.ts +27 -0
  119. package/src/commands/doctor.test.ts +149 -0
  120. package/src/commands/doctor.ts +280 -0
  121. package/src/commands/export.test.ts +327 -3
  122. package/src/commands/export.ts +81 -99
  123. package/src/commands/gfx.ts +7 -6
  124. package/src/commands/label-cart.ts +304 -0
  125. package/src/commands/lint.test.ts +6 -0
  126. package/src/commands/lint.ts +7 -6
  127. package/src/commands/minify.test.ts +6 -0
  128. package/src/commands/minify.ts +7 -6
  129. package/src/commands/pico8-env.ts +124 -0
  130. package/src/commands/playtest.test.ts +3 -0
  131. package/src/commands/playtest.ts +10 -34
  132. package/src/commands/run.test.ts +116 -2
  133. package/src/commands/run.ts +62 -24
  134. package/src/commands/serve.test.ts +138 -2
  135. package/src/commands/serve.ts +80 -21
  136. package/src/commands/shrinko-env.test.ts +68 -0
  137. package/src/commands/shrinko-env.ts +40 -0
  138. package/src/commands/tokens.test.ts +19 -0
  139. package/src/commands/tokens.ts +9 -6
  140. package/src/commands/verify.test.ts +12 -0
  141. package/src/commands/verify.ts +7 -4
  142. package/src/engine/child-env.ts +22 -0
  143. package/src/engine/gfx/fixtures/pico8-screenshot-384.png +0 -0
  144. package/src/engine/gfx/label.test.ts +467 -1
  145. package/src/engine/gfx/label.ts +147 -41
  146. package/src/engine/pico8/adapter.ts +25 -3
  147. package/src/engine/pico8/cleanup.test.ts +28 -0
  148. package/src/engine/pico8/cleanup.ts +76 -0
  149. package/src/engine/pico8/display.test.ts +210 -0
  150. package/src/engine/pico8/display.ts +429 -0
  151. package/src/engine/pico8/drive.test.ts +20 -0
  152. package/src/engine/pico8/drive.ts +25 -2
  153. package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
  154. package/src/engine/pico8/harness.test.ts +26 -0
  155. package/src/engine/pico8/harness.ts +18 -2
  156. package/src/engine/pico8/index.ts +3 -0
  157. package/src/engine/pico8/session-daemon-entry.ts +1 -1
  158. package/src/engine/pico8/session-daemon-main.ts +1 -1
  159. package/src/engine/pico8/session-daemon.test.ts +32 -1
  160. package/src/engine/pico8/session-daemon.ts +25 -17
  161. package/src/engine/pico8/session.test.ts +1 -1
  162. package/src/engine/pico8/session.ts +1 -1
  163. package/src/engine/pico8/shell.test.ts +449 -10
  164. package/src/engine/pico8/shell.ts +255 -26
  165. package/src/engine/pico8/supervisor.ts +1 -1
  166. package/src/engine/pico8/virtual-display.process.test.ts +206 -0
  167. package/src/engine/shrinko/adapter.ts +1 -1
  168. package/src/engine/shrinko/shell.ts +3 -3
  169. package/src/engine/skills/skills.test.ts +10 -0
  170. package/src/skills/picopilot-audio/SKILL.md +4 -8
  171. package/src/skills/picopilot-debug/SKILL.md +18 -1
  172. package/src/skills/picopilot-overview/SKILL.md +9 -1
@@ -1,4 +1,10 @@
1
- import {existsSync, mkdtempSync, readFileSync, rmSync} from 'node:fs';
1
+ import {
2
+ existsSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
2
8
  import {tmpdir} from 'node:os';
3
9
  import {join} from 'node:path';
4
10
  import {afterEach, beforeEach, describe, expect, it} from 'vitest';
@@ -90,6 +96,31 @@ async function waitSocket(): Promise<void> {
90
96
  }
91
97
 
92
98
  describe('session daemon: verb round-trip over the socket (fake process)', () => {
99
+ it('shot waits for the PNG PICO-8 writes AFTER the ACK, and never returns an older shot', async () => {
100
+ // Real PICO-8 acks the SHOT (printh) before extcmd("screen") lands on disk.
101
+ const proc = new FakeDriven();
102
+ let n = 0;
103
+ const realWrite = proc.writeStdin.bind(proc);
104
+ proc.writeStdin = (bytes: Uint8Array) => {
105
+ realWrite(bytes);
106
+ if (bytes[0] === OPCODE.shot) {
107
+ const file = join(paths.shotDir, `play${n++}.png`);
108
+ setTimeout(() => writeFileSync(file, 'png'), 150);
109
+ }
110
+ };
111
+ startFakeDaemon(proc);
112
+ await waitSocket();
113
+ const client = new SessionClient(paths.socket);
114
+ const first = await client.send({verb: 'shot'});
115
+ expect(first.ok && first.value.screenshot).toBe(
116
+ join(paths.shotDir, 'play0.png'),
117
+ );
118
+ const second = await client.send({verb: 'shot'});
119
+ expect(second.ok && second.value.screenshot).toBe(
120
+ join(paths.shotDir, 'play1.png'),
121
+ );
122
+ });
123
+
93
124
  it('step advances frames and returns the structured result', async () => {
94
125
  startFakeDaemon(new FakeDriven());
95
126
  await waitSocket();
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The playtest session DAEMON runtime + CLIENT (ADR-0011, prd US #6). This is the
2
+ * The playtest session DAEMON runtime + CLIENT (ADR-0011, spec US #6). This is the
3
3
  * IO half of the supervisor: the daemon LISTENS on a session's Unix domain socket
4
4
  * and drives a live {@link DriveSession} on behalf of each verb sub-invocation;
5
5
  * the {@link SessionClient} CONNECTS to that socket to send a verb and read back
@@ -14,7 +14,7 @@
14
14
 
15
15
  import {spawn} from 'node:child_process';
16
16
  import {createConnection, createServer, type Socket} from 'node:net';
17
- import {existsSync, readdirSync, rmSync, writeFileSync} from 'node:fs';
17
+ import {existsSync, rmSync, writeFileSync} from 'node:fs';
18
18
  import {dirname, join} from 'node:path';
19
19
  import {fileURLToPath} from 'node:url';
20
20
  import {
@@ -206,7 +206,10 @@ export function serveSession(options: ServeSessionOptions): {
206
206
  }
207
207
  case 'shot': {
208
208
  const r: ShotResult = await session.shot();
209
- const screenshot = collectShot(options.paths.shotDir, r.shotName);
209
+ const screenshot = await collectShot(
210
+ options.paths.shotDir,
211
+ r.shotName,
212
+ );
210
213
  return ok(options.id, {
211
214
  frame: r.frame,
212
215
  ack: r.ack,
@@ -285,22 +288,27 @@ function ok(
285
288
  return {ok: true, value: {id, ...partial}};
286
289
  }
287
290
 
291
+ /** How long `shot` waits for PICO-8 to write the PNG after acking the SHOT. */
292
+ const SHOT_FILE_TIMEOUT_MS = 3000;
293
+ const SHOT_FILE_POLL_MS = 20;
294
+
288
295
  /**
289
- * Resolves the absolute path of the screenshot a SHOT just produced in the
290
- * `-desktop` dir. The cart named it `<shotName>` (basename) and PICO-8 appends
291
- * `.png`; if the exact file is not yet visible (a filesystem race) it falls back
292
- * to the most-recent PNG, so the caller still gets a path to read.
296
+ * Resolves the screenshot the cart just took: `<shotDir>/<shotName>.png`. The
297
+ * cart's SHOT ACK is printed BEFORE `extcmd("screen")` lands on disk (verified
298
+ * on PICO-8 0.2.7), so poll briefly for that exact file. It deliberately never
299
+ * falls back to "the most recent PNG": that returned the PREVIOUS shot (or
300
+ * nothing) whenever the new file was still being written. `undefined` only if
301
+ * the file never appears within {@link SHOT_FILE_TIMEOUT_MS}.
293
302
  */
294
- function collectShot(shotDir: string, shotName: string): string | undefined {
303
+ async function collectShot(
304
+ shotDir: string,
305
+ shotName: string,
306
+ ): Promise<string | undefined> {
295
307
  const exact = join(shotDir, `${shotName}.png`);
296
- if (existsSync(exact)) return exact;
297
- try {
298
- const pngs = readdirSync(shotDir)
299
- .filter((f) => f.toLowerCase().endsWith('.png'))
300
- .sort();
301
- const last = pngs.at(-1);
302
- return last !== undefined ? join(shotDir, last) : undefined;
303
- } catch {
304
- return undefined;
308
+ const deadline = Date.now() + SHOT_FILE_TIMEOUT_MS;
309
+ while (!existsSync(exact)) {
310
+ if (Date.now() >= deadline) return undefined;
311
+ await new Promise((r) => setTimeout(r, SHOT_FILE_POLL_MS));
305
312
  }
313
+ return exact;
306
314
  }
@@ -5,7 +5,7 @@ import type {Pico8Process} from './shell.js';
5
5
 
6
6
  /**
7
7
  * The RESUMABLE session HANDSHAKE core is tested WITHOUT the paid PICO-8 binary
8
- * (ADR-0011, prd US #6): a FAKE driven process lets us assert the load-bearing
8
+ * (ADR-0011, spec US #6): a FAKE driven process lets us assert the load-bearing
9
9
  * transport contract, each verb WRITES its fixed-size command block and WAITS for
10
10
  * the cart's ACK before returning (send -> wait-ack -> return), and the failure
11
11
  * paths (a lost/late ack, a process that dies mid-command) surface as STRUCTURED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The RESUMABLE playtest SESSION handshake core (ADR-0011, prd US #6): the pure,
2
+ * The RESUMABLE playtest SESSION handshake core (ADR-0011, spec US #6): the pure,
3
3
  * CI-testable half of the "A" live model. It keeps a single driven PICO-8 process
4
4
  * ALIVE and PAUSED between the agent's turns and drives it one command at a time
5
5
  * over the verified transport: write a FIXED-SIZE command block, WAIT for the
@@ -3,8 +3,10 @@ import {tmpdir} from 'node:os';
3
3
  import {join} from 'node:path';
4
4
  import {afterEach, beforeEach, describe, expect, it} from 'vitest';
5
5
  import {DONE_SENTINEL, RECORD_WAV_BASENAME} from './adapter.js';
6
+ import {FakeXvfb} from './fake-xvfb.test-support.js';
6
7
  import {
7
8
  type Pico8Process,
9
+ pico8ChildEnv,
8
10
  pico8HomeDir,
9
11
  ShellPico8Adapter,
10
12
  type SpawnRunner,
@@ -42,8 +44,11 @@ class FakeProcess implements Pico8Process {
42
44
  writeStdin(bytes: Uint8Array): void {
43
45
  for (const b of bytes) this.stdinBytes.push(b);
44
46
  }
45
- kill(): void {
47
+ /** The signals `kill` received (undefined = the default SIGKILL). */
48
+ signals: (string | undefined)[] = [];
49
+ kill(signal?: NodeJS.Signals): void {
46
50
  this.killed = true;
51
+ this.signals.push(signal);
47
52
  }
48
53
 
49
54
  // Test drivers:
@@ -144,7 +149,7 @@ describe('ShellPico8Adapter: sentinel-driven termination', () => {
144
149
  proc.emit(`${DONE_SENTINEL}\n`);
145
150
  await resultP;
146
151
  expect(spawnArgs).toContain('-home');
147
- const home = spawnArgs[spawnArgs.indexOf('-home') + 1];
152
+ const home = spawnArgs[spawnArgs.indexOf('-home') + 1]!;
148
153
  // Points at an isolated temp dir, NOT the process CWD.
149
154
  expect(home.startsWith(tmpdir())).toBe(true);
150
155
  expect(home).not.toBe(process.cwd());
@@ -260,7 +265,11 @@ function writeNonEmptyWav(path: string): void {
260
265
  describe('ShellPico8Adapter.record: PICO-8 absent (the CI-testable boundary)', () => {
261
266
  it('returns structured pico8-not-found when the binary ENOENTs', async () => {
262
267
  const proc = new FakeProcess();
263
- const adapter = new ShellPico8Adapter({env: {}, spawn: () => proc});
268
+ const adapter = new ShellPico8Adapter({
269
+ env: {},
270
+ spawn: () => proc,
271
+ display: 'native',
272
+ });
264
273
  const resultP = adapter.record({
265
274
  cartPath: '/x/main.p8',
266
275
  wavDir: '/x/wav',
@@ -269,8 +278,8 @@ describe('ShellPico8Adapter.record: PICO-8 absent (the CI-testable boundary)', (
269
278
  proc.fail('ENOENT');
270
279
  const result = await resultP;
271
280
  expect(result.ok).toBe(false);
272
- if (!result.ok) {
273
- expect(result.reason).toBe('pico8-not-found');
281
+ expect(!result.ok && result.reason).toBe('pico8-not-found');
282
+ if (!result.ok && result.reason === 'pico8-not-found') {
274
283
  expect(result.remedy).toBe('set PICO8_PATH or install PICO-8');
275
284
  expect(result.needs).toContain('pico8');
276
285
  }
@@ -291,7 +300,7 @@ describe('ShellPico8Adapter.record: real A/V session + WAV isolation', () => {
291
300
  spawnArgs = args;
292
301
  return proc;
293
302
  };
294
- const adapter = new ShellPico8Adapter({env: {}, spawn});
303
+ const adapter = new ShellPico8Adapter({env: {}, spawn, display: 'native'});
295
304
  const resultP = adapter.record({
296
305
  cartPath: '/x/harness.p8',
297
306
  wavDir,
@@ -312,7 +321,11 @@ describe('ShellPico8Adapter.record: real A/V session + WAV isolation', () => {
312
321
  it('collects the <basename>.wav the cooperating cart wrote (sentinel-ended)', async () => {
313
322
  const proc = new FakeProcess();
314
323
  writeNonEmptyWav(join(wavDir, `${RECORD_WAV_BASENAME}.wav`));
315
- const adapter = new ShellPico8Adapter({env: {}, spawn: () => proc});
324
+ const adapter = new ShellPico8Adapter({
325
+ env: {},
326
+ spawn: () => proc,
327
+ display: 'native',
328
+ });
316
329
  const resultP = adapter.record({
317
330
  cartPath: '/x/harness.p8',
318
331
  wavDir,
@@ -334,7 +347,11 @@ describe('ShellPico8Adapter.record: real A/V session + WAV isolation', () => {
334
347
  const proc = new FakeProcess();
335
348
  // A well-formed but EMPTY RIFF header (what headless -x produces).
336
349
  writeFileSync(join(wavDir, `${RECORD_WAV_BASENAME}.wav`), Buffer.alloc(44));
337
- const adapter = new ShellPico8Adapter({env: {}, spawn: () => proc});
350
+ const adapter = new ShellPico8Adapter({
351
+ env: {},
352
+ spawn: () => proc,
353
+ display: 'native',
354
+ });
338
355
  const resultP = adapter.record({
339
356
  cartPath: '/x/harness.p8',
340
357
  wavDir,
@@ -347,7 +364,11 @@ describe('ShellPico8Adapter.record: real A/V session + WAV isolation', () => {
347
364
 
348
365
  it('reports no WAV when none was produced', async () => {
349
366
  const proc = new FakeProcess();
350
- const adapter = new ShellPico8Adapter({env: {}, spawn: () => proc});
367
+ const adapter = new ShellPico8Adapter({
368
+ env: {},
369
+ spawn: () => proc,
370
+ display: 'native',
371
+ });
351
372
  const resultP = adapter.record({
352
373
  cartPath: '/x/harness.p8',
353
374
  wavDir,
@@ -360,7 +381,11 @@ describe('ShellPico8Adapter.record: real A/V session + WAV isolation', () => {
360
381
 
361
382
  it('fires the record backstop when the cart never signals (never a hang)', async () => {
362
383
  const proc = new FakeProcess();
363
- const adapter = new ShellPico8Adapter({env: {}, spawn: () => proc});
384
+ const adapter = new ShellPico8Adapter({
385
+ env: {},
386
+ spawn: () => proc,
387
+ display: 'native',
388
+ });
364
389
  const resultP = adapter.record({
365
390
  cartPath: '/x/harness.p8',
366
391
  wavDir,
@@ -581,3 +606,417 @@ describe('ShellPico8Adapter.export: the headless -export -x invocation', () => {
581
606
  }
582
607
  });
583
608
  });
609
+
610
+ /**
611
+ * The virtual-display record path (ADR-0017), with a fake PICO-8 AND a fake
612
+ * Xvfb: the display must come up before PICO-8 spawns, PICO-8 must see it, and
613
+ * it must ALWAYS be torn down, however the run ends.
614
+ */
615
+ describe('ShellPico8Adapter.record: virtual display (fake Xvfb)', () => {
616
+ let wavDir: string;
617
+ beforeEach(() => {
618
+ wavDir = mkdtempSync(join(tmpdir(), 'pico8-wav-'));
619
+ });
620
+ afterEach(() => rmSync(wavDir, {recursive: true, force: true}));
621
+
622
+ /** Everything installed: pico8 and Xvfb resolve, Xvfb reports display :37. */
623
+ function rig(
624
+ opts: {
625
+ env?: NodeJS.ProcessEnv;
626
+ display?: 'auto' | 'xvfb' | 'native';
627
+ installed?: string[];
628
+ killGraceMs?: number;
629
+ } = {},
630
+ ) {
631
+ const proc = new FakeProcess();
632
+ const xvfb = new FakeXvfb();
633
+ const installed = opts.installed ?? ['pico8', 'Xvfb'];
634
+ let xvfbSpawns = 0;
635
+ let spawnedEnv: NodeJS.ProcessEnv | undefined;
636
+ let spawnedFile: string | undefined;
637
+ let resolveSpawned: () => void = () => {};
638
+ const spawned = new Promise<void>((r) => {
639
+ resolveSpawned = r;
640
+ });
641
+ const adapter = new ShellPico8Adapter({
642
+ env: opts.env ?? {},
643
+ display: opts.display ?? 'auto',
644
+ killGraceMs: opts.killGraceMs,
645
+ spawn: (file, _args, env) => {
646
+ spawnedFile = file;
647
+ spawnedEnv = env;
648
+ setImmediate(resolveSpawned);
649
+ return proc;
650
+ },
651
+ virtualDisplay: {
652
+ find: (n) => (installed.includes(n) ? `/fake/${n}` : undefined),
653
+ spawnXvfb: () => {
654
+ xvfbSpawns++;
655
+ setImmediate(() => xvfb.reportDisplay(37));
656
+ return xvfb;
657
+ },
658
+ },
659
+ });
660
+ return {
661
+ proc,
662
+ xvfb,
663
+ adapter,
664
+ spawned,
665
+ get xvfbSpawns() {
666
+ return xvfbSpawns;
667
+ },
668
+ get env() {
669
+ return spawnedEnv;
670
+ },
671
+ get file() {
672
+ return spawnedFile;
673
+ },
674
+ };
675
+ }
676
+
677
+ it('runs PICO-8 on the virtual display (DISPLAY, dummy SDL audio, a runtime dir) and stops Xvfb on the sentinel', async () => {
678
+ const r = rig();
679
+ const resultP = r.adapter.record({
680
+ cartPath: '/x/h.p8',
681
+ wavDir,
682
+ backstopMs: 9999,
683
+ });
684
+ await r.spawned;
685
+ expect(r.env?.DISPLAY).toBe(':37');
686
+ expect(r.env?.SDL_AUDIODRIVER).toBe('dummy');
687
+ expect(r.env?.XDG_RUNTIME_DIR).toBeTruthy();
688
+ expect(r.xvfb.exited).toBe(false); // alive while PICO-8 runs
689
+ r.proc.emit(`${DONE_SENTINEL}\n`);
690
+ const result = await resultP;
691
+ expect(result.ok).toBe(true);
692
+ if (result.ok) {
693
+ expect(result.value.exitReason).toBe('sentinel');
694
+ expect(result.value.display).toBe('Xvfb');
695
+ }
696
+ expect(r.proc.killed).toBe(true);
697
+ expect(r.xvfb.exited).toBe(true);
698
+ });
699
+
700
+ it('keeps a caller-chosen SDL_AUDIODRIVER', async () => {
701
+ const r = rig({env: {SDL_AUDIODRIVER: 'pulseaudio'}});
702
+ const resultP = r.adapter.record({
703
+ cartPath: '/x/h.p8',
704
+ wavDir,
705
+ backstopMs: 9999,
706
+ });
707
+ await r.spawned;
708
+ expect(r.env?.SDL_AUDIODRIVER).toBe('pulseaudio');
709
+ r.proc.emit(`${DONE_SENTINEL}\n`);
710
+ await resultP;
711
+ });
712
+
713
+ it('stops Xvfb when the backstop fires', async () => {
714
+ const r = rig();
715
+ const result = await r.adapter.record({
716
+ cartPath: '/x/h.p8',
717
+ wavDir,
718
+ backstopMs: 20,
719
+ });
720
+ expect(result.ok).toBe(true);
721
+ if (result.ok) expect(result.value.exitReason).toBe('timeout');
722
+ expect(r.proc.killed).toBe(true);
723
+ expect(r.xvfb.exited).toBe(true);
724
+ });
725
+
726
+ it('stops Xvfb when PICO-8 crashes (exits on its own)', async () => {
727
+ const r = rig();
728
+ const resultP = r.adapter.record({
729
+ cartPath: '/x/h.p8',
730
+ wavDir,
731
+ backstopMs: 9999,
732
+ });
733
+ await r.spawned;
734
+ r.proc.close(139);
735
+ const result = await resultP;
736
+ expect(result.ok).toBe(true);
737
+ if (result.ok) expect(result.value.exitReason).toBe('exit');
738
+ expect(r.xvfb.exited).toBe(true);
739
+ });
740
+
741
+ it('stops Xvfb when PICO-8 fails to spawn', async () => {
742
+ const r = rig();
743
+ const resultP = r.adapter.record({
744
+ cartPath: '/x/h.p8',
745
+ wavDir,
746
+ backstopMs: 9999,
747
+ });
748
+ await r.spawned;
749
+ r.proc.fail('ENOENT');
750
+ const result = await resultP;
751
+ expect(!result.ok && result.reason).toBe('pico8-not-found');
752
+ expect(r.xvfb.exited).toBe(true);
753
+ });
754
+
755
+ it('wraps PICO-8 in xvfb-run when only the script exists, and ends it with SIGTERM first', async () => {
756
+ const r = rig({installed: ['pico8', 'xvfb-run'], killGraceMs: 5_000});
757
+ const resultP = r.adapter.record({
758
+ cartPath: '/x/h.p8',
759
+ wavDir,
760
+ backstopMs: 9999,
761
+ });
762
+ await r.spawned;
763
+ expect(r.file).toBe('/fake/xvfb-run');
764
+ expect(r.env?.SDL_AUDIODRIVER).toBe('dummy');
765
+ r.proc.emit(`${DONE_SENTINEL}\n`);
766
+ // SIGTERM (so Xvfb removes its lock + socket), and the run waits for the
767
+ // group to close instead of SIGKILLing it.
768
+ expect(r.proc.signals).toEqual(['SIGTERM']);
769
+ r.proc.close(0);
770
+ const result = await resultP;
771
+ expect(result.ok).toBe(true);
772
+ if (result.ok) expect(result.value.display).toBe('xvfb-run');
773
+ expect(r.proc.signals).toEqual(['SIGTERM']);
774
+ expect(r.xvfbSpawns).toBe(0);
775
+ });
776
+
777
+ it('xvfb-run that ignores SIGTERM is SIGKILLed after the grace period', async () => {
778
+ const r = rig({installed: ['pico8', 'xvfb-run'], killGraceMs: 10});
779
+ const resultP = r.adapter.record({
780
+ cartPath: '/x/h.p8',
781
+ wavDir,
782
+ backstopMs: 9999,
783
+ });
784
+ await r.spawned;
785
+ r.proc.emit(`${DONE_SENTINEL}\n`);
786
+ const result = await resultP;
787
+ expect(result.ok).toBe(true);
788
+ expect(r.proc.signals).toEqual(['SIGTERM', undefined]);
789
+ });
790
+
791
+ it('returns display-unavailable (never a silent timeout) when there is no display and no Xvfb', async () => {
792
+ const r = rig({installed: ['pico8']});
793
+ const result = await r.adapter.record({
794
+ cartPath: '/x/h.p8',
795
+ wavDir,
796
+ backstopMs: 9999,
797
+ });
798
+ expect(result.ok).toBe(false);
799
+ if (!result.ok && result.reason === 'display-unavailable') {
800
+ expect(result.remedy).toMatch(/Xvfb/);
801
+ } else {
802
+ throw new Error(
803
+ `expected display-unavailable, got ${JSON.stringify(result)}`,
804
+ );
805
+ }
806
+ expect(r.env).toBeUndefined(); // PICO-8 never launched
807
+ });
808
+
809
+ it('reports pico8-not-found (not display-unavailable) when PICO-8 itself is missing', async () => {
810
+ const r = rig({installed: []});
811
+ const result = await r.adapter.record({
812
+ cartPath: '/x/h.p8',
813
+ wavDir,
814
+ backstopMs: 9999,
815
+ });
816
+ expect(!result.ok && result.reason).toBe('pico8-not-found');
817
+ expect(r.xvfbSpawns).toBe(0);
818
+ });
819
+
820
+ it('auto with a native display (X11 or Wayland) starts no Xvfb', async () => {
821
+ for (const env of [{DISPLAY: ':0'}, {WAYLAND_DISPLAY: 'wayland-0'}]) {
822
+ const r = rig({env});
823
+ const resultP = r.adapter.record({
824
+ cartPath: '/x/h.p8',
825
+ wavDir,
826
+ backstopMs: 9999,
827
+ });
828
+ await r.spawned;
829
+ r.proc.emit(`${DONE_SENTINEL}\n`);
830
+ const result = await resultP;
831
+ expect(result.ok).toBe(true);
832
+ if (result.ok) expect(result.value.display).toBe('native');
833
+ expect(r.xvfbSpawns).toBe(0);
834
+ expect(r.env?.SDL_AUDIODRIVER).toBeUndefined();
835
+ }
836
+ });
837
+
838
+ it('xvfb mode uses a virtual display even when a native one exists', async () => {
839
+ const r = rig({env: {DISPLAY: ':0'}, display: 'xvfb'});
840
+ const resultP = r.adapter.record({
841
+ cartPath: '/x/h.p8',
842
+ wavDir,
843
+ backstopMs: 9999,
844
+ });
845
+ await r.spawned;
846
+ expect(r.env?.DISPLAY).toBe(':37');
847
+ r.proc.emit(`${DONE_SENTINEL}\n`);
848
+ await resultP;
849
+ expect(r.xvfbSpawns).toBe(1);
850
+ expect(r.xvfb.exited).toBe(true);
851
+ });
852
+
853
+ it('the -x paths (run, drive, export) never start Xvfb, even in xvfb mode', async () => {
854
+ const r = rig({display: 'xvfb'});
855
+ const runP = r.adapter.run({
856
+ cartPath: '/x/c.p8',
857
+ shotDir: wavDir,
858
+ backstopMs: 9999,
859
+ });
860
+ r.proc.emit(`${DONE_SENTINEL}\n`);
861
+ await runP;
862
+ const driveP = r.adapter.drive({
863
+ cartPath: '/x/c.p8',
864
+ shotDir: wavDir,
865
+ blocks: new Uint8Array(),
866
+ backstopMs: 9999,
867
+ });
868
+ r.proc.emit(`${DONE_SENTINEL}\n`);
869
+ await driveP;
870
+ const exportP = r.adapter.export({
871
+ cartPath: '/x/c.p8',
872
+ outDir: wavDir,
873
+ backstopMs: 9999,
874
+ });
875
+ r.proc.close(0);
876
+ await exportP;
877
+ expect(r.xvfbSpawns).toBe(0);
878
+ });
879
+ });
880
+
881
+ describe('ShellPico8Adapter.record: says WHY there is no WAV', () => {
882
+ let wavDir: string;
883
+ beforeEach(() => {
884
+ wavDir = mkdtempSync(join(tmpdir(), 'pico8-wav-'));
885
+ });
886
+ afterEach(() => rmSync(wavDir, {recursive: true, force: true}));
887
+
888
+ it('backstop: names the missed sentinel and includes the tail of PICO-8 log.txt', async () => {
889
+ // A previous run's log must not leak into this report...
890
+ writeFileSync(join(pico8HomeDir(), 'log.txt'), 'STALE previous run\n');
891
+ const proc = new FakeProcess();
892
+ const adapter = new ShellPico8Adapter({
893
+ env: {},
894
+ spawn: () => {
895
+ // ...only what THIS run's PICO-8 writes.
896
+ writeFileSync(
897
+ join(pico8HomeDir(), 'log.txt'),
898
+ 'codo_init\nsyntax error in main.lua\n',
899
+ );
900
+ return proc;
901
+ },
902
+ display: 'native',
903
+ });
904
+ const result = await adapter.record({
905
+ cartPath: '/x/h.p8',
906
+ wavDir,
907
+ backstopMs: 20,
908
+ });
909
+ expect(result.ok).toBe(true);
910
+ if (!result.ok) return;
911
+ expect(result.value.wavPath).toBeUndefined();
912
+ expect(result.value.noWavReason).toMatch(/backstop/);
913
+ expect(result.value.noWavReason).toMatch(/sentinel/);
914
+ expect(result.value.pico8LogTail).toContain('syntax error in main.lua');
915
+ expect(result.value.pico8LogTail).not.toContain('STALE');
916
+ });
917
+
918
+ it('early exit and an empty capture each get their own reason', async () => {
919
+ const exitProc = new FakeProcess();
920
+ const a1 = new ShellPico8Adapter({
921
+ env: {},
922
+ spawn: () => exitProc,
923
+ display: 'native',
924
+ });
925
+ const p1 = a1.record({cartPath: '/x/h.p8', wavDir, backstopMs: 9999});
926
+ exitProc.close(1);
927
+ const r1 = await p1;
928
+ expect(r1.ok).toBe(true);
929
+ if (r1.ok) expect(r1.value.noWavReason).toMatch(/exited before/);
930
+
931
+ const doneProc = new FakeProcess();
932
+ const a2 = new ShellPico8Adapter({
933
+ env: {},
934
+ spawn: () => doneProc,
935
+ display: 'native',
936
+ });
937
+ const p2 = a2.record({cartPath: '/x/h.p8', wavDir, backstopMs: 9999});
938
+ doneProc.emit(`${DONE_SENTINEL}\n`);
939
+ const r2 = await p2;
940
+ expect(r2.ok).toBe(true);
941
+ if (r2.ok)
942
+ expect(r2.value.noWavReason).toMatch(/reached its done-sentinel/);
943
+ });
944
+
945
+ it('a captured WAV carries no reason', async () => {
946
+ writeNonEmptyWav(join(wavDir, `${RECORD_WAV_BASENAME}.wav`));
947
+ const proc = new FakeProcess();
948
+ const adapter = new ShellPico8Adapter({
949
+ env: {},
950
+ spawn: () => proc,
951
+ display: 'native',
952
+ });
953
+ const p = adapter.record({cartPath: '/x/h.p8', wavDir, backstopMs: 9999});
954
+ proc.emit(`${DONE_SENTINEL}\n`);
955
+ const r = await p;
956
+ expect(r.ok).toBe(true);
957
+ if (r.ok) {
958
+ expect(r.value.noWavReason).toBeUndefined();
959
+ expect(r.value.pico8LogTail).toBeUndefined();
960
+ }
961
+ });
962
+ });
963
+
964
+ describe('pico8ChildEnv + the spawned env', () => {
965
+ it('passes the parent env through, overlaid with the resolved values', () => {
966
+ const env = pico8ChildEnv(
967
+ {PICO8_PATH: '/opt/pico8', PATH: undefined},
968
+ {
969
+ DISPLAY: ':1',
970
+ HOME: '/home/u',
971
+ XDG_RUNTIME_DIR: '/run/user/1',
972
+ PATH: '/usr/bin',
973
+ },
974
+ );
975
+ expect(env).toMatchObject({
976
+ DISPLAY: ':1',
977
+ HOME: '/home/u',
978
+ XDG_RUNTIME_DIR: '/run/user/1',
979
+ PICO8_PATH: '/opt/pico8',
980
+ PATH: '/usr/bin', // an undefined resolved value never erases the parent's
981
+ });
982
+ });
983
+
984
+ it('the spawn sees the env it was given, plus a private XDG_RUNTIME_DIR only when absent', async () => {
985
+ let seen: NodeJS.ProcessEnv | undefined;
986
+ const proc = new FakeProcess();
987
+ const adapter = new ShellPico8Adapter({
988
+ env: {DISPLAY: ':1', HOME: '/home/u'},
989
+ spawn: (_f, _a, env) => {
990
+ seen = env;
991
+ return proc;
992
+ },
993
+ });
994
+ const p = adapter.run({
995
+ cartPath: '/x/c.p8',
996
+ shotDir: '/x',
997
+ backstopMs: 9999,
998
+ });
999
+ proc.emit(`${DONE_SENTINEL}\n`);
1000
+ await p;
1001
+ expect(seen?.DISPLAY).toBe(':1');
1002
+ expect(seen?.HOME).toBe('/home/u');
1003
+ expect(seen?.XDG_RUNTIME_DIR).toMatch(/picopilot-xdg-runtime-/);
1004
+
1005
+ const proc2 = new FakeProcess();
1006
+ const adapter2 = new ShellPico8Adapter({
1007
+ env: {XDG_RUNTIME_DIR: '/run/user/1'},
1008
+ spawn: (_f, _a, env) => {
1009
+ seen = env;
1010
+ return proc2;
1011
+ },
1012
+ });
1013
+ const p2 = adapter2.run({
1014
+ cartPath: '/x/c.p8',
1015
+ shotDir: '/x',
1016
+ backstopMs: 9999,
1017
+ });
1018
+ proc2.emit(`${DONE_SENTINEL}\n`);
1019
+ await p2;
1020
+ expect(seen?.XDG_RUNTIME_DIR).toBe('/run/user/1');
1021
+ });
1022
+ });