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
package/src/commands/run.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import {execFileSync} from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
3
9
|
import {homedir, tmpdir} from 'node:os';
|
|
4
10
|
import {join} from 'node:path';
|
|
5
11
|
import {Cli} from 'incur';
|
|
@@ -11,7 +17,8 @@ import type {
|
|
|
11
17
|
RecordOptions,
|
|
12
18
|
RunOptions,
|
|
13
19
|
} from '../engine/pico8/index.js';
|
|
14
|
-
import {pico8NotFound} from '../engine/pico8/index.js';
|
|
20
|
+
import {displayUnavailable, pico8NotFound} from '../engine/pico8/index.js';
|
|
21
|
+
import {createShellAdapterFactory} from './pico8-env.js';
|
|
15
22
|
import {type Pico8AdapterFactory, registerRun} from './run.js';
|
|
16
23
|
|
|
17
24
|
/** A trivial valid cart the command reads (existence + a path to hand the adapter). */
|
|
@@ -47,6 +54,12 @@ function stubAdapter(
|
|
|
47
54
|
recordResult?: Pico8RecordResult,
|
|
48
55
|
): Pico8Adapter {
|
|
49
56
|
return {
|
|
57
|
+
async drive() {
|
|
58
|
+
throw new Error('drive not used');
|
|
59
|
+
},
|
|
60
|
+
async export() {
|
|
61
|
+
throw new Error('export not used');
|
|
62
|
+
},
|
|
50
63
|
async run(options) {
|
|
51
64
|
if (seen !== undefined) seen.options = options;
|
|
52
65
|
return result;
|
|
@@ -262,3 +275,104 @@ describe('picopilot run: cart-not-found (picopilot-side error, distinct from pic
|
|
|
262
275
|
expect(snap()).toEqual(before); // same entries: an absent dir stays absent, a present one unchanged
|
|
263
276
|
});
|
|
264
277
|
});
|
|
278
|
+
|
|
279
|
+
describe('picopilot run --record-audio: an #include cart produces a loadable harness', () => {
|
|
280
|
+
it('inlines the cart includes into the harness it hands the record pass', async () => {
|
|
281
|
+
writeFileSync(
|
|
282
|
+
cartPath,
|
|
283
|
+
'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\n#include main.lua\n',
|
|
284
|
+
);
|
|
285
|
+
writeFileSync(
|
|
286
|
+
join(dir, 'main.lua'),
|
|
287
|
+
'function _update() hello_from_main=1 end\n',
|
|
288
|
+
);
|
|
289
|
+
const seen: {recordOptions?: RecordOptions} = {};
|
|
290
|
+
const {exitCode} = await runRun(
|
|
291
|
+
() => stubAdapter(ranWith('sentinel'), seen),
|
|
292
|
+
['run', cartPath, '--record-audio', '--json'],
|
|
293
|
+
);
|
|
294
|
+
expect(exitCode).toBe(0);
|
|
295
|
+
const harness = readFileSync(seen.recordOptions!.cartPath, 'utf8');
|
|
296
|
+
expect(harness).not.toMatch(/#include/);
|
|
297
|
+
expect(harness).toContain('hello_from_main=1');
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('picopilot run --record-audio: failures are explained, never silent', () => {
|
|
302
|
+
it('surfaces noAudioReason + pico8LogTail when no WAV was captured', async () => {
|
|
303
|
+
const {stdout} = await runRun(
|
|
304
|
+
() =>
|
|
305
|
+
stubAdapter(ranWith('sentinel'), undefined, {
|
|
306
|
+
ok: true,
|
|
307
|
+
value: {
|
|
308
|
+
wavPath: undefined,
|
|
309
|
+
printh: '',
|
|
310
|
+
exitReason: 'timeout',
|
|
311
|
+
noWavReason: 'the backstop fired before the done-sentinel',
|
|
312
|
+
pico8LogTail: 'syntax error',
|
|
313
|
+
},
|
|
314
|
+
}),
|
|
315
|
+
['run', cartPath, '--record-audio', '--json'],
|
|
316
|
+
);
|
|
317
|
+
const out = JSON.parse(stdout);
|
|
318
|
+
expect(out.audioCaptured).toBe(false);
|
|
319
|
+
expect(out.noAudioReason).toMatch(/backstop/);
|
|
320
|
+
expect(out.pico8LogTail).toBe('syntax error');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('display-unavailable is a structured error whose CTA names the fix', async () => {
|
|
324
|
+
const {stdout, exitCode} = await runRun(
|
|
325
|
+
() =>
|
|
326
|
+
stubAdapter(
|
|
327
|
+
ranWith('sentinel'),
|
|
328
|
+
undefined,
|
|
329
|
+
displayUnavailable('no DISPLAY and no Xvfb'),
|
|
330
|
+
),
|
|
331
|
+
['run', cartPath, '--record-audio', '--json', '--full-output'],
|
|
332
|
+
);
|
|
333
|
+
expect(exitCode).not.toBe(0);
|
|
334
|
+
const out = JSON.parse(stdout);
|
|
335
|
+
expect(out.error.code).toBe('display-unavailable');
|
|
336
|
+
expect(out.error.message).toMatch(/Xvfb/);
|
|
337
|
+
expect(JSON.stringify(out.meta?.cta ?? out.cta)).toMatch(/install Xvfb/);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe('picopilot run: the PICO-8 child inherits the parent environment', () => {
|
|
342
|
+
it('DISPLAY, HOME and XDG_RUNTIME_DIR from the parent reach the spawn; PICO8_PATH comes from the resolved env', async () => {
|
|
343
|
+
let spawnedEnv: NodeJS.ProcessEnv | undefined;
|
|
344
|
+
const factory = createShellAdapterFactory({
|
|
345
|
+
parentEnv: {
|
|
346
|
+
DISPLAY: ':5',
|
|
347
|
+
HOME: '/home/agent',
|
|
348
|
+
XDG_RUNTIME_DIR: '/run/user/1000',
|
|
349
|
+
SDL_AUDIODRIVER: 'pulseaudio',
|
|
350
|
+
PICO8_PATH: '/parent/pico8',
|
|
351
|
+
},
|
|
352
|
+
spawn: (_file, _args, env) => {
|
|
353
|
+
spawnedEnv = env;
|
|
354
|
+
return {
|
|
355
|
+
onStdout(cb) {
|
|
356
|
+
setImmediate(() => cb('__PICOPILOT_DONE__\n'));
|
|
357
|
+
},
|
|
358
|
+
onClose() {},
|
|
359
|
+
onError() {},
|
|
360
|
+
kill() {},
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
const {exitCode} = await runRun(factory, ['run', cartPath, '--json'], {
|
|
365
|
+
PICO8_PATH: '/resolved/pico8',
|
|
366
|
+
PATH: '/usr/bin',
|
|
367
|
+
});
|
|
368
|
+
expect(exitCode).toBe(0);
|
|
369
|
+
expect(spawnedEnv).toMatchObject({
|
|
370
|
+
DISPLAY: ':5',
|
|
371
|
+
HOME: '/home/agent',
|
|
372
|
+
XDG_RUNTIME_DIR: '/run/user/1000',
|
|
373
|
+
SDL_AUDIODRIVER: 'pulseaudio',
|
|
374
|
+
PICO8_PATH: '/resolved/pico8',
|
|
375
|
+
PATH: '/usr/bin',
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
});
|
package/src/commands/run.ts
CHANGED
|
@@ -12,20 +12,25 @@ import {
|
|
|
12
12
|
buildRecordHarness,
|
|
13
13
|
DONE_SENTINEL,
|
|
14
14
|
HarnessError,
|
|
15
|
-
type Pico8Adapter,
|
|
16
|
-
ShellPico8Adapter,
|
|
17
15
|
} from '../engine/pico8/index.js';
|
|
16
|
+
import {makeReadInclude} from './cart-includes.js';
|
|
17
|
+
import {
|
|
18
|
+
createShellAdapterFactory,
|
|
19
|
+
displayModeOf,
|
|
20
|
+
displayUnavailableError,
|
|
21
|
+
PICO8_ENV,
|
|
22
|
+
pico8NotFoundError,
|
|
23
|
+
type Pico8AdapterFactory,
|
|
24
|
+
} from './pico8-env.js';
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
27
|
* Injects the PICO-8 adapter `run` uses (defaults to the shell adapter over
|
|
21
28
|
* 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
|
|
23
|
-
* to isolate the child `env` (its `PICO8_PATH`/`PATH`).
|
|
29
|
+
* stub to drive the sentinel/backstop/absent paths without the paid binary.
|
|
24
30
|
*/
|
|
25
|
-
export type Pico8AdapterFactory
|
|
31
|
+
export type {Pico8AdapterFactory} from './pico8-env.js';
|
|
26
32
|
|
|
27
|
-
const defaultAdapterFactory: Pico8AdapterFactory = (
|
|
28
|
-
new ShellPico8Adapter({env});
|
|
33
|
+
const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
|
|
29
34
|
|
|
30
35
|
/** The default backstop: kill a cart that never signals/exits after this many ms. */
|
|
31
36
|
const DEFAULT_BACKSTOP_MS = 15_000;
|
|
@@ -95,18 +100,12 @@ export function registerRun(
|
|
|
95
100
|
.boolean()
|
|
96
101
|
.default(false)
|
|
97
102
|
.describe(
|
|
98
|
-
"Also capture a WAV of the run's audio alongside the screenshots (ADR-0009). A REAL-TIME recording
|
|
103
|
+
"Also capture a WAV of the run's audio alongside the screenshots (ADR-0009). A REAL-TIME recording in a separate -run pass (not the headless screenshot pass), never an offline export. Works headless: with no display, picopilot runs that pass on a private virtual display (ADR-0017, --display).",
|
|
99
104
|
),
|
|
100
105
|
}),
|
|
101
|
-
// incur resolves these from the (test-overridable) env source
|
|
102
|
-
//
|
|
103
|
-
env:
|
|
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
|
-
}),
|
|
106
|
+
// incur resolves these from the (test-overridable) env source; the child
|
|
107
|
+
// gets the parent env with them overlaid (DISPLAY, HOME, ... pass through).
|
|
108
|
+
env: PICO8_ENV,
|
|
110
109
|
output: z.object({
|
|
111
110
|
screenshots: z
|
|
112
111
|
.array(z.string())
|
|
@@ -131,6 +130,24 @@ export function registerRun(
|
|
|
131
130
|
.describe(
|
|
132
131
|
'Whether a non-empty WAV was captured (false unless --record-audio ran and produced audio).',
|
|
133
132
|
),
|
|
133
|
+
audioDisplay: z
|
|
134
|
+
.enum(['native', 'Xvfb', 'xvfb-run'])
|
|
135
|
+
.optional()
|
|
136
|
+
.describe(
|
|
137
|
+
'With --record-audio: which display the -run pass used (native, or a private virtual display).',
|
|
138
|
+
),
|
|
139
|
+
noAudioReason: z
|
|
140
|
+
.string()
|
|
141
|
+
.optional()
|
|
142
|
+
.describe(
|
|
143
|
+
'With --record-audio and no WAV: why (backstop fired / PICO-8 exited before the done-sentinel / empty capture).',
|
|
144
|
+
),
|
|
145
|
+
pico8LogTail: z
|
|
146
|
+
.string()
|
|
147
|
+
.optional()
|
|
148
|
+
.describe(
|
|
149
|
+
"With --record-audio and no WAV: the tail of PICO-8's own log.txt (load errors, SDL init failures).",
|
|
150
|
+
),
|
|
134
151
|
}),
|
|
135
152
|
examples: [
|
|
136
153
|
{description: 'Run main.p8'},
|
|
@@ -141,7 +158,7 @@ export function registerRun(
|
|
|
141
158
|
options: {shotDir: './shots'},
|
|
142
159
|
},
|
|
143
160
|
],
|
|
144
|
-
async run({args, options, env, error, ok}) {
|
|
161
|
+
async run({args, options, env, error, ok, var: vars}) {
|
|
145
162
|
const cartPath = isAbsolute(args.cart)
|
|
146
163
|
? args.cart
|
|
147
164
|
: resolve(process.cwd(), args.cart);
|
|
@@ -166,7 +183,9 @@ export function registerRun(
|
|
|
166
183
|
: mkdtempSync(join(tmpdir(), 'picopilot-run-'));
|
|
167
184
|
if (!existsSync(shotDir)) mkdirSync(shotDir, {recursive: true});
|
|
168
185
|
|
|
169
|
-
const adapter = adapterFactory(env as NodeJS.ProcessEnv
|
|
186
|
+
const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
|
|
187
|
+
display: displayModeOf(vars),
|
|
188
|
+
});
|
|
170
189
|
const result = await adapter.run({
|
|
171
190
|
cartPath,
|
|
172
191
|
shotDir,
|
|
@@ -194,10 +213,16 @@ export function registerRun(
|
|
|
194
213
|
// uses. The WAV lands in the isolated shotDir alongside the screenshots.
|
|
195
214
|
let wav: string | undefined;
|
|
196
215
|
let audioCaptured = false;
|
|
216
|
+
let noAudioReason: string | undefined;
|
|
217
|
+
let pico8LogTail: string | undefined;
|
|
218
|
+
let audioDisplay: 'native' | 'Xvfb' | 'xvfb-run' | undefined;
|
|
197
219
|
if (options.recordAudio) {
|
|
198
220
|
try {
|
|
221
|
+
// `#include`s are inlined (resolved against the SOURCE cart's folder):
|
|
222
|
+
// the harness runs from shotDir, where `main.lua` does not exist.
|
|
199
223
|
const harness = buildRecordHarness(readFileSync(cartPath, 'utf8'), {
|
|
200
224
|
sentinel: options.sentinel,
|
|
225
|
+
readInclude: makeReadInclude(cartPath),
|
|
201
226
|
});
|
|
202
227
|
const harnessCart = join(shotDir, 'record-audio-harness.p8');
|
|
203
228
|
writeFileSync(harnessCart, harness.cartText);
|
|
@@ -208,16 +233,26 @@ export function registerRun(
|
|
|
208
233
|
sentinel: options.sentinel,
|
|
209
234
|
backstopMs: options.backstopMs,
|
|
210
235
|
});
|
|
211
|
-
if (rec.ok) {
|
|
212
|
-
|
|
213
|
-
|
|
236
|
+
if (!rec.ok) {
|
|
237
|
+
// pico8-not-found is impossible here (the screenshot pass above would
|
|
238
|
+
// have returned it); display-unavailable is the real case: the -run
|
|
239
|
+
// pass needs a display, there is none, and no Xvfb to make one.
|
|
240
|
+
return error(
|
|
241
|
+
rec.reason === 'display-unavailable'
|
|
242
|
+
? displayUnavailableError('run --record-audio', rec)
|
|
243
|
+
: pico8NotFoundError(rec),
|
|
244
|
+
);
|
|
214
245
|
}
|
|
215
|
-
|
|
216
|
-
|
|
246
|
+
wav = rec.value.wavPath;
|
|
247
|
+
audioCaptured = rec.value.wavPath !== undefined;
|
|
248
|
+
noAudioReason = rec.value.noWavReason;
|
|
249
|
+
pico8LogTail = rec.value.pico8LogTail;
|
|
250
|
+
audioDisplay = rec.value.display;
|
|
217
251
|
} catch (e) {
|
|
218
252
|
if (!(e instanceof HarnessError)) throw e;
|
|
219
253
|
// A harness-build failure (unparseable cart) leaves audioCaptured false;
|
|
220
254
|
// the screenshot run's report still stands.
|
|
255
|
+
noAudioReason = `the record harness could not be built: ${e.message}`;
|
|
221
256
|
}
|
|
222
257
|
}
|
|
223
258
|
|
|
@@ -277,6 +312,9 @@ export function registerRun(
|
|
|
277
312
|
shotDir,
|
|
278
313
|
wav,
|
|
279
314
|
audioCaptured,
|
|
315
|
+
...(audioDisplay !== undefined ? {audioDisplay} : {}),
|
|
316
|
+
...(noAudioReason !== undefined ? {noAudioReason} : {}),
|
|
317
|
+
...(pico8LogTail !== undefined ? {pico8LogTail} : {}),
|
|
280
318
|
},
|
|
281
319
|
cta === undefined ? undefined : {cta},
|
|
282
320
|
);
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import {execFileSync} from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
3
9
|
import {tmpdir} from 'node:os';
|
|
4
10
|
import {join} from 'node:path';
|
|
5
11
|
import {Cli} from 'incur';
|
|
6
12
|
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
13
|
+
import {encodePng} from '../engine/gfx/index.js';
|
|
7
14
|
import type {
|
|
15
|
+
DriveOptions,
|
|
8
16
|
ExportOptions,
|
|
9
17
|
Pico8Adapter,
|
|
18
|
+
Pico8DriveResult,
|
|
10
19
|
Pico8ExportResult,
|
|
11
20
|
} from '../engine/pico8/index.js';
|
|
12
21
|
import {pico8NotFound} from '../engine/pico8/index.js';
|
|
@@ -211,7 +220,12 @@ describe('picopilot serve: failure paths', () => {
|
|
|
211
220
|
const lower = stdout.toLowerCase();
|
|
212
221
|
expect(lower).toContain('picopilot run');
|
|
213
222
|
expect(lower).toContain('playtest');
|
|
214
|
-
|
|
223
|
+
// The remedy must be one that WORKS: `export --label` never writes the label
|
|
224
|
+
// into the cart, so "export --label, then re-serve" would fail again. serve
|
|
225
|
+
// takes the label itself.
|
|
226
|
+
expect(lower).toMatch(/picopilot serve [^`]*--label/);
|
|
227
|
+
expect(lower).toContain('--label-frame 0');
|
|
228
|
+
expect(lower).not.toContain('then re-serve');
|
|
215
229
|
expect(lower).toContain('do not hand-edit main.p8');
|
|
216
230
|
});
|
|
217
231
|
|
|
@@ -225,3 +239,125 @@ describe('picopilot serve: failure paths', () => {
|
|
|
225
239
|
expect(stdout).toContain('no cart at');
|
|
226
240
|
});
|
|
227
241
|
});
|
|
242
|
+
|
|
243
|
+
describe('picopilot serve: --label (bake a screenshot in for a labelless cart)', () => {
|
|
244
|
+
const shotPath = new URL(
|
|
245
|
+
'../engine/gfx/fixtures/pico8-screenshot-384.png',
|
|
246
|
+
import.meta.url,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
it('exports a labeled COPY (the screenshot as-is), leaves the cart untouched, and cleans the copy up', async () => {
|
|
250
|
+
const labelFile = join(dir, 'shot.png');
|
|
251
|
+
writeFileSync(labelFile, readFileSync(shotPath));
|
|
252
|
+
let exportedCart = '';
|
|
253
|
+
const exportSeen: {options?: ExportOptions} = {};
|
|
254
|
+
const {exitCode} = await runServe(
|
|
255
|
+
() => {
|
|
256
|
+
const inner = stubAdapter('writes-bundle', exportSeen);
|
|
257
|
+
return {
|
|
258
|
+
...inner,
|
|
259
|
+
export: async (o: ExportOptions) => {
|
|
260
|
+
exportedCart = readFileSync(o.cartPath, 'utf8');
|
|
261
|
+
return inner.export(o);
|
|
262
|
+
},
|
|
263
|
+
} as Pico8Adapter;
|
|
264
|
+
},
|
|
265
|
+
fakeServer(),
|
|
266
|
+
['serve', cartPath, '--label', labelFile, '--json'],
|
|
267
|
+
);
|
|
268
|
+
expect(exitCode).toBe(0);
|
|
269
|
+
expect(exportSeen.options!.cartPath).not.toBe(cartPath);
|
|
270
|
+
const label = exportedCart.split('__label__\n')[1]!.split('\n');
|
|
271
|
+
expect(label[10]![10]).toBe('8');
|
|
272
|
+
expect(label[100]![100]).toBe('c');
|
|
273
|
+
expect(readFileSync(cartPath, 'utf8')).not.toContain('__label__');
|
|
274
|
+
// Only the cart and the label image remain: the labeled copy is gone.
|
|
275
|
+
expect(readdirSync(dir).sort()).toEqual(['main.p8', 'shot.png']);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('removes the labeled copy even when the export step throws', async () => {
|
|
279
|
+
const labelFile = join(dir, 'shot.png');
|
|
280
|
+
writeFileSync(labelFile, readFileSync(shotPath));
|
|
281
|
+
await runServe(
|
|
282
|
+
() =>
|
|
283
|
+
({
|
|
284
|
+
...stubAdapter('writes-bundle'),
|
|
285
|
+
export: async () => {
|
|
286
|
+
throw new Error('boom');
|
|
287
|
+
},
|
|
288
|
+
}) as Pico8Adapter,
|
|
289
|
+
fakeServer(),
|
|
290
|
+
['serve', cartPath, '--label', labelFile, '--json'],
|
|
291
|
+
).catch(() => undefined);
|
|
292
|
+
expect(readdirSync(dir).sort()).toEqual(['main.p8', 'shot.png']);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('surfaces a bad label image as label-wrong-size with the screenshot CTA, before exporting', async () => {
|
|
296
|
+
const labelFile = join(dir, 'odd.png');
|
|
297
|
+
writeFileSync(
|
|
298
|
+
labelFile,
|
|
299
|
+
encodePng({width: 200, height: 200, rgb: new Uint8Array(200 * 200 * 3)}),
|
|
300
|
+
);
|
|
301
|
+
const exportSeen: {options?: ExportOptions} = {};
|
|
302
|
+
const {stdout, exitCode} = await runServe(
|
|
303
|
+
() => stubAdapter('writes-bundle', exportSeen),
|
|
304
|
+
fakeServer(),
|
|
305
|
+
['serve', cartPath, '--label', labelFile, '--json'],
|
|
306
|
+
);
|
|
307
|
+
expect(exitCode).not.toBe(0);
|
|
308
|
+
expect(stdout).toContain('label-wrong-size');
|
|
309
|
+
expect(stdout).toContain('scaled for you');
|
|
310
|
+
expect(exportSeen.options).toBeUndefined();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('picopilot serve: --label-frame (capture the label from the game)', () => {
|
|
315
|
+
it('captures frame N with no input and serves the export of a labeled copy', async () => {
|
|
316
|
+
let drive: DriveOptions | undefined;
|
|
317
|
+
let exportedCart = '';
|
|
318
|
+
const {exitCode} = await runServe(
|
|
319
|
+
() => ({
|
|
320
|
+
...stubAdapter('writes-bundle'),
|
|
321
|
+
async drive(o: DriveOptions): Promise<Pico8DriveResult> {
|
|
322
|
+
drive = o;
|
|
323
|
+
const shot = join(o.shotDir, 'label0.png');
|
|
324
|
+
writeFileSync(
|
|
325
|
+
shot,
|
|
326
|
+
readFileSync(
|
|
327
|
+
new URL(
|
|
328
|
+
'../engine/gfx/fixtures/pico8-screenshot-384.png',
|
|
329
|
+
import.meta.url,
|
|
330
|
+
),
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
return {
|
|
334
|
+
ok: true,
|
|
335
|
+
value: {screenshots: [shot], printh: '', exitReason: 'sentinel'},
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
async export(o: ExportOptions) {
|
|
339
|
+
exportedCart = readFileSync(o.cartPath, 'utf8');
|
|
340
|
+
return stubAdapter('writes-bundle').export(o);
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
fakeServer(),
|
|
344
|
+
['serve', cartPath, '--label-frame', '2', '--json'],
|
|
345
|
+
);
|
|
346
|
+
expect(exitCode).toBe(0);
|
|
347
|
+
expect(drive).toBeDefined();
|
|
348
|
+
expect(exportedCart.split('__label__\n')[1]!.split('\n')[10]![10]).toBe(
|
|
349
|
+
'8',
|
|
350
|
+
);
|
|
351
|
+
expect(readdirSync(dir)).toEqual(['main.p8']);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('rejects --label with --label-frame', async () => {
|
|
355
|
+
const {stdout, exitCode} = await runServe(
|
|
356
|
+
() => stubAdapter('writes-bundle'),
|
|
357
|
+
fakeServer(),
|
|
358
|
+
['serve', cartPath, '--label', 'x.png', '--label-frame', '1', '--json'],
|
|
359
|
+
);
|
|
360
|
+
expect(exitCode).not.toBe(0);
|
|
361
|
+
expect(stdout).toContain('label-flags-conflict');
|
|
362
|
+
});
|
|
363
|
+
});
|
package/src/commands/serve.ts
CHANGED
|
@@ -12,18 +12,23 @@ import {type Cli, z} from 'incur';
|
|
|
12
12
|
import {
|
|
13
13
|
EXPORT_HTML_NAME,
|
|
14
14
|
type Pico8Adapter,
|
|
15
|
-
|
|
15
|
+
type Pico8ExportResult,
|
|
16
16
|
} from '../engine/pico8/index.js';
|
|
17
|
+
import {
|
|
18
|
+
createShellAdapterFactory,
|
|
19
|
+
PICO8_ENV,
|
|
20
|
+
type Pico8AdapterFactory,
|
|
21
|
+
} from './pico8-env.js';
|
|
22
|
+
import {prepareLabeledCart} from './label-cart.js';
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
25
|
* Injects the PICO-8 adapter `serve` uses (defaults to the shell adapter). Same
|
|
20
26
|
* seam as `export`/`run`: a test passes a stub so the export step needs no paid
|
|
21
27
|
* binary, and the child `env` (`PICO8_PATH`/`PATH`) stays isolated.
|
|
22
28
|
*/
|
|
23
|
-
export type Pico8AdapterFactory
|
|
29
|
+
export type {Pico8AdapterFactory} from './pico8-env.js';
|
|
24
30
|
|
|
25
|
-
const defaultAdapterFactory: Pico8AdapterFactory = (
|
|
26
|
-
new ShellPico8Adapter({env});
|
|
31
|
+
const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
|
|
27
32
|
|
|
28
33
|
/**
|
|
29
34
|
* Injects the HTTP-server factory `serve` binds. The default is a zero-dep
|
|
@@ -174,6 +179,20 @@ export function registerServe(
|
|
|
174
179
|
),
|
|
175
180
|
}),
|
|
176
181
|
options: z.object({
|
|
182
|
+
label: z
|
|
183
|
+
.string()
|
|
184
|
+
.optional()
|
|
185
|
+
.describe(
|
|
186
|
+
'PNG to bake in as the __label__ (the export splash) for a cart that has none: a `run`/`playtest` screenshot as-is, or any 128x128 PNG. The cart itself is not modified. Ignored if the cart already has a label.',
|
|
187
|
+
),
|
|
188
|
+
labelFrame: z
|
|
189
|
+
.number()
|
|
190
|
+
.int()
|
|
191
|
+
.min(0)
|
|
192
|
+
.optional()
|
|
193
|
+
.describe(
|
|
194
|
+
'Capture the label from the game itself: run the cart headlessly with NO input and use frame N (0-based: 0 = the first drawn frame, usually the title screen) as the __label__. Needs PICO-8; the cart need not cooperate. Exclusive with --label; ignored if the cart already has a label.',
|
|
195
|
+
),
|
|
177
196
|
port: z
|
|
178
197
|
.number()
|
|
179
198
|
.int()
|
|
@@ -186,15 +205,11 @@ export function registerServe(
|
|
|
186
205
|
.int()
|
|
187
206
|
.positive()
|
|
188
207
|
.default(DEFAULT_BACKSTOP_MS)
|
|
189
|
-
.describe(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
PICO8_PATH: z
|
|
193
|
-
.string()
|
|
194
|
-
.optional()
|
|
195
|
-
.describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
|
|
196
|
-
PATH: z.string().optional().describe('Used to locate `pico8`.'),
|
|
208
|
+
.describe(
|
|
209
|
+
'Safety backstop for the export step (and the --label-frame capture run).',
|
|
210
|
+
),
|
|
197
211
|
}),
|
|
212
|
+
env: PICO8_ENV,
|
|
198
213
|
output: z.object({
|
|
199
214
|
url: z.string().describe('The local URL to open in a browser.'),
|
|
200
215
|
port: z.number().describe('The port the server bound to.'),
|
|
@@ -210,6 +225,18 @@ export function registerServe(
|
|
|
210
225
|
args: {cart: 'game.p8'},
|
|
211
226
|
options: {port: 8080},
|
|
212
227
|
},
|
|
228
|
+
{
|
|
229
|
+
description:
|
|
230
|
+
'Serve a labelless cart, using a `run` screenshot as the splash',
|
|
231
|
+
args: {cart: 'game.p8'},
|
|
232
|
+
options: {label: './shots/<name>.png'},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
description:
|
|
236
|
+
'Serve a labelless cart, using its title screen (frame 0) as the splash',
|
|
237
|
+
args: {cart: 'game.p8'},
|
|
238
|
+
options: {labelFrame: 0},
|
|
239
|
+
},
|
|
213
240
|
],
|
|
214
241
|
async run({args, options, env, error, ok}) {
|
|
215
242
|
const cartPath = isAbsolute(args.cart)
|
|
@@ -224,16 +251,48 @@ export function registerServe(
|
|
|
224
251
|
});
|
|
225
252
|
}
|
|
226
253
|
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
254
|
+
// --label / --label-frame: export a labeled throwaway copy (shared with
|
|
255
|
+
// `export`; the cart is never mutated). A bad image or a failed capture
|
|
256
|
+
// fails before anything is exported.
|
|
230
257
|
const adapter = adapterFactory(env as NodeJS.ProcessEnv);
|
|
231
|
-
const
|
|
258
|
+
const labeled = await prepareLabeledCart(
|
|
232
259
|
cartPath,
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
260
|
+
{
|
|
261
|
+
label: options.label,
|
|
262
|
+
labelFrame: options.labelFrame,
|
|
263
|
+
backstopMs: options.backstopMs,
|
|
264
|
+
},
|
|
265
|
+
(o) => adapter.drive(o),
|
|
266
|
+
);
|
|
267
|
+
if (!labeled.ok) {
|
|
268
|
+
return error({
|
|
269
|
+
code: labeled.code,
|
|
270
|
+
message: labeled.message,
|
|
271
|
+
exitCode: 1,
|
|
272
|
+
...(labeled.cta ? {cta: labeled.cta} : {}),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Always export first, into an isolated temp dir (never a user path or
|
|
277
|
+
// ~/Desktop). The bundle is the standalone pair the static server plays.
|
|
278
|
+
// The labeled copy (in the user's cart dir) is removed on every path; a
|
|
279
|
+
// throw also reaps the temp dir, since nothing will be served from it.
|
|
280
|
+
let result: Pico8ExportResult;
|
|
281
|
+
let serveDir: string | undefined;
|
|
282
|
+
try {
|
|
283
|
+
serveDir = mkdtempSync(join(tmpdir(), 'picopilot-serve-'));
|
|
284
|
+
result = await adapter.export({
|
|
285
|
+
cartPath: labeled.cartPath,
|
|
286
|
+
outDir: serveDir,
|
|
287
|
+
htmlName: EXPORT_HTML_NAME,
|
|
288
|
+
backstopMs: options.backstopMs,
|
|
289
|
+
});
|
|
290
|
+
} catch (e) {
|
|
291
|
+
if (serveDir !== undefined) removeDir(serveDir);
|
|
292
|
+
throw e;
|
|
293
|
+
} finally {
|
|
294
|
+
labeled.cleanup();
|
|
295
|
+
}
|
|
237
296
|
|
|
238
297
|
if (!result.ok) {
|
|
239
298
|
// Nothing is being served: remove the temp dir now, not on exit.
|
|
@@ -250,7 +309,7 @@ export function registerServe(
|
|
|
250
309
|
return error({
|
|
251
310
|
code: 'export-failed',
|
|
252
311
|
message: result.value.labelWarning
|
|
253
|
-
? 'PICO-8 produced no bundle: this cart has no __label__ (the export splash), which the browser export needs. To just SEE your own build work, use `picopilot run` or `picopilot playtest` (no label needed) instead of serve. If you genuinely want a browser build to SHARE,
|
|
312
|
+
? 'PICO-8 produced no bundle: this cart has no __label__ (the export splash), which the browser export needs. To just SEE your own build work, use `picopilot run` or `picopilot playtest` (no label needed) instead of serve. If you genuinely want a browser build to SHARE, give it a splash: `picopilot serve <cart> --label-frame 0` captures the title screen for you, or `picopilot serve <cart> --label <screenshot.png>` takes any path `run`/`playtest` lists under `screenshots` as-is (do NOT hand-edit main.p8).'
|
|
254
313
|
: 'PICO-8 produced no export bundle. Check the cart runs without a boot error (via `picopilot run`).',
|
|
255
314
|
exitCode: 1,
|
|
256
315
|
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
|
2
|
+
import {tmpdir} from 'node:os';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {Cli} from 'incur';
|
|
5
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
6
|
+
import type {ChildRunner} from '../engine/shrinko/index.js';
|
|
7
|
+
import {createShellShrinkoFactory} from './shrinko-env.js';
|
|
8
|
+
import {registerTokens} from './tokens.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* shrinko8 is a Python tool: a `pip install --user` copy is found through HOME
|
|
12
|
+
* (user site-packages), so the child must inherit the parent environment, with
|
|
13
|
+
* the command's resolved PATH overlaid (the same rule as the PICO-8 child,
|
|
14
|
+
* ADR-0017).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
let dir: string;
|
|
18
|
+
let cartPath: string;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
dir = mkdtempSync(join(tmpdir(), 'picopilot-shrinko-env-'));
|
|
21
|
+
cartPath = join(dir, 'main.p8');
|
|
22
|
+
writeFileSync(
|
|
23
|
+
cartPath,
|
|
24
|
+
'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => rmSync(dir, {recursive: true, force: true}));
|
|
28
|
+
|
|
29
|
+
describe('shrinko child env', () => {
|
|
30
|
+
it('HOME and the rest of the parent env reach shrinko; PATH comes from the resolved env', async () => {
|
|
31
|
+
let seen: NodeJS.ProcessEnv | undefined;
|
|
32
|
+
const run: ChildRunner = async (_file, _args, env) => {
|
|
33
|
+
seen = env;
|
|
34
|
+
return {
|
|
35
|
+
code: 0,
|
|
36
|
+
stdout: 'tokens: 3 0%\nchars: 11 0%\ncompressed: 20 0%\n',
|
|
37
|
+
stderr: '',
|
|
38
|
+
spawnError: undefined,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
const cli = Cli.create('picopilot', {version: '0.0.0'});
|
|
42
|
+
registerTokens(
|
|
43
|
+
cli,
|
|
44
|
+
createShellShrinkoFactory({
|
|
45
|
+
parentEnv: {
|
|
46
|
+
HOME: '/home/agent',
|
|
47
|
+
PYTHONUSERBASE: '/home/agent/.local',
|
|
48
|
+
PATH: '/parent/bin',
|
|
49
|
+
},
|
|
50
|
+
run,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
let stdout = '';
|
|
54
|
+
await cli.serve(['tokens', cartPath, '--json'], {
|
|
55
|
+
stdout(s) {
|
|
56
|
+
stdout += s;
|
|
57
|
+
},
|
|
58
|
+
exit() {},
|
|
59
|
+
env: {PATH: '/resolved/bin'},
|
|
60
|
+
});
|
|
61
|
+
expect(JSON.parse(stdout).tokens).toBe(3);
|
|
62
|
+
expect(seen).toMatchObject({
|
|
63
|
+
HOME: '/home/agent',
|
|
64
|
+
PYTHONUSERBASE: '/home/agent/.local',
|
|
65
|
+
PATH: '/resolved/bin',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|