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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +111 -16
- 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 +61 -44
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/cart-includes.d.ts +11 -0
- package/dist/commands/cart-includes.d.ts.map +1 -0
- package/dist/commands/cart-includes.js +26 -0
- package/dist/commands/cart-includes.js.map +1 -0
- package/dist/commands/doctor.d.ts +22 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +222 -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 +66 -86
- 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 +3 -2
- package/dist/commands/gfx.js.map +1 -1
- package/dist/commands/label-cart.d.ts +72 -0
- package/dist/commands/label-cart.d.ts.map +1 -0
- package/dist/commands/label-cart.js +228 -0
- package/dist/commands/label-cart.js.map +1 -0
- 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 +76 -0
- package/dist/commands/pico8-env.d.ts.map +1 -0
- package/dist/commands/pico8-env.js +71 -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 +6 -32
- 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 +46 -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 +65 -19
- 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/gfx/label.d.ts +15 -9
- package/dist/engine/gfx/label.d.ts.map +1 -1
- package/dist/engine/gfx/label.js +130 -38
- package/dist/engine/gfx/label.js.map +1 -1
- 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/session-daemon-entry.d.ts +1 -1
- package/dist/engine/pico8/session-daemon-entry.js +1 -1
- package/dist/engine/pico8/session-daemon-main.d.ts +1 -1
- package/dist/engine/pico8/session-daemon-main.js +1 -1
- package/dist/engine/pico8/session-daemon.d.ts +1 -1
- package/dist/engine/pico8/session-daemon.d.ts.map +1 -1
- package/dist/engine/pico8/session-daemon.js +19 -19
- package/dist/engine/pico8/session-daemon.js.map +1 -1
- package/dist/engine/pico8/session.d.ts +1 -1
- package/dist/engine/pico8/session.js +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/pico8/supervisor.d.ts +1 -1
- package/dist/engine/pico8/supervisor.js +1 -1
- package/dist/engine/shrinko/adapter.d.ts +1 -1
- package/dist/engine/shrinko/shell.d.ts +3 -3
- package/dist/engine/shrinko/shell.js +3 -3
- package/package.json +2 -1
- package/src/cli.test.ts +188 -0
- package/src/cli.ts +122 -16
- package/src/commands/audio.test.ts +76 -1
- package/src/commands/audio.ts +79 -57
- package/src/commands/cart-includes.ts +27 -0
- package/src/commands/doctor.test.ts +149 -0
- package/src/commands/doctor.ts +280 -0
- package/src/commands/export.test.ts +327 -3
- package/src/commands/export.ts +81 -99
- package/src/commands/gfx.ts +7 -6
- package/src/commands/label-cart.ts +304 -0
- package/src/commands/lint.test.ts +6 -0
- package/src/commands/lint.ts +7 -6
- package/src/commands/minify.test.ts +6 -0
- package/src/commands/minify.ts +7 -6
- package/src/commands/pico8-env.ts +124 -0
- package/src/commands/playtest.test.ts +3 -0
- package/src/commands/playtest.ts +10 -34
- package/src/commands/run.test.ts +116 -2
- package/src/commands/run.ts +62 -24
- package/src/commands/serve.test.ts +138 -2
- package/src/commands/serve.ts +80 -21
- package/src/commands/shrinko-env.test.ts +68 -0
- package/src/commands/shrinko-env.ts +40 -0
- package/src/commands/tokens.test.ts +19 -0
- package/src/commands/tokens.ts +9 -6
- package/src/commands/verify.test.ts +12 -0
- package/src/commands/verify.ts +7 -4
- package/src/engine/child-env.ts +22 -0
- package/src/engine/gfx/fixtures/pico8-screenshot-384.png +0 -0
- package/src/engine/gfx/label.test.ts +467 -1
- package/src/engine/gfx/label.ts +147 -41
- 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/session-daemon-entry.ts +1 -1
- package/src/engine/pico8/session-daemon-main.ts +1 -1
- package/src/engine/pico8/session-daemon.test.ts +32 -1
- package/src/engine/pico8/session-daemon.ts +25 -17
- package/src/engine/pico8/session.test.ts +1 -1
- package/src/engine/pico8/session.ts +1 -1
- package/src/engine/pico8/shell.test.ts +449 -10
- package/src/engine/pico8/shell.ts +255 -26
- package/src/engine/pico8/supervisor.ts +1 -1
- package/src/engine/pico8/virtual-display.process.test.ts +206 -0
- package/src/engine/shrinko/adapter.ts +1 -1
- package/src/engine/shrinko/shell.ts +3 -3
- package/src/engine/skills/skills.test.ts +10 -0
- package/src/skills/picopilot-audio/SKILL.md +4 -8
- package/src/skills/picopilot-debug/SKILL.md +18 -1
- package/src/skills/picopilot-overview/SKILL.md +9 -1
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* to the most
|
|
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(
|
|
303
|
+
async function collectShot(
|
|
304
|
+
shotDir: string,
|
|
305
|
+
shotName: string,
|
|
306
|
+
): Promise<string | undefined> {
|
|
295
307
|
const exact = join(shotDir, `${shotName}.png`);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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,
|
|
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,
|
|
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()
|
|
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({
|
|
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
|
+
});
|