picopilot 0.0.0 → 0.1.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/LICENSE +661 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/audio.d.ts +17 -0
- package/dist/commands/audio.d.ts.map +1 -0
- package/dist/commands/audio.js +315 -0
- package/dist/commands/audio.js.map +1 -0
- package/dist/commands/export.d.ts +36 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +257 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/gfx.d.ts +28 -0
- package/dist/commands/gfx.d.ts.map +1 -0
- package/dist/commands/gfx.js +521 -0
- package/dist/commands/gfx.js.map +1 -0
- package/dist/commands/init.d.ts +34 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +151 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/lint.d.ts +20 -0
- package/dist/commands/lint.d.ts.map +1 -0
- package/dist/commands/lint.js +90 -0
- package/dist/commands/lint.js.map +1 -0
- package/dist/commands/minify.d.ts +20 -0
- package/dist/commands/minify.d.ts.map +1 -0
- package/dist/commands/minify.js +129 -0
- package/dist/commands/minify.js.map +1 -0
- package/dist/commands/music.d.ts +13 -0
- package/dist/commands/music.d.ts.map +1 -0
- package/dist/commands/music.js +241 -0
- package/dist/commands/music.js.map +1 -0
- package/dist/commands/playtest.d.ts +29 -0
- package/dist/commands/playtest.d.ts.map +1 -0
- package/dist/commands/playtest.js +631 -0
- package/dist/commands/playtest.js.map +1 -0
- package/dist/commands/run.d.ts +30 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +202 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/serve.d.ts +50 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +213 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/sfx.d.ts +13 -0
- package/dist/commands/sfx.d.ts.map +1 -0
- package/dist/commands/sfx.js +254 -0
- package/dist/commands/sfx.js.map +1 -0
- package/dist/commands/tokens.d.ts +36 -0
- package/dist/commands/tokens.d.ts.map +1 -0
- package/dist/commands/tokens.js +129 -0
- package/dist/commands/tokens.js.map +1 -0
- package/dist/commands/verify.d.ts +57 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +213 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/version.d.ts +12 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +24 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/engine/audio/index.d.ts +14 -0
- package/dist/engine/audio/index.d.ts.map +1 -0
- package/dist/engine/audio/index.js +14 -0
- package/dist/engine/audio/index.js.map +1 -0
- package/dist/engine/audio/music.d.ts +116 -0
- package/dist/engine/audio/music.d.ts.map +1 -0
- package/dist/engine/audio/music.js +138 -0
- package/dist/engine/audio/music.js.map +1 -0
- package/dist/engine/audio/section.d.ts +34 -0
- package/dist/engine/audio/section.d.ts.map +1 -0
- package/dist/engine/audio/section.js +67 -0
- package/dist/engine/audio/section.js.map +1 -0
- package/dist/engine/audio/sfx.d.ts +141 -0
- package/dist/engine/audio/sfx.d.ts.map +1 -0
- package/dist/engine/audio/sfx.js +506 -0
- package/dist/engine/audio/sfx.js.map +1 -0
- package/dist/engine/cart/cart.d.ts +75 -0
- package/dist/engine/cart/cart.d.ts.map +1 -0
- package/dist/engine/cart/cart.js +153 -0
- package/dist/engine/cart/cart.js.map +1 -0
- package/dist/engine/cart/errors.d.ts +29 -0
- package/dist/engine/cart/errors.d.ts.map +1 -0
- package/dist/engine/cart/errors.js +26 -0
- package/dist/engine/cart/errors.js.map +1 -0
- package/dist/engine/cart/gfx.d.ts +79 -0
- package/dist/engine/cart/gfx.d.ts.map +1 -0
- package/dist/engine/cart/gfx.js +171 -0
- package/dist/engine/cart/gfx.js.map +1 -0
- package/dist/engine/cart/index.d.ts +15 -0
- package/dist/engine/cart/index.d.ts.map +1 -0
- package/dist/engine/cart/index.js +15 -0
- package/dist/engine/cart/index.js.map +1 -0
- package/dist/engine/cart/map.d.ts +46 -0
- package/dist/engine/cart/map.d.ts.map +1 -0
- package/dist/engine/cart/map.js +118 -0
- package/dist/engine/cart/map.js.map +1 -0
- package/dist/engine/config/config.d.ts +74 -0
- package/dist/engine/config/config.d.ts.map +1 -0
- package/dist/engine/config/config.js +103 -0
- package/dist/engine/config/config.js.map +1 -0
- package/dist/engine/config/index.d.ts +7 -0
- package/dist/engine/config/index.d.ts.map +1 -0
- package/dist/engine/config/index.js +7 -0
- package/dist/engine/config/index.js.map +1 -0
- package/dist/engine/gfx/grid.d.ts +51 -0
- package/dist/engine/gfx/grid.d.ts.map +1 -0
- package/dist/engine/gfx/grid.js +116 -0
- package/dist/engine/gfx/grid.js.map +1 -0
- package/dist/engine/gfx/index.d.ts +17 -0
- package/dist/engine/gfx/index.d.ts.map +1 -0
- package/dist/engine/gfx/index.js +17 -0
- package/dist/engine/gfx/index.js.map +1 -0
- package/dist/engine/gfx/label.d.ts +34 -0
- package/dist/engine/gfx/label.d.ts.map +1 -0
- package/dist/engine/gfx/label.js +190 -0
- package/dist/engine/gfx/label.js.map +1 -0
- package/dist/engine/gfx/overlap.d.ts +42 -0
- package/dist/engine/gfx/overlap.d.ts.map +1 -0
- package/dist/engine/gfx/overlap.js +57 -0
- package/dist/engine/gfx/overlap.js.map +1 -0
- package/dist/engine/gfx/palette.d.ts +29 -0
- package/dist/engine/gfx/palette.d.ts.map +1 -0
- package/dist/engine/gfx/palette.js +46 -0
- package/dist/engine/gfx/palette.js.map +1 -0
- package/dist/engine/gfx/png.d.ts +31 -0
- package/dist/engine/gfx/png.d.ts.map +1 -0
- package/dist/engine/gfx/png.js +165 -0
- package/dist/engine/gfx/png.js.map +1 -0
- package/dist/engine/gfx/render.d.ts +42 -0
- package/dist/engine/gfx/render.d.ts.map +1 -0
- package/dist/engine/gfx/render.js +72 -0
- package/dist/engine/gfx/render.js.map +1 -0
- package/dist/engine/index.d.ts +22 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +22 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/pico8/adapter.d.ts +267 -0
- package/dist/engine/pico8/adapter.d.ts.map +1 -0
- package/dist/engine/pico8/adapter.js +37 -0
- package/dist/engine/pico8/adapter.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts +308 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -0
- package/dist/engine/pico8/drive.js +581 -0
- package/dist/engine/pico8/drive.js.map +1 -0
- package/dist/engine/pico8/harness.d.ts +146 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -0
- package/dist/engine/pico8/harness.js +251 -0
- package/dist/engine/pico8/harness.js.map +1 -0
- package/dist/engine/pico8/index.d.ts +21 -0
- package/dist/engine/pico8/index.d.ts.map +1 -0
- package/dist/engine/pico8/index.js +21 -0
- package/dist/engine/pico8/index.js.map +1 -0
- package/dist/engine/pico8/sentinel.d.ts +63 -0
- package/dist/engine/pico8/sentinel.d.ts.map +1 -0
- package/dist/engine/pico8/sentinel.js +86 -0
- package/dist/engine/pico8/sentinel.js.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.js +11 -0
- package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
- package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
- package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-main.js +71 -0
- package/dist/engine/pico8/session-daemon-main.js.map +1 -0
- package/dist/engine/pico8/session-daemon.d.ts +68 -0
- package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon.js +264 -0
- package/dist/engine/pico8/session-daemon.js.map +1 -0
- package/dist/engine/pico8/session.d.ts +140 -0
- package/dist/engine/pico8/session.d.ts.map +1 -0
- package/dist/engine/pico8/session.js +250 -0
- package/dist/engine/pico8/session.js.map +1 -0
- package/dist/engine/pico8/shell.d.ts +103 -0
- package/dist/engine/pico8/shell.d.ts.map +1 -0
- package/dist/engine/pico8/shell.js +324 -0
- package/dist/engine/pico8/shell.js.map +1 -0
- package/dist/engine/pico8/supervisor.d.ts +137 -0
- package/dist/engine/pico8/supervisor.d.ts.map +1 -0
- package/dist/engine/pico8/supervisor.js +140 -0
- package/dist/engine/pico8/supervisor.js.map +1 -0
- package/dist/engine/scaffold/index.d.ts +8 -0
- package/dist/engine/scaffold/index.d.ts.map +1 -0
- package/dist/engine/scaffold/index.js +8 -0
- package/dist/engine/scaffold/index.js.map +1 -0
- package/dist/engine/scaffold/scaffold.d.ts +49 -0
- package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
- package/dist/engine/scaffold/scaffold.js +217 -0
- package/dist/engine/scaffold/scaffold.js.map +1 -0
- package/dist/engine/shrinko/adapter.d.ts +167 -0
- package/dist/engine/shrinko/adapter.d.ts.map +1 -0
- package/dist/engine/shrinko/adapter.js +107 -0
- package/dist/engine/shrinko/adapter.js.map +1 -0
- package/dist/engine/shrinko/index.d.ts +13 -0
- package/dist/engine/shrinko/index.d.ts.map +1 -0
- package/dist/engine/shrinko/index.js +13 -0
- package/dist/engine/shrinko/index.js.map +1 -0
- package/dist/engine/shrinko/shell.d.ts +69 -0
- package/dist/engine/shrinko/shell.d.ts.map +1 -0
- package/dist/engine/shrinko/shell.js +156 -0
- package/dist/engine/shrinko/shell.js.map +1 -0
- package/dist/engine/skills/index.d.ts +8 -0
- package/dist/engine/skills/index.d.ts.map +1 -0
- package/dist/engine/skills/index.js +8 -0
- package/dist/engine/skills/index.js.map +1 -0
- package/dist/engine/skills/skills.d.ts +104 -0
- package/dist/engine/skills/skills.d.ts.map +1 -0
- package/dist/engine/skills/skills.js +149 -0
- package/dist/engine/skills/skills.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/package.json +48 -2
- package/src/bin.ts +4 -0
- package/src/cli.ts +61 -0
- package/src/commands/audio.test.ts +280 -0
- package/src/commands/audio.ts +410 -0
- package/src/commands/export.test.ts +310 -0
- package/src/commands/export.ts +316 -0
- package/src/commands/gfx-io.test.ts +178 -0
- package/src/commands/gfx.test.ts +470 -0
- package/src/commands/gfx.ts +645 -0
- package/src/commands/init.test.ts +460 -0
- package/src/commands/init.ts +193 -0
- package/src/commands/lint.test.ts +137 -0
- package/src/commands/lint.ts +117 -0
- package/src/commands/minify.test.ts +166 -0
- package/src/commands/minify.ts +156 -0
- package/src/commands/music.test.ts +272 -0
- package/src/commands/music.ts +288 -0
- package/src/commands/playtest.test.ts +317 -0
- package/src/commands/playtest.ts +773 -0
- package/src/commands/run.test.ts +238 -0
- package/src/commands/run.ts +259 -0
- package/src/commands/serve.test.ts +218 -0
- package/src/commands/serve.ts +272 -0
- package/src/commands/sfx.test.ts +349 -0
- package/src/commands/sfx.ts +300 -0
- package/src/commands/tokens.test.ts +212 -0
- package/src/commands/tokens.ts +168 -0
- package/src/commands/verify.test.ts +238 -0
- package/src/commands/verify.ts +256 -0
- package/src/commands/version.test.ts +70 -0
- package/src/commands/version.ts +24 -0
- package/src/engine/audio/index.ts +47 -0
- package/src/engine/audio/music.test.ts +188 -0
- package/src/engine/audio/music.ts +218 -0
- package/src/engine/audio/section.test.ts +124 -0
- package/src/engine/audio/section.ts +79 -0
- package/src/engine/audio/sfx.test.ts +410 -0
- package/src/engine/audio/sfx.ts +694 -0
- package/src/engine/cart/cart.test.ts +304 -0
- package/src/engine/cart/cart.ts +211 -0
- package/src/engine/cart/errors.ts +39 -0
- package/src/engine/cart/fixtures/crlf.p8 +6 -0
- package/src/engine/cart/fixtures/full.p8 +174 -0
- package/src/engine/cart/fixtures/header-only.p8 +2 -0
- package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
- package/src/engine/cart/fixtures/minimal.p8 +10 -0
- package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
- package/src/engine/cart/gfx.ts +195 -0
- package/src/engine/cart/index.ts +27 -0
- package/src/engine/cart/map.ts +134 -0
- package/src/engine/config/config.ts +115 -0
- package/src/engine/config/index.ts +12 -0
- package/src/engine/gfx/grid.test.ts +128 -0
- package/src/engine/gfx/grid.ts +136 -0
- package/src/engine/gfx/index.ts +34 -0
- package/src/engine/gfx/label.test.ts +105 -0
- package/src/engine/gfx/label.ts +229 -0
- package/src/engine/gfx/overlap.test.ts +71 -0
- package/src/engine/gfx/overlap.ts +66 -0
- package/src/engine/gfx/palette.test.ts +35 -0
- package/src/engine/gfx/palette.ts +54 -0
- package/src/engine/gfx/png.test.ts +111 -0
- package/src/engine/gfx/png.ts +196 -0
- package/src/engine/gfx/render.test.ts +180 -0
- package/src/engine/gfx/render.ts +106 -0
- package/src/engine/index.ts +21 -0
- package/src/engine/pico8/adapter.ts +303 -0
- package/src/engine/pico8/drive.test.ts +415 -0
- package/src/engine/pico8/drive.ts +729 -0
- package/src/engine/pico8/harness.test.ts +181 -0
- package/src/engine/pico8/harness.ts +354 -0
- package/src/engine/pico8/index.ts +20 -0
- package/src/engine/pico8/sentinel.test.ts +80 -0
- package/src/engine/pico8/sentinel.ts +93 -0
- package/src/engine/pico8/session-daemon-entry.ts +12 -0
- package/src/engine/pico8/session-daemon-main.ts +98 -0
- package/src/engine/pico8/session-daemon.test.ts +212 -0
- package/src/engine/pico8/session-daemon.ts +306 -0
- package/src/engine/pico8/session.test.ts +239 -0
- package/src/engine/pico8/session.ts +321 -0
- package/src/engine/pico8/shell.test.ts +583 -0
- package/src/engine/pico8/shell.ts +412 -0
- package/src/engine/pico8/supervisor.test.ts +105 -0
- package/src/engine/pico8/supervisor.ts +197 -0
- package/src/engine/scaffold/index.ts +15 -0
- package/src/engine/scaffold/scaffold.ts +236 -0
- package/src/engine/shrinko/adapter.ts +241 -0
- package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
- package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
- package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
- package/src/engine/shrinko/index.ts +33 -0
- package/src/engine/shrinko/shell.ts +224 -0
- package/src/engine/shrinko/shrinko.test.ts +182 -0
- package/src/engine/skills/index.ts +14 -0
- package/src/engine/skills/skills.test.ts +376 -0
- package/src/engine/skills/skills.ts +205 -0
- package/src/index.ts +13 -0
- package/src/skills/game-design-reference/SKILL.md +70 -0
- package/src/skills/game-jam/SKILL.md +45 -0
- package/src/skills/picopilot-art/SKILL.md +62 -0
- package/src/skills/picopilot-audio/SKILL.md +194 -0
- package/src/skills/picopilot-code/SKILL.md +97 -0
- package/src/skills/picopilot-code/reference/README.md +42 -0
- package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
- package/src/skills/picopilot-code/reference/platformer.md +119 -0
- package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
- package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
- package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
- package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
- package/src/skills/picopilot-debug/SKILL.md +195 -0
- package/src/skills/picopilot-overview/SKILL.md +52 -0
- package/src/version.ts +22 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import {execFileSync} from 'node:child_process';
|
|
2
|
+
import {existsSync, mkdtempSync, readdirSync, writeFileSync} from 'node:fs';
|
|
3
|
+
import {homedir, tmpdir} from 'node:os';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {Cli} from 'incur';
|
|
6
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
7
|
+
import type {
|
|
8
|
+
Pico8Adapter,
|
|
9
|
+
Pico8RecordResult,
|
|
10
|
+
Pico8Result,
|
|
11
|
+
RecordOptions,
|
|
12
|
+
RunOptions,
|
|
13
|
+
} from '../engine/pico8/index.js';
|
|
14
|
+
import {pico8NotFound} from '../engine/pico8/index.js';
|
|
15
|
+
import {type Pico8AdapterFactory, registerRun} from './run.js';
|
|
16
|
+
|
|
17
|
+
/** A trivial valid cart the command reads (existence + a path to hand the adapter). */
|
|
18
|
+
const CART_TEXT =
|
|
19
|
+
'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n';
|
|
20
|
+
|
|
21
|
+
let dir: string;
|
|
22
|
+
let cartPath: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
dir = mkdtempSync(join(tmpdir(), 'picopilot-run-'));
|
|
26
|
+
cartPath = join(dir, 'main.p8');
|
|
27
|
+
writeFileSync(cartPath, CART_TEXT);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
try {
|
|
32
|
+
execFileSync('rm', ['-rf', dir]);
|
|
33
|
+
} catch {
|
|
34
|
+
// ignore
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A stub {@link Pico8Adapter} standing in for BOTH the shell adapter and a
|
|
40
|
+
* hypothetical web-export adapter. It never spawns PICO-8, so no test depends on
|
|
41
|
+
* the paid binary. It also RECORDS the {@link RunOptions} it was handed, so a
|
|
42
|
+
* test can assert the command chose an isolated shotDir (never ~/Desktop).
|
|
43
|
+
*/
|
|
44
|
+
function stubAdapter(
|
|
45
|
+
result: Pico8Result,
|
|
46
|
+
seen?: {options?: RunOptions; recordOptions?: RecordOptions},
|
|
47
|
+
recordResult?: Pico8RecordResult,
|
|
48
|
+
): Pico8Adapter {
|
|
49
|
+
return {
|
|
50
|
+
async run(options) {
|
|
51
|
+
if (seen !== undefined) seen.options = options;
|
|
52
|
+
return result;
|
|
53
|
+
},
|
|
54
|
+
async record(options) {
|
|
55
|
+
if (seen !== undefined) seen.recordOptions = options;
|
|
56
|
+
return (
|
|
57
|
+
recordResult ?? {
|
|
58
|
+
ok: true,
|
|
59
|
+
value: {
|
|
60
|
+
wavPath: undefined,
|
|
61
|
+
printh: '',
|
|
62
|
+
exitReason: 'sentinel',
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ranWith(exitReason: 'sentinel' | 'timeout' | 'exit'): Pico8Result {
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
value: {screenshots: [], printh: 'hello from cart\n', exitReason},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Drives `picopilot run` through incur's `serve` DI with an INJECTED adapter
|
|
79
|
+
* factory (the seam), capturing stdout + exit without the real environment or
|
|
80
|
+
* the paid binary. `env` defaults to a bare PATH the stub ignores.
|
|
81
|
+
*/
|
|
82
|
+
async function runRun(
|
|
83
|
+
factory: Pico8AdapterFactory,
|
|
84
|
+
argv: string[] = ['run', cartPath, '--json'],
|
|
85
|
+
env: Record<string, string | undefined> = {PATH: '/does/not/matter'},
|
|
86
|
+
) {
|
|
87
|
+
const cli = Cli.create('picopilot', {version: '0.0.0'});
|
|
88
|
+
registerRun(cli, factory);
|
|
89
|
+
let stdout = '';
|
|
90
|
+
let exitCode = 0;
|
|
91
|
+
await cli.serve(argv, {
|
|
92
|
+
stdout(s) {
|
|
93
|
+
stdout += s;
|
|
94
|
+
},
|
|
95
|
+
exit(code) {
|
|
96
|
+
exitCode = code;
|
|
97
|
+
},
|
|
98
|
+
env,
|
|
99
|
+
});
|
|
100
|
+
return {stdout, exitCode};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('picopilot run: PICO-8 absent (the CI-testable boundary)', () => {
|
|
104
|
+
it('returns structured pico8-not-found + nonzero exit (mirrors shrinko, never a crash)', async () => {
|
|
105
|
+
const {stdout, exitCode} = await runRun(() => stubAdapter(pico8NotFound()));
|
|
106
|
+
expect(exitCode).not.toBe(0);
|
|
107
|
+
// The message carries the exact remedy + needs so an agent reading only the
|
|
108
|
+
// message still learns the fix.
|
|
109
|
+
expect(stdout).toContain('set PICO8_PATH or install PICO-8');
|
|
110
|
+
expect(stdout).toContain('pico8');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('picopilot run: structured run report', () => {
|
|
115
|
+
it('reports screenshots + printh + exitReason on a sentinel-ended run', async () => {
|
|
116
|
+
const {stdout, exitCode} = await runRun(() =>
|
|
117
|
+
stubAdapter(ranWith('sentinel')),
|
|
118
|
+
);
|
|
119
|
+
expect(exitCode).toBe(0);
|
|
120
|
+
const out = JSON.parse(stdout);
|
|
121
|
+
expect(out.exitReason).toBe('sentinel');
|
|
122
|
+
expect(out.printh).toContain('hello from cart');
|
|
123
|
+
expect(Array.isArray(out.screenshots)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('a backstop timeout CTAs toward the static gate (verify/tokens)', async () => {
|
|
127
|
+
const {stdout, exitCode} = await runRun(() =>
|
|
128
|
+
stubAdapter(ranWith('timeout')),
|
|
129
|
+
);
|
|
130
|
+
// A timeout is a soft warning, not a failure exit: the run still returns a report.
|
|
131
|
+
expect(exitCode).toBe(0);
|
|
132
|
+
const out = JSON.parse(stdout);
|
|
133
|
+
expect(out.exitReason).toBe('timeout');
|
|
134
|
+
// The CTA points at the static checks to catch a code fault before re-running.
|
|
135
|
+
expect(stdout.toLowerCase()).toContain('verify');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('picopilot run: shotDir isolation (never ~/Desktop)', () => {
|
|
140
|
+
it('defaults to an isolated temp dir, not the real ~/Desktop', async () => {
|
|
141
|
+
const seen: {options?: RunOptions} = {};
|
|
142
|
+
const {exitCode} = await runRun(() =>
|
|
143
|
+
stubAdapter(ranWith('sentinel'), seen),
|
|
144
|
+
);
|
|
145
|
+
expect(exitCode).toBe(0);
|
|
146
|
+
const shotDir = seen.options?.shotDir ?? '';
|
|
147
|
+
// The chosen dir is a fresh temp dir, NOT the default screenshot location.
|
|
148
|
+
expect(shotDir.startsWith(tmpdir())).toBe(true);
|
|
149
|
+
expect(shotDir).not.toContain(join(homedir(), 'Desktop'));
|
|
150
|
+
// And it was actually created for PICO-8 to write into.
|
|
151
|
+
expect(existsSync(shotDir)).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('threads --input through to the adapter (the one-shot playtest channel)', async () => {
|
|
155
|
+
const seen: {options?: RunOptions} = {};
|
|
156
|
+
await runRun(
|
|
157
|
+
() => stubAdapter(ranWith('sentinel'), seen),
|
|
158
|
+
['run', cartPath, '--input', 'rrrrz', '--json'],
|
|
159
|
+
);
|
|
160
|
+
expect(seen.options?.input).toBe('rrrrz');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('honours an explicit --shot-dir', async () => {
|
|
164
|
+
const seen: {options?: RunOptions} = {};
|
|
165
|
+
const chosen = join(dir, 'shots');
|
|
166
|
+
await runRun(
|
|
167
|
+
() => stubAdapter(ranWith('sentinel'), seen),
|
|
168
|
+
['run', cartPath, '--shot-dir', chosen, '--json'],
|
|
169
|
+
);
|
|
170
|
+
expect(seen.options?.shotDir).toBe(chosen);
|
|
171
|
+
expect(existsSync(chosen)).toBe(true); // created if missing
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('picopilot run: --record-audio (additive WAV capture, ADR-0009)', () => {
|
|
176
|
+
it('collects a WAV alongside the screenshots when --record-audio is set', async () => {
|
|
177
|
+
const seen: {options?: RunOptions; recordOptions?: RecordOptions} = {};
|
|
178
|
+
const wav = join(dir, 'run.wav');
|
|
179
|
+
writeFileSync(wav, 'x');
|
|
180
|
+
const {stdout, exitCode} = await runRun(
|
|
181
|
+
() =>
|
|
182
|
+
stubAdapter(ranWith('sentinel'), seen, {
|
|
183
|
+
ok: true,
|
|
184
|
+
value: {wavPath: wav, printh: '', exitReason: 'sentinel'},
|
|
185
|
+
}),
|
|
186
|
+
['run', cartPath, '--record-audio', '--json'],
|
|
187
|
+
);
|
|
188
|
+
expect(exitCode).toBe(0);
|
|
189
|
+
const out = JSON.parse(stdout);
|
|
190
|
+
// The WAV is an ADDITIVE artifact next to the existing screenshot envelope.
|
|
191
|
+
expect(out.wav).toBe(wav);
|
|
192
|
+
expect(out.audioCaptured).toBe(true);
|
|
193
|
+
expect(Array.isArray(out.screenshots)).toBe(true);
|
|
194
|
+
// The record pass got a real A/V request into the SAME isolated shotDir.
|
|
195
|
+
expect(seen.recordOptions?.wavDir).toBe(seen.options?.shotDir);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('is additive: without the flag no record pass runs (audioCaptured false)', async () => {
|
|
199
|
+
const seen: {options?: RunOptions; recordOptions?: RecordOptions} = {};
|
|
200
|
+
const {stdout} = await runRun(() => stubAdapter(ranWith('sentinel'), seen));
|
|
201
|
+
const out = JSON.parse(stdout);
|
|
202
|
+
expect(out.audioCaptured).toBe(false);
|
|
203
|
+
expect(out.wav).toBeUndefined();
|
|
204
|
+
expect(seen.recordOptions).toBeUndefined(); // record() never called
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('picopilot run: cart-not-found (picopilot-side error, distinct from pico8 absence)', () => {
|
|
209
|
+
it('errors with a nonzero exit when the cart path does not exist', async () => {
|
|
210
|
+
const {stdout, exitCode} = await runRun(
|
|
211
|
+
() => stubAdapter(ranWith('sentinel')),
|
|
212
|
+
['run', join(dir, 'nope.p8'), '--json'],
|
|
213
|
+
);
|
|
214
|
+
expect(exitCode).not.toBe(0);
|
|
215
|
+
expect(stdout).toContain('no cart at');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('does NOT invoke the adapter when the cart is missing (fails fast)', async () => {
|
|
219
|
+
let called = false;
|
|
220
|
+
await runRun(() => {
|
|
221
|
+
called = true;
|
|
222
|
+
return stubAdapter(ranWith('sentinel'));
|
|
223
|
+
}, ['run', join(dir, 'missing.p8'), '--json']);
|
|
224
|
+
// The cart-existence check runs BEFORE the adapter factory.
|
|
225
|
+
expect(called).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('leaves the real ~/Desktop BYTE-untouched across a run (no screenshot pollution)', async () => {
|
|
229
|
+
// The pollution guard: the -desktop flag redirects screenshots to a temp dir,
|
|
230
|
+
// so a default run must never add/change anything under the real ~/Desktop.
|
|
231
|
+
const desktop = join(homedir(), 'Desktop');
|
|
232
|
+
const snap = (): string[] =>
|
|
233
|
+
existsSync(desktop) ? readdirSync(desktop).sort() : [];
|
|
234
|
+
const before = snap();
|
|
235
|
+
await runRun(() => stubAdapter(ranWith('sentinel')));
|
|
236
|
+
expect(snap()).toEqual(before); // same entries: an absent dir stays absent, a present one unchanged
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
import {tmpdir} from 'node:os';
|
|
9
|
+
import {isAbsolute, join, resolve} from 'node:path';
|
|
10
|
+
import {type Cli, z} from 'incur';
|
|
11
|
+
import {
|
|
12
|
+
buildRecordHarness,
|
|
13
|
+
DONE_SENTINEL,
|
|
14
|
+
HarnessError,
|
|
15
|
+
type Pico8Adapter,
|
|
16
|
+
ShellPico8Adapter,
|
|
17
|
+
} from '../engine/pico8/index.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Injects the PICO-8 adapter `run` uses (defaults to the shell adapter over
|
|
21
|
+
* native `pico8 -x`). Mirrors the shrinko `adapterFactory` seam: a test passes a
|
|
22
|
+
* stub to drive the sentinel/backstop/absent paths without the paid binary, and
|
|
23
|
+
* to isolate the child `env` (its `PICO8_PATH`/`PATH`).
|
|
24
|
+
*/
|
|
25
|
+
export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
|
|
26
|
+
|
|
27
|
+
const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
|
|
28
|
+
new ShellPico8Adapter({env});
|
|
29
|
+
|
|
30
|
+
/** The default backstop: kill a cart that never signals/exits after this many ms. */
|
|
31
|
+
const DEFAULT_BACKSTOP_MS = 15_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Registers `picopilot run`, the THIN sentinel-watch + screenshot-collect
|
|
35
|
+
* orchestration command (US #14, ADR-0006). It is NOT a `pico8` wrapper: its
|
|
36
|
+
* value is the glue an agent should not hand-roll every run, launch PICO-8,
|
|
37
|
+
* stream stdout, kill on the cart's done-sentinel (backstop as the safety net),
|
|
38
|
+
* and collect screenshots + printh + exit reason into ONE structured result.
|
|
39
|
+
*
|
|
40
|
+
* The cart cooperates: it self-screenshots via `extcmd("screen")` on a timer and
|
|
41
|
+
* prints the done-sentinel via `printh` when finished (the `picopilot-debug`
|
|
42
|
+
* skill teaches the recipe). A cart cannot self-quit the app, so the external
|
|
43
|
+
* sentinel-kill IS the quit mechanism.
|
|
44
|
+
*
|
|
45
|
+
* PICO-8 absent → structured `pico8-not-found` + nonzero exit (mirrors the
|
|
46
|
+
* shrinko boundary), never a crash or a hang. That absent path is the one
|
|
47
|
+
* exercised in CI; live runs are a manual/opt-in tier.
|
|
48
|
+
*
|
|
49
|
+
* @param adapterFactory injects the adapter (defaults to the shell adapter);
|
|
50
|
+
* tests pass a stub to drive present/absent without the real binary.
|
|
51
|
+
*/
|
|
52
|
+
export function registerRun(
|
|
53
|
+
cli: Cli.Cli,
|
|
54
|
+
adapterFactory: Pico8AdapterFactory = defaultAdapterFactory,
|
|
55
|
+
): void {
|
|
56
|
+
cli.command('run', {
|
|
57
|
+
description:
|
|
58
|
+
"Run a cart on the user's PICO-8, capture printh + screenshots, end on a done-sentinel. Requires PICO-8.",
|
|
59
|
+
args: z.object({
|
|
60
|
+
cart: z
|
|
61
|
+
.string()
|
|
62
|
+
.default('main.p8')
|
|
63
|
+
.describe(
|
|
64
|
+
'The .p8 cart to run. Defaults to main.p8 in the current folder.',
|
|
65
|
+
),
|
|
66
|
+
}),
|
|
67
|
+
options: z.object({
|
|
68
|
+
shotDir: z
|
|
69
|
+
.string()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe(
|
|
72
|
+
'Where PICO-8 writes screenshots (-desktop). Defaults to an isolated temp dir; NEVER ~/Desktop.',
|
|
73
|
+
),
|
|
74
|
+
sentinel: z
|
|
75
|
+
.string()
|
|
76
|
+
.default(DONE_SENTINEL)
|
|
77
|
+
.describe(
|
|
78
|
+
'The stdout line that ends the run. The cart printh-es this when done.',
|
|
79
|
+
),
|
|
80
|
+
backstopMs: z
|
|
81
|
+
.number()
|
|
82
|
+
.int()
|
|
83
|
+
.positive()
|
|
84
|
+
.default(DEFAULT_BACKSTOP_MS)
|
|
85
|
+
.describe(
|
|
86
|
+
'Safety backstop: kill PICO-8 after this many ms if it never signals.',
|
|
87
|
+
),
|
|
88
|
+
input: z
|
|
89
|
+
.string()
|
|
90
|
+
.optional()
|
|
91
|
+
.describe(
|
|
92
|
+
'One-shot scripted input passed as `-p` (the cart reads it via stat(6)) for automated playtests.',
|
|
93
|
+
),
|
|
94
|
+
recordAudio: z
|
|
95
|
+
.boolean()
|
|
96
|
+
.default(false)
|
|
97
|
+
.describe(
|
|
98
|
+
"Also capture a WAV of the run's audio alongside the screenshots (ADR-0009). A REAL-TIME recording that needs a real audio+video session (a separate -run pass, NOT the headless screenshot pass), never an offline export.",
|
|
99
|
+
),
|
|
100
|
+
}),
|
|
101
|
+
// incur resolves these from the (test-overridable) env source and hands
|
|
102
|
+
// them to the child, so PICO8_PATH/PATH locate the binary in isolation.
|
|
103
|
+
env: z.object({
|
|
104
|
+
PICO8_PATH: z
|
|
105
|
+
.string()
|
|
106
|
+
.optional()
|
|
107
|
+
.describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
|
|
108
|
+
PATH: z.string().optional().describe('Used to locate `pico8`.'),
|
|
109
|
+
}),
|
|
110
|
+
output: z.object({
|
|
111
|
+
screenshots: z
|
|
112
|
+
.array(z.string())
|
|
113
|
+
.describe('Absolute paths of the PNG screenshots the cart produced.'),
|
|
114
|
+
printh: z
|
|
115
|
+
.string()
|
|
116
|
+
.describe('Captured stdout (the cart printh output + PICO-8 lines).'),
|
|
117
|
+
exitReason: z
|
|
118
|
+
.enum(['sentinel', 'timeout', 'exit'])
|
|
119
|
+
.describe(
|
|
120
|
+
'sentinel = cart signalled done; timeout = backstop fired; exit = PICO-8 quit.',
|
|
121
|
+
),
|
|
122
|
+
shotDir: z.string().describe('The dir screenshots were written to.'),
|
|
123
|
+
wav: z
|
|
124
|
+
.string()
|
|
125
|
+
.optional()
|
|
126
|
+
.describe(
|
|
127
|
+
'Absolute path of the captured WAV (only with --record-audio; omitted if no audio was captured).',
|
|
128
|
+
),
|
|
129
|
+
audioCaptured: z
|
|
130
|
+
.boolean()
|
|
131
|
+
.describe(
|
|
132
|
+
'Whether a non-empty WAV was captured (false unless --record-audio ran and produced audio).',
|
|
133
|
+
),
|
|
134
|
+
}),
|
|
135
|
+
examples: [
|
|
136
|
+
{description: 'Run main.p8'},
|
|
137
|
+
{description: 'Run a specific cart', args: {cart: 'game.p8'}},
|
|
138
|
+
{
|
|
139
|
+
description: 'Collect screenshots into a chosen dir',
|
|
140
|
+
args: {cart: 'game.p8'},
|
|
141
|
+
options: {shotDir: './shots'},
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
async run({args, options, env, error, ok}) {
|
|
145
|
+
const cartPath = isAbsolute(args.cart)
|
|
146
|
+
? args.cart
|
|
147
|
+
: resolve(process.cwd(), args.cart);
|
|
148
|
+
|
|
149
|
+
// A missing cart is a picopilot-side error, distinct from PICO-8 absence.
|
|
150
|
+
if (!existsSync(cartPath)) {
|
|
151
|
+
return error({
|
|
152
|
+
code: 'cart-not-found',
|
|
153
|
+
message: `no cart at ${cartPath}`,
|
|
154
|
+
exitCode: 1,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Screenshot dir: a user-given path, else an isolated temp dir. NEVER the
|
|
159
|
+
// default ~/Desktop (the `-desktop` flag is the isolation lever). Create
|
|
160
|
+
// it so PICO-8 has somewhere to write.
|
|
161
|
+
const shotDir =
|
|
162
|
+
options.shotDir !== undefined
|
|
163
|
+
? isAbsolute(options.shotDir)
|
|
164
|
+
? options.shotDir
|
|
165
|
+
: resolve(process.cwd(), options.shotDir)
|
|
166
|
+
: mkdtempSync(join(tmpdir(), 'picopilot-run-'));
|
|
167
|
+
if (!existsSync(shotDir)) mkdirSync(shotDir, {recursive: true});
|
|
168
|
+
|
|
169
|
+
const adapter = adapterFactory(env as NodeJS.ProcessEnv);
|
|
170
|
+
const result = await adapter.run({
|
|
171
|
+
cartPath,
|
|
172
|
+
shotDir,
|
|
173
|
+
sentinel: options.sentinel,
|
|
174
|
+
backstopMs: options.backstopMs,
|
|
175
|
+
input: options.input,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!result.ok) {
|
|
179
|
+
// The two-tier structured failure (US #19, ADR-0006): PICO-8 absent.
|
|
180
|
+
// The message carries the exact remedy + needs; the code is the handle.
|
|
181
|
+
return error({
|
|
182
|
+
code: result.reason,
|
|
183
|
+
message: `PICO-8 is not installed. ${result.remedy} (needs: ${result.needs.join(', ')})`,
|
|
184
|
+
exitCode: 1,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const {screenshots, printh, exitReason} = result.value;
|
|
189
|
+
|
|
190
|
+
// --record-audio: an ADDITIVE second pass that records the run's audio to a
|
|
191
|
+
// WAV. It cannot reuse the headless `-x` screenshot pass (which mixes no
|
|
192
|
+
// audio -> an empty WAV, ADR-0009), so it does a separate `-run` capture via
|
|
193
|
+
// the record seam, injecting the same cooperative recorder `audio record`
|
|
194
|
+
// uses. The WAV lands in the isolated shotDir alongside the screenshots.
|
|
195
|
+
let wav: string | undefined;
|
|
196
|
+
let audioCaptured = false;
|
|
197
|
+
if (options.recordAudio) {
|
|
198
|
+
try {
|
|
199
|
+
const harness = buildRecordHarness(readFileSync(cartPath, 'utf8'), {
|
|
200
|
+
sentinel: options.sentinel,
|
|
201
|
+
});
|
|
202
|
+
const harnessCart = join(shotDir, 'record-audio-harness.p8');
|
|
203
|
+
writeFileSync(harnessCart, harness.cartText);
|
|
204
|
+
const rec = await adapter.record({
|
|
205
|
+
cartPath: harnessCart,
|
|
206
|
+
wavDir: shotDir,
|
|
207
|
+
wavBasename: harness.wavBasename,
|
|
208
|
+
sentinel: options.sentinel,
|
|
209
|
+
backstopMs: options.backstopMs,
|
|
210
|
+
});
|
|
211
|
+
if (rec.ok) {
|
|
212
|
+
wav = rec.value.wavPath;
|
|
213
|
+
audioCaptured = rec.value.wavPath !== undefined;
|
|
214
|
+
}
|
|
215
|
+
// A record-pass pico8-not-found is impossible here: the screenshot
|
|
216
|
+
// pass above already returned pico8-not-found if PICO-8 were absent.
|
|
217
|
+
} catch (e) {
|
|
218
|
+
if (!(e instanceof HarnessError)) throw e;
|
|
219
|
+
// A harness-build failure (unparseable cart) leaves audioCaptured false;
|
|
220
|
+
// the screenshot run's report still stands.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A backstop timeout is a soft warning: the cart never signalled done, so
|
|
225
|
+
// it may have hung or errored. CTA the agent toward the static checks
|
|
226
|
+
// (lint/tokens) to catch a code fault before re-running.
|
|
227
|
+
const cta =
|
|
228
|
+
exitReason === 'timeout'
|
|
229
|
+
? {
|
|
230
|
+
description:
|
|
231
|
+
'The cart never printed the done-sentinel (backstop fired). Check for a fault:',
|
|
232
|
+
commands: [
|
|
233
|
+
{
|
|
234
|
+
command: 'verify',
|
|
235
|
+
description:
|
|
236
|
+
'Run the static gate (tokens + lint + integrity).',
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
command: 'tokens',
|
|
240
|
+
description: 'Confirm the cart is under the token budget.',
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
}
|
|
244
|
+
: undefined;
|
|
245
|
+
|
|
246
|
+
return ok(
|
|
247
|
+
{
|
|
248
|
+
screenshots: [...screenshots],
|
|
249
|
+
printh,
|
|
250
|
+
exitReason,
|
|
251
|
+
shotDir,
|
|
252
|
+
wav,
|
|
253
|
+
audioCaptured,
|
|
254
|
+
},
|
|
255
|
+
cta === undefined ? undefined : {cta},
|
|
256
|
+
);
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
}
|