picopilot 0.0.0 → 0.1.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/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 +224 -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 +259 -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 +482 -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 +264 -0
- package/src/commands/run.ts +285 -0
- package/src/commands/serve.test.ts +227 -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 +278 -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,412 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
statSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import {tmpdir} from 'node:os';
|
|
10
|
+
import {join} from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
type DriveOptions,
|
|
13
|
+
type ExitReason,
|
|
14
|
+
EXPORT_HTML_NAME,
|
|
15
|
+
type ExportOptions,
|
|
16
|
+
type Pico8Adapter,
|
|
17
|
+
pico8NotFound,
|
|
18
|
+
type Pico8DriveResult,
|
|
19
|
+
type Pico8ExportResult,
|
|
20
|
+
type Pico8RecordResult,
|
|
21
|
+
type Pico8Result,
|
|
22
|
+
RECORD_WAV_BASENAME,
|
|
23
|
+
type RecordOptions,
|
|
24
|
+
type RunOptions,
|
|
25
|
+
} from './adapter.js';
|
|
26
|
+
import {SentinelWatcher} from './sentinel.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A spawned PICO-8 process, abstracted so tests inject a fake. `onStdout`
|
|
30
|
+
* delivers stdout chunks (both `stdout` and `stderr` folded in, since PICO-8's
|
|
31
|
+
* `printh` and its own lines can land on either); `onClose` fires when the
|
|
32
|
+
* process exits on its own; `onError` fires on a spawn failure (ENOENT ⇒ PICO-8
|
|
33
|
+
* absent). `kill` terminates the process tree (the backstop / sentinel kill).
|
|
34
|
+
*/
|
|
35
|
+
export interface Pico8Process {
|
|
36
|
+
onStdout(cb: (chunk: string) => void): void;
|
|
37
|
+
onClose(cb: (code: number | null) => void): void;
|
|
38
|
+
onError(cb: (err: NodeJS.ErrnoException) => void): void;
|
|
39
|
+
kill(): void;
|
|
40
|
+
/**
|
|
41
|
+
* Writes raw bytes to the process's stdin, for the `drive` transport (the
|
|
42
|
+
* fixed-size command blocks). Absent/undefined when stdin was detached
|
|
43
|
+
* (`run`/`record` ignore stdin so the cart never blocks on console input).
|
|
44
|
+
*/
|
|
45
|
+
writeStdin?(bytes: Uint8Array): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The process-spawn seam (mirrors shrinko's `ChildRunner`). The real impl spawns
|
|
50
|
+
* `pico8 -x`; a test injects a fake that emits synthetic stdout and a chosen
|
|
51
|
+
* outcome, so the sentinel-kill / backstop / pico8-absent paths are all testable
|
|
52
|
+
* WITHOUT the paid binary. `env` is handed in (its `PICO8_PATH`/`PATH` are the
|
|
53
|
+
* isolation levers), never read ambiently.
|
|
54
|
+
*/
|
|
55
|
+
export type SpawnRunner = (
|
|
56
|
+
file: string,
|
|
57
|
+
args: string[],
|
|
58
|
+
env: NodeJS.ProcessEnv,
|
|
59
|
+
options?: {stdin?: boolean},
|
|
60
|
+
) => Pico8Process;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The default {@link SpawnRunner}: `spawn` with stdin detached (`ignore`) so the
|
|
64
|
+
* cart never blocks on console input, stdout/stderr piped for the sentinel
|
|
65
|
+
* watch. A spawn failure surfaces via `onError` (ENOENT ⇒ absent). `kill` sends
|
|
66
|
+
* SIGKILL to the whole tree (`detached` + negative pid) so a hung PICO-8 dies.
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* An isolated directory to hand PICO-8 as its `-home`, so it writes its config /
|
|
70
|
+
* data tree (`config.txt`, `log.txt`, `sdl_controllers.txt`, `backup/`, `carts/`,
|
|
71
|
+
* `bbs/`, `cdata/`, `cstore/`, `plates/`) THERE and never litters the caller's
|
|
72
|
+
* CWD. Without `-home`, PICO-8 creates that tree in whatever directory it was
|
|
73
|
+
* spawned from, e.g. a user's repo root (see
|
|
74
|
+
* work/notes/observations/pico8-appimage-writes-config-into-cwd.md). Verified:
|
|
75
|
+
* `-home <dir>` fully redirects every one of those files/dirs.
|
|
76
|
+
*
|
|
77
|
+
* One dir per process, created lazily under the OS temp dir and reused by every
|
|
78
|
+
* launch (run/drive/export/record/session). It is throwaway config, so OS temp
|
|
79
|
+
* cleanup reclaims it; picopilot never needs its contents.
|
|
80
|
+
*/
|
|
81
|
+
let cachedHome: string | undefined;
|
|
82
|
+
export function pico8HomeDir(): string {
|
|
83
|
+
if (cachedHome !== undefined && existsSync(cachedHome)) return cachedHome;
|
|
84
|
+
cachedHome = mkdtempSync(join(tmpdir(), 'picopilot-pico8-home-'));
|
|
85
|
+
return cachedHome;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Prepend `-home <isolatedDir>` to a PICO-8 arg list so its config/data never
|
|
90
|
+
* land in the CWD. Applied at every spawn site.
|
|
91
|
+
*/
|
|
92
|
+
export function withPico8Home(args: string[]): string[] {
|
|
93
|
+
const home = pico8HomeDir();
|
|
94
|
+
mkdirSync(home, {recursive: true});
|
|
95
|
+
return ['-home', home, ...args];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const spawnRunner: SpawnRunner = (file, args, env, options) => {
|
|
99
|
+
// `drive` pipes fixed-size command blocks to stdin; `run`/`record` detach it so
|
|
100
|
+
// the cart never blocks on console input.
|
|
101
|
+
const stdin = options?.stdin === true ? 'pipe' : 'ignore';
|
|
102
|
+
const child = spawn(file, args, {
|
|
103
|
+
env,
|
|
104
|
+
stdio: [stdin, 'pipe', 'pipe'],
|
|
105
|
+
detached: true,
|
|
106
|
+
});
|
|
107
|
+
child.stdout?.setEncoding('utf8');
|
|
108
|
+
child.stderr?.setEncoding('utf8');
|
|
109
|
+
return {
|
|
110
|
+
onStdout(cb) {
|
|
111
|
+
child.stdout?.on('data', cb);
|
|
112
|
+
child.stderr?.on('data', cb);
|
|
113
|
+
},
|
|
114
|
+
onClose(cb) {
|
|
115
|
+
child.on('close', cb);
|
|
116
|
+
},
|
|
117
|
+
onError(cb) {
|
|
118
|
+
child.on('error', cb as (e: Error) => void);
|
|
119
|
+
},
|
|
120
|
+
writeStdin(bytes) {
|
|
121
|
+
try {
|
|
122
|
+
child.stdin?.write(Buffer.from(bytes));
|
|
123
|
+
} catch {
|
|
124
|
+
// A closed stdin (the cart exited) is not fatal to the run.
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
kill() {
|
|
128
|
+
try {
|
|
129
|
+
// Negative pid kills the process GROUP (detached), so any child dies too.
|
|
130
|
+
if (child.pid !== undefined) process.kill(-child.pid, 'SIGKILL');
|
|
131
|
+
} catch {
|
|
132
|
+
child.kill('SIGKILL'); // fall back to killing just the process
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** The candidate ways to launch PICO-8, tried in order: `PICO8_PATH`, then `pico8` on `PATH`. */
|
|
139
|
+
export function pico8Candidates(env: NodeJS.ProcessEnv): string[] {
|
|
140
|
+
const fromEnv = env.PICO8_PATH?.trim();
|
|
141
|
+
return fromEnv !== undefined && fromEnv.length > 0
|
|
142
|
+
? [fromEnv, 'pico8']
|
|
143
|
+
: ['pico8'];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The files an export produced in `outDir`, absolute + sorted. PICO-8 writes the
|
|
148
|
+
* `<name>.html` shell and the `<name>.js` runtime payload; we return every file
|
|
149
|
+
* present so the report is faithful even if a future PICO-8 adds siblings.
|
|
150
|
+
*/
|
|
151
|
+
function collectExportFiles(outDir: string): string[] {
|
|
152
|
+
if (!existsSync(outDir)) return [];
|
|
153
|
+
return readdirSync(outDir)
|
|
154
|
+
.sort()
|
|
155
|
+
.map((f) => join(outDir, f))
|
|
156
|
+
.filter((p) => {
|
|
157
|
+
try {
|
|
158
|
+
return statSync(p).isFile();
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** PNG files a run produced in its `-desktop` dir, sorted for deterministic order. */
|
|
166
|
+
function collectScreenshots(shotDir: string): string[] {
|
|
167
|
+
if (!existsSync(shotDir)) return [];
|
|
168
|
+
return readdirSync(shotDir)
|
|
169
|
+
.filter((f) => f.toLowerCase().endsWith('.png'))
|
|
170
|
+
.sort()
|
|
171
|
+
.map((f) => join(shotDir, f));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The WAV a record run produced in its `wavDir`, or `undefined`. Prefers the
|
|
176
|
+
* expected `<basename>.wav` (what the harness named via `set_filename`); falls
|
|
177
|
+
* back to any single `.wav` present. A well-formed but EMPTY WAV (a headless
|
|
178
|
+
* `-x` capture yields a 44-byte 0-frame RIFF header, ADR-0009) is treated as
|
|
179
|
+
* "no usable WAV" so a silent capture is not reported as a real recording.
|
|
180
|
+
*/
|
|
181
|
+
function collectWav(wavDir: string, basename: string): string | undefined {
|
|
182
|
+
if (!existsSync(wavDir)) return undefined;
|
|
183
|
+
const wavs = readdirSync(wavDir).filter((f) =>
|
|
184
|
+
f.toLowerCase().endsWith('.wav'),
|
|
185
|
+
);
|
|
186
|
+
if (wavs.length === 0) return undefined;
|
|
187
|
+
const expected = `${basename}.wav`;
|
|
188
|
+
const chosen = wavs.includes(expected) ? expected : wavs.sort()[0]!;
|
|
189
|
+
const path = join(wavDir, chosen);
|
|
190
|
+
// A 44-byte RIFF header with no PCM data is the empty/silent (headless) WAV.
|
|
191
|
+
try {
|
|
192
|
+
if (statSync(path).size <= 44) return undefined;
|
|
193
|
+
} catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
return path;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Options for {@link ShellPico8Adapter}. */
|
|
200
|
+
export interface ShellPico8Options {
|
|
201
|
+
/** The environment for the child (its `PICO8_PATH`/`PATH` locate the binary). */
|
|
202
|
+
readonly env: NodeJS.ProcessEnv;
|
|
203
|
+
/** The spawn runner (defaults to {@link spawnRunner}); tests inject a fake. */
|
|
204
|
+
readonly spawn?: SpawnRunner;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The v1 {@link Pico8Adapter}: shells out to the user's native `pico8 -x`
|
|
209
|
+
* (ADR-0006, native path first). It owns the ORCHESTRATION the command should
|
|
210
|
+
* not hand-roll: launch, stream stdout, kill on the done-sentinel, arm the
|
|
211
|
+
* backstop, and collect screenshots + printh + exit reason into one result.
|
|
212
|
+
*
|
|
213
|
+
* PICO-8 absent (the first candidate ENOENTs and there is no fallback) returns
|
|
214
|
+
* the structured {@link pico8NotFound} value, never a throw or a hang. That
|
|
215
|
+
* absent path is the CI-testable one; live runs are a manual/opt-in tier.
|
|
216
|
+
*/
|
|
217
|
+
export class ShellPico8Adapter implements Pico8Adapter {
|
|
218
|
+
private readonly env: NodeJS.ProcessEnv;
|
|
219
|
+
private readonly spawnRun: SpawnRunner;
|
|
220
|
+
|
|
221
|
+
constructor(options: ShellPico8Options) {
|
|
222
|
+
this.env = options.env;
|
|
223
|
+
this.spawnRun = options.spawn ?? spawnRunner;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
run(options: RunOptions): Promise<Pico8Result> {
|
|
227
|
+
const [file] = pico8Candidates(this.env) as [string, ...string[]];
|
|
228
|
+
const args = [
|
|
229
|
+
'-desktop',
|
|
230
|
+
options.shotDir,
|
|
231
|
+
'-x',
|
|
232
|
+
options.cartPath,
|
|
233
|
+
// One-shot scripted input (the cart reads it via stat(6)); omitted if absent.
|
|
234
|
+
...(options.input !== undefined ? ['-p', options.input] : []),
|
|
235
|
+
];
|
|
236
|
+
return this.orchestrate(file, args, options, (exitReason, printh) => ({
|
|
237
|
+
ok: true,
|
|
238
|
+
value: {
|
|
239
|
+
screenshots: collectScreenshots(options.shotDir),
|
|
240
|
+
printh,
|
|
241
|
+
exitReason,
|
|
242
|
+
},
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Records a running cart's audio to a WAV (ADR-0009). Launches PICO-8 in a
|
|
248
|
+
* REAL A/V session (`-run`, NOT the headless `-x` that mixes no audio) and
|
|
249
|
+
* controls the WAV location with `-root_path <wavDir>`, because
|
|
250
|
+
* `extcmd("audio_end", 1)` saves to PICO-8's CURRENT folder, not `-desktop`
|
|
251
|
+
* (the `audio_end(1)` trap). The cooperating cart (a harness for `record`, or
|
|
252
|
+
* the whole render harness) does the `audio_rec`/`audio_end`/sentinel dance; we
|
|
253
|
+
* watch the sentinel, arm the backstop, and collect `<basename>.wav` from the
|
|
254
|
+
* isolated `wavDir`. Live capture is a manual/opt-in tier; the absent path is
|
|
255
|
+
* the CI-testable one.
|
|
256
|
+
*/
|
|
257
|
+
/**
|
|
258
|
+
* Drives a throwaway cart through scripted input and captures live gameplay
|
|
259
|
+
* (ADR-0011). Launches `-desktop <shotDir> -x <cart>` with a LIVE stdin, writes
|
|
260
|
+
* the whole encoded FIXED-SIZE command block stream up front (the one-shot; the
|
|
261
|
+
* cart drains one block per frame and screenshots at the SHOT points), watches
|
|
262
|
+
* the sentinel, arms the backstop, and collects the SHOT PNGs + printh + exit
|
|
263
|
+
* reason. Returns {@link pico8NotFound} when PICO-8 is absent. Live capture is a
|
|
264
|
+
* manual/opt-in tier; the absent path is the CI-testable one.
|
|
265
|
+
*/
|
|
266
|
+
drive(options: DriveOptions): Promise<Pico8DriveResult> {
|
|
267
|
+
const [file] = pico8Candidates(this.env) as [string, ...string[]];
|
|
268
|
+
const args = ['-desktop', options.shotDir, '-x', options.cartPath];
|
|
269
|
+
return this.orchestrate<Pico8DriveResult>(
|
|
270
|
+
file,
|
|
271
|
+
args,
|
|
272
|
+
{sentinel: options.sentinel, backstopMs: options.backstopMs},
|
|
273
|
+
(exitReason, printh) => ({
|
|
274
|
+
ok: true,
|
|
275
|
+
value: {
|
|
276
|
+
screenshots: collectScreenshots(options.shotDir),
|
|
277
|
+
printh,
|
|
278
|
+
exitReason,
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
{stdin: true, onSpawn: (proc) => proc.writeStdin?.(options.blocks)},
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Exports the cart as a PICO-8 HTML bundle over the HEADLESS `-x` path (no
|
|
287
|
+
* display needed: `pico8 <cart> -export <outDir>/<name> -x` runs the cart, writes
|
|
288
|
+
* `<name>.html` + `<name>.js`, and exits). PICO-8 derives both file names from
|
|
289
|
+
* the `.html` name, so passing `index.html` yields a directly-serveable pair.
|
|
290
|
+
* A labelless cart prints "please capture a label first" and still exports (a
|
|
291
|
+
* blank splash), surfaced as `labelWarning`. PICO-8 absent -> {@link pico8NotFound}.
|
|
292
|
+
*/
|
|
293
|
+
export(options: ExportOptions): Promise<Pico8ExportResult> {
|
|
294
|
+
const [file] = pico8Candidates(this.env) as [string, ...string[]];
|
|
295
|
+
const htmlName = options.htmlName ?? EXPORT_HTML_NAME;
|
|
296
|
+
const outPath = join(options.outDir, htmlName);
|
|
297
|
+
// `-export <name>.html` is a load-and-export command; `-x` makes it headless
|
|
298
|
+
// (run the cart, perform the export, exit) so no window/display is needed.
|
|
299
|
+
const args = ['-export', outPath, '-x', options.cartPath];
|
|
300
|
+
return this.orchestrate<Pico8ExportResult>(
|
|
301
|
+
file,
|
|
302
|
+
args,
|
|
303
|
+
// The export has no cooperative done-sentinel; `-x` exits on its own when
|
|
304
|
+
// finished, and the backstop is the net for a cart that hangs at boot.
|
|
305
|
+
{backstopMs: options.backstopMs},
|
|
306
|
+
(_exitReason, printh) => {
|
|
307
|
+
const files = collectExportFiles(options.outDir);
|
|
308
|
+
const htmlPath = files.find((p) => p.endsWith('.html'));
|
|
309
|
+
const jsPath = files.find((p) => p.endsWith('.js'));
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
value: {
|
|
313
|
+
htmlPath,
|
|
314
|
+
jsPath,
|
|
315
|
+
files,
|
|
316
|
+
// PICO-8 warns when the cart has no `__label__` to embed as the splash.
|
|
317
|
+
labelWarning: /please capture a label first/i.test(printh),
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
record(options: RecordOptions): Promise<Pico8RecordResult> {
|
|
325
|
+
const [file] = pico8Candidates(this.env) as [string, ...string[]];
|
|
326
|
+
const basename = options.wavBasename ?? RECORD_WAV_BASENAME;
|
|
327
|
+
// `-root_path <wavDir>` steers the `audio_end(1)`-to-current-folder WAV into
|
|
328
|
+
// the isolated temp dir (never ~/Desktop or the carts root). `-run` is a real
|
|
329
|
+
// A/V session (recording needs audio to capture; `-x` yields an empty WAV).
|
|
330
|
+
const args = ['-root_path', options.wavDir, '-run', options.cartPath];
|
|
331
|
+
return this.orchestrate<Pico8RecordResult>(
|
|
332
|
+
file,
|
|
333
|
+
args,
|
|
334
|
+
{sentinel: options.sentinel, backstopMs: options.backstopMs},
|
|
335
|
+
(exitReason, printh) => ({
|
|
336
|
+
ok: true,
|
|
337
|
+
value: {
|
|
338
|
+
wavPath: collectWav(options.wavDir, basename),
|
|
339
|
+
printh,
|
|
340
|
+
exitReason,
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The shared launch/sentinel-watch/backstop/collect loop behind both
|
|
348
|
+
* {@link run} and {@link record}. `buildOk` turns the exit reason + captured
|
|
349
|
+
* stdout into the command-specific success value; a spawn ENOENT with no
|
|
350
|
+
* fallback resolves the structured {@link pico8NotFound}. `printh` for `run` is
|
|
351
|
+
* filled by the caller from the run report path (kept out of `buildOk` there so
|
|
352
|
+
* the two report shapes stay distinct).
|
|
353
|
+
*/
|
|
354
|
+
private orchestrate<R extends {ok: boolean}>(
|
|
355
|
+
file: string,
|
|
356
|
+
args: string[],
|
|
357
|
+
opts: {sentinel?: string; backstopMs: number},
|
|
358
|
+
buildOk: (exitReason: ExitReason, printh: string) => R,
|
|
359
|
+
spawnOpts?: {stdin?: boolean; onSpawn?: (proc: Pico8Process) => void},
|
|
360
|
+
): Promise<R | ReturnType<typeof pico8NotFound>> {
|
|
361
|
+
const watcher = new SentinelWatcher(opts.sentinel);
|
|
362
|
+
|
|
363
|
+
return new Promise<R | ReturnType<typeof pico8NotFound>>((resolve) => {
|
|
364
|
+
let settled = false;
|
|
365
|
+
let backstop: NodeJS.Timeout | undefined;
|
|
366
|
+
const proc = this.spawnRun(file, withPico8Home(args), this.env, {
|
|
367
|
+
stdin: spawnOpts?.stdin,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const finish = (exitReason: ExitReason): void => {
|
|
371
|
+
if (settled) return;
|
|
372
|
+
settled = true;
|
|
373
|
+
if (backstop !== undefined) clearTimeout(backstop);
|
|
374
|
+
resolve(buildOk(exitReason, watcher.text));
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
proc.onError((err) => {
|
|
378
|
+
// A spawn ENOENT ⇒ PICO-8 is absent (a bad PICO8_PATH surfaces as absence
|
|
379
|
+
// with the same remedy, not a silent fall-through). Any other spawn error
|
|
380
|
+
// is a failed run, not a crash.
|
|
381
|
+
if (settled) return;
|
|
382
|
+
if (err.code === 'ENOENT') {
|
|
383
|
+
settled = true;
|
|
384
|
+
if (backstop !== undefined) clearTimeout(backstop);
|
|
385
|
+
resolve(pico8NotFound());
|
|
386
|
+
} else {
|
|
387
|
+
finish('exit');
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
proc.onStdout((chunk) => {
|
|
392
|
+
if (watcher.push(chunk) && !settled) {
|
|
393
|
+
proc.kill(); // sentinel matched: end the run promptly
|
|
394
|
+
finish('sentinel');
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
proc.onClose(() => finish('exit')); // PICO-8 exited on its own
|
|
399
|
+
|
|
400
|
+
// For `drive`: write the command blocks to the live stdin now that it is
|
|
401
|
+
// spawned. A spawn ENOENT resolves via onError before this matters.
|
|
402
|
+
spawnOpts?.onSpawn?.(proc);
|
|
403
|
+
|
|
404
|
+
// Backstop: a cart that neither signals nor exits gets killed.
|
|
405
|
+
backstop = setTimeout(() => {
|
|
406
|
+
if (settled) return;
|
|
407
|
+
proc.kill();
|
|
408
|
+
finish('timeout');
|
|
409
|
+
}, opts.backstopMs);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {mkdtempSync, rmSync} from 'node:fs';
|
|
2
|
+
import {tmpdir} from 'node:os';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
encodeFrame,
|
|
7
|
+
FrameDecoder,
|
|
8
|
+
SESSION_BASE_DIRNAME,
|
|
9
|
+
SessionRegistry,
|
|
10
|
+
type SessionRequest,
|
|
11
|
+
type SessionResponse,
|
|
12
|
+
} from './supervisor.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The session SUPERVISOR's pure pieces are CI-tested without a socket or binary
|
|
16
|
+
* (ADR-0011): the id/path registry (so a session can never escape its controlled
|
|
17
|
+
* base) and the newline-JSON frame codec (so the client<->daemon protocol is
|
|
18
|
+
* exact). The live daemon is the manual/opt-in tier.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
describe('SessionRegistry: id validation (no path traversal, controlled base)', () => {
|
|
22
|
+
it('accepts filesystem-safe ids', () => {
|
|
23
|
+
for (const id of ['run1', 's-abc123', 'A.b_c-1']) {
|
|
24
|
+
expect(SessionRegistry.validateId(id)).toBe(id);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects traversal + unsafe ids (structured throw)', () => {
|
|
29
|
+
for (const bad of ['..', '.', 'a/b', '../etc', 'a b', '', 'x'.repeat(65)]) {
|
|
30
|
+
expect(() => SessionRegistry.validateId(bad)).toThrow();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('fresh ids are unique + safe', () => {
|
|
35
|
+
const a = SessionRegistry.freshId();
|
|
36
|
+
const b = SessionRegistry.freshId();
|
|
37
|
+
expect(a).not.toBe(b);
|
|
38
|
+
expect(SessionRegistry.validateId(a)).toBe(a);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('SessionRegistry: path derivation under a controlled base', () => {
|
|
43
|
+
let base: string;
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
base = mkdtempSync(join(tmpdir(), 'pp-sess-'));
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => rmSync(base, {recursive: true, force: true}));
|
|
48
|
+
|
|
49
|
+
it('resolves a session id to its dir/socket/shots under the base', () => {
|
|
50
|
+
const reg = new SessionRegistry(base);
|
|
51
|
+
const p = reg.resolve('run1');
|
|
52
|
+
expect(p.dir).toBe(join(base, 'run1'));
|
|
53
|
+
expect(p.socket.startsWith(p.dir)).toBe(true);
|
|
54
|
+
expect(p.shotDir.startsWith(p.dir)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('a resolved socket/shotDir NEVER escapes the base (no traversal path)', () => {
|
|
58
|
+
const reg = new SessionRegistry(base);
|
|
59
|
+
expect(() => reg.resolve('../escape')).toThrow();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('create makes the dir + shotDir and list/remove round-trip', () => {
|
|
63
|
+
const reg = new SessionRegistry(base);
|
|
64
|
+
reg.create('a');
|
|
65
|
+
reg.create('b');
|
|
66
|
+
expect(reg.list()).toEqual(['a', 'b']);
|
|
67
|
+
reg.remove('a');
|
|
68
|
+
expect(reg.list()).toEqual(['b']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('the default base sits under tmp (never ~/Desktop or the carts root)', () => {
|
|
72
|
+
const reg = new SessionRegistry();
|
|
73
|
+
expect(reg.base).toBe(join(tmpdir(), SESSION_BASE_DIRNAME));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('the session frame codec (client <-> daemon protocol)', () => {
|
|
78
|
+
it('encodes a request as one newline-terminated JSON frame', () => {
|
|
79
|
+
const req: SessionRequest = {verb: 'step', frames: 30};
|
|
80
|
+
expect(encodeFrame(req)).toBe(`${JSON.stringify(req)}\n`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('decodes whole frames + buffers a partial one across chunks', () => {
|
|
84
|
+
const a: SessionResponse = {
|
|
85
|
+
ok: true,
|
|
86
|
+
value: {id: 'x', frame: 1, alive: true},
|
|
87
|
+
};
|
|
88
|
+
const b: SessionResponse = {ok: false, code: 'e', message: 'boom'};
|
|
89
|
+
const wire = encodeFrame(a) + encodeFrame(b);
|
|
90
|
+
|
|
91
|
+
// A single push of the whole stream yields both frames, in order.
|
|
92
|
+
expect(new FrameDecoder<SessionResponse>().push(wire)).toEqual([a, b]);
|
|
93
|
+
|
|
94
|
+
// A frame split across two chunks still decodes exactly once (no half frame).
|
|
95
|
+
const dec = new FrameDecoder<SessionResponse>();
|
|
96
|
+
const cut = Math.floor(wire.indexOf('\n') / 2); // mid the FIRST frame
|
|
97
|
+
expect(dec.push(wire.slice(0, cut))).toEqual([]); // no complete frame yet
|
|
98
|
+
expect(dec.push(wire.slice(cut))).toEqual([a, b]); // both complete now
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('ignores blank lines between frames', () => {
|
|
102
|
+
const dec = new FrameDecoder<SessionRequest>();
|
|
103
|
+
expect(dec.push('\n\n')).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|