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
@@ -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:
@@ -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
+ });