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
|
@@ -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()
|
|
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({
|
|
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
|
-
|
|
273
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
+
});
|