hardening and bugfixing prior to stable release

This commit is contained in:
Benjamin Diedrichsen
2026-07-31 18:21:43 +02:00
parent ac7ea07e3c
commit 0aa0be5542
44 changed files with 3016 additions and 436 deletions
+63
View File
@@ -200,4 +200,67 @@ describe('Variables secrets', () => {
expect(variables.persistable('cube-a')).toEqual({});
expect(variables.persistable('cube-b')).toEqual({ PASSWORD: 'b' });
});
it('excludes a declared secret from the env a session records', () => {
const variables = new Variables({ PASSWORD: 'hunter2', KEY_DIR: './vault' }, ['PASSWORD']);
expect(variables.persistableEnv()).toEqual({ KEY_DIR: './vault' });
});
it('records an env with no secrets in it whole', () => {
const variables = new Variables({ KEY_DIR: './vault' }, ['PASSWORD']);
expect(variables.persistableEnv()).toEqual({ KEY_DIR: './vault' });
});
});
describe('Variables globally declared secrets', () => {
/** `env` carrying a key that cube-a declares secret and cube-b knows nothing of. */
const withLeakyEnv = () => {
const variables = new Variables({ PASSWORD: 'wildpass123', KEY_DIR: '/vault' }, ['PASSWORD']);
variables.declareSecrets('cube-a', ['PASSWORD']);
variables.declareSchema('cube-a', ['USER', 'PASSWORD']);
variables.declareSchema('cube-b', ['PORT']);
return variables;
};
it('does not seed a secret onto a cube that does not declare it', () => {
const variables = withLeakyEnv();
variables.assign('cube-b', 'default', { PORT: 22 });
expect(variables.get('cube-b')).not.toHaveProperty('PASSWORD');
expect(variables.of('cube-b', 'PASSWORD')).toBeUndefined();
});
it('still seeds it onto a cube whose schema declares it', () => {
const variables = withLeakyEnv();
variables.assign('cube-a', 'default', {});
expect(variables.get('cube-a').PASSWORD).toBe('wildpass123');
expect(variables.of('cube-a', 'PASSWORD')?.origin).toBe('env');
expect(variables.persistable('cube-a')).not.toHaveProperty('PASSWORD');
});
it('keeps broadcasting an undeclared key that is not a secret', () => {
// ssh:keyman reads KEY_DIR off host.data without declaring it in its schema.
const variables = withLeakyEnv();
variables.assign('cube-b', 'default', {});
expect(variables.get('cube-b').KEY_DIR).toBe('/vault');
});
it('redacts a global secret on a cube whose own manifest forgot to list it', () => {
const variables = new Variables({}, ['PASSWORD']);
variables.assign('cube-b', 'prompt', { PASSWORD: 'typed' });
expect(variables.of('cube-b', 'PASSWORD')?.redacted).toBe(true);
expect(variables.persistable('cube-b')).toEqual({});
});
it('treats a cube that declared no schema as declaring nothing', () => {
const variables = new Variables({ PASSWORD: 'p' }, ['PASSWORD']);
variables.assign('cube-z', 'default', {});
expect(variables.get('cube-z')).toEqual({});
});
});
@@ -129,10 +129,15 @@ describe('BuildContext session replay', () => {
});
describe('BuildContext replay gaps', () => {
const replay = (cube: Cube, recorded: Record<string, string> = {}, options = {}) =>
const replay = (
cube: Cube,
recorded: Record<string, string> = {},
options = {},
variables = new Variables()
) =>
new BuildContext(
{ [cube.id]: cube },
new Variables(),
variables,
session([{ key: cube.id, variables: recorded }]),
config,
{ method: 'ssh' },
@@ -175,16 +180,18 @@ describe('BuildContext replay gaps', () => {
expect(VariableAssignment).not.toHaveBeenCalled();
});
it('refuses to deploy when the form was cancelled', async () => {
it('refuses to deploy when the form came back empty', async () => {
const cube = testCube('cube-a', z.object({ SSID: z.string() }));
// The real VariableAssignment swallows a cancelled form, so the gap check
// has to run again afterwards or the cube ships without the variable.
// A form that resolves is not proof of an answer: enquirer renders
// `Math.min(limit, height)` fields, so a terminal misreporting its height
// submits `{}` without the user having seen a question. The gap check has
// to run again afterwards or the cube ships without the variable.
vi.mocked(VariableAssignment).mockResolvedValue(undefined);
const context = replay(cube);
await expect(context.resolveCube('cube-a', 'host1')).rejects.toThrow(
'Cube "cube-a" is missing SSID and cannot be deployed.'
'Cube "cube-a" is missing SSID. Nothing supplied it'
);
expect(context.deployCalls).toHaveLength(0);
});
@@ -194,10 +201,36 @@ describe('BuildContext replay gaps', () => {
const context = replay(cube, {}, { useDefaults: true });
// A schema default is deliberately not good enough for a secret: it would
// deploy a different credential than the run being replayed.
await expect(context.resolveCube('cube-a', 'host1')).rejects.toThrow(
/cannot be replayed with --use-defaults: PASSWORD/
/cannot be replayed with --use-defaults: PASSWORD would have to be entered\..*not accepted for a secret/s
);
});
it('accepts a secret supplied through config env under --use-defaults', async () => {
const cube = secretCube('cube-a', z.object({ PASSWORD: z.string().default('changeme') }));
const context = replay(
cube,
{},
{ useDefaults: true },
new Variables({ PASSWORD: 'from-env' }, ['PASSWORD'])
);
await context.resolveCube('cube-a', 'host1');
expect(VariableAssignment).not.toHaveBeenCalled();
expect(context.deployCalls[0].env.PASSWORD).toBe('from-env');
});
it('accepts a required variable a dependency passed under --use-defaults', async () => {
const cube = testCube('cube-a', z.object({ SSID: z.string() }));
const context = replay(cube, {}, { useDefaults: true });
await context.resolveCube('cube-a', 'host1', { SSID: 'from-param' });
expect(context.deployCalls[0].env.SSID).toBe('from-param');
});
});
describe('BuildContext session recording', () => {
@@ -240,6 +273,50 @@ describe('BuildContext session recording', () => {
});
});
describe('BuildContext secret broadcast', () => {
// The field run put PASSWORD under `env` because the docs said to, and watched
// it appear unmasked on the command line of every cube that was not user:add.
const resolveBoth = async () => {
const declaring = secretCube('cube-a', z.object({ PASSWORD: z.string().default('changeme') }));
const innocent = testCube('cube-b', z.object({ PORT: z.string().default('22') }));
const context = new BuildContext(
{ 'cube-a': declaring, 'cube-b': innocent },
new Variables({ PASSWORD: 'wildpass123', KEY_DIR: '/vault' }, ['PASSWORD']),
session(),
config,
{ method: 'ssh' },
{ useDefaults: true }
);
await context.resolveCube('cube-a', 'host1');
await context.resolveCube('cube-b', 'host1');
return context;
};
it('never puts an env secret on a cube that does not declare it', async () => {
const context = await resolveBoth();
const [, forB] = context.deployCalls;
expect(forB.cube).toBe('cube-b');
expect(forB.env).not.toHaveProperty('PASSWORD');
expect(forB.command.join(' ')).not.toContain('wildpass123');
});
it('still delivers it to the cube that declares it', async () => {
const context = await resolveBoth();
const [forA] = context.deployCalls;
expect(forA.env.PASSWORD).toBe('wildpass123');
expect(forA.secrets).toEqual(['PASSWORD']);
});
it('leaves an ordinary env key broadcast to both', async () => {
const context = await resolveBoth();
expect(context.deployCalls.map((call) => call.env.KEY_DIR)).toEqual(['/vault', '/vault']);
});
});
describe('BuildContext --use-defaults', () => {
const withDefaults = (cube: Cube, variables = new Variables(), cfg = config) =>
new BuildContext(
@@ -333,6 +410,38 @@ describe('BuildContext --use-defaults', () => {
});
});
describe('BuildContext interactive completeness', () => {
const interactive = (cube: Cube, variables = new Variables()) =>
new BuildContext({ [cube.id]: cube }, variables, session(), config, { method: 'ssh' });
it('refuses to deploy when the form submitted nothing', async () => {
const cube = testCube('cube-a', z.object({ SSID: z.string() }));
// What a 0-row terminal does: the form renders no fields, the user sees no
// question, enquirer resolves `{}` and the run used to carry on and deploy
// the cube with SSID simply absent from `--data`.
vi.mocked(VariableAssignment).mockResolvedValue(undefined);
const context = interactive(cube);
await expect(context.resolveCube('cube-a', 'host1')).rejects.toThrow(
/Cube "cube-a" is missing SSID\. Nothing supplied it/
);
expect(context.deployCalls).toHaveLength(0);
});
it('deploys when the form answered', async () => {
const cube = testCube('cube-a', z.object({ SSID: z.string() }));
vi.mocked(VariableAssignment).mockImplementation(async (_cube, variables) => {
variables.assign('cube-a', 'prompt', { SSID: 'typed' });
});
const context = interactive(cube);
await context.resolveCube('cube-a', 'host1');
expect(context.deployCalls[0].env.SSID).toBe('typed');
});
});
describe('BuildContext command construction', () => {
const build = (auth: { method: string; username?: string; password?: string }) => {
const context = new BuildContext(
@@ -125,3 +125,53 @@ describe('BuildContext.resolveCube', () => {
expect(context.deployCalls.map((c) => c.cube)).toEqual(['cube-a', 'cube-b', 'cube-c']);
});
});
describe('deploy order across several selected cubes', () => {
// `nopy.main.ts` walks `workflow.selectedCubes` and calls `resolveCube` once
// per entry, so the order that list arrives in is the order the loop visits.
// Emission is post-order, though, so a declared edge is honoured whichever way
// round the two cubes were listed — the recursion *is* the topological sort,
// and these pin that rather than leaving it to be inferred from the one-root
// cases above.
async function resolveAll(cubes: Record<string, Cube>, selected: string[]): Promise<string[]> {
const context = new BuildContext(
cubes,
new Variables(),
{ cubes: [] } as any,
{
env: {},
} as any,
{ method: 'ssh' }
);
for (const id of selected) await context.resolveCube(id, 'host1');
return context.deployCalls.map((c) => c.cube);
}
it('emits a dependency first even when it is selected last', async () => {
const cubes = {
'cube-a': createTestCube('cube-a'),
'cube-b': createTestCube('cube-b', () => ['cube-a']),
};
// The list order is the inversion of the dependency: b depends on a, and a
// is named after it. Resolving b still drags a in ahead of itself, and the
// second visit is deduped rather than re-emitted at the tail.
expect(await resolveAll(cubes, ['cube-b', 'cube-a'])).toEqual(['cube-a', 'cube-b']);
expect(await resolveAll(cubes, ['cube-a', 'cube-b'])).toEqual(['cube-a', 'cube-b']);
});
it('interleaves an unrelated cube by list order and nothing else', async () => {
// With no edge between them there is nothing to sort on, so `cube-z` lands
// where the list put it. That is the whole of what selection order decides.
const cubes = {
'cube-a': createTestCube('cube-a'),
'cube-b': createTestCube('cube-b', () => ['cube-a']),
'cube-z': createTestCube('cube-z'),
};
expect(await resolveAll(cubes, ['cube-z', 'cube-b'])).toEqual(['cube-z', 'cube-a', 'cube-b']);
expect(await resolveAll(cubes, ['cube-b', 'cube-z'])).toEqual(['cube-a', 'cube-b', 'cube-z']);
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* Tests for nopy.errors — how a failed run is presented.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NopyUsageError, reportError } from '../src/nopy.errors.js';
let out: ReturnType<typeof vi.spyOn>;
let err: ReturnType<typeof vi.spyOn>;
/** Everything written to stderr by the last call, as one string. */
const stderr = () => err.mock.calls.map((call) => String(call[0])).join('\n');
beforeEach(() => {
out = vi.spyOn(console, 'log').mockImplementation(() => {});
err = vi.spyOn(console, 'error').mockImplementation(() => {});
delete process.env.NOPY_DEBUG;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('reportError', () => {
it('prints a usage error as one line and points at the debug switch', () => {
reportError(new NopyUsageError('No .nopyrc.json found'));
expect(stderr()).toContain('Error: No .nopyrc.json found');
expect(stderr()).not.toContain('nopy.errors');
expect(stderr()).toContain('NOPY_DEBUG=1');
});
it('keeps the stack for anything unexpected', () => {
reportError(new TypeError('cannot read properties of undefined'));
expect(stderr()).toContain('Error: cannot read properties of undefined');
expect(stderr()).toContain('TypeError: cannot read properties of undefined\n at ');
expect(stderr()).not.toContain('NOPY_DEBUG=1');
});
it('prints the stack of a usage error under NOPY_DEBUG', () => {
process.env.NOPY_DEBUG = '1';
reportError(new NopyUsageError('No .nopyrc.json found'));
expect(stderr()).toContain('NopyUsageError: No .nopyrc.json found\n at ');
expect(stderr()).not.toContain('NOPY_DEBUG=1 for');
});
it('reports a thrown non-error', () => {
reportError('just a string');
expect(stderr()).toContain('Error: just a string');
expect(stderr()).toContain('NOPY_DEBUG=1');
});
it('says nothing on stdout', () => {
reportError(new NopyUsageError('No .nopyrc.json found'));
expect(out).not.toHaveBeenCalled();
});
});
-14
View File
@@ -123,20 +123,6 @@ describe('outputExecutionPlan', () => {
expect(output).toContain('host1');
});
it('outputs JSON format when requested', () => {
const calls = [createTestCall('cube-a', 'host1')];
outputExecutionPlan(calls, true);
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
const output = consoleLogSpy.mock.calls[0][0];
const parsed = JSON.parse(output);
expect(parsed.plan).toHaveLength(1);
expect(parsed.plan[0].cube).toBe('cube-a');
expect(parsed.plan[0].host).toBe('host1');
});
it('masks variables the manifest declared secret', () => {
const call: DeployCall = {
...createTestCall('cube-a', 'host1'),
+42
View File
@@ -0,0 +1,42 @@
/**
* Runs one real enquirer variable form and prints what it produced.
*
* Driven by `tests/prompts.pty.test.ts` under a pty of a chosen size. Nothing
* here is mocked — the point is the prompt library's own behaviour on a
* terminal that reports no size, which cannot be observed from inside a vitest
* worker because there is no TTY there to misreport.
*
* Prints one line, `NOPY_PROBE <json>`, holding the values the form assigned at
* the `prompt` origin. An empty object means the form submitted nothing.
*/
import { Cube, Manifest } from '@bitsquare/nopy-cubes';
import { z } from 'zod';
import { Variables } from '../../src/nopy.common.js';
import { VariableAssignment } from '../../src/nopy.prompts.js';
const KEYS = ['ALPHA', 'BETA'];
const cube = new Cube(
Manifest({
id: 'probe',
name: 'Zero-rows probe',
schema: z.object({
ALPHA: z.string().describe('First value').default(''),
BETA: z.string().describe('Second value').default(''),
}),
}),
'/cubes/probe',
'deploy.py'
);
const variables = new Variables();
await VariableAssignment(cube, variables);
const assigned: Record<string, unknown> = {};
for (const key of KEYS) {
const variable = variables.of('probe', key);
if (variable?.origin === 'prompt') assigned[key] = variable.value;
}
process.stdout.write(`\nNOPY_PROBE ${JSON.stringify(assigned)}\n`);
+115 -45
View File
@@ -48,7 +48,12 @@ vi.mock('../src/cubes/index.js', () => ({ loadCubes }));
vi.mock('../src/nopy.config.js', () => ({ loadConfig, getConfigPaths }));
vi.mock('../src/nopy.workflow.js', () => ({ runWorkflow }));
vi.mock('../src/nopy.history.js', () => ({ addToHistory, DEFAULT_HISTORY_SIZE: 10 }));
vi.mock('../src/nopy.session.js', () => ({ saveSession }));
// Only the writer is a spy — `describeSession` and the version constant are pure
// and the assertions below are about what nopy() actually stamps.
vi.mock('../src/nopy.session.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/nopy.session.js')>()),
saveSession,
}));
vi.mock('../src/cubes/dependencies.js', () => ({
BuildContext: class {
resolveCube = resolveCube;
@@ -67,16 +72,17 @@ vi.mock('../src/nopy.executor.js', async (importOriginal) => {
import { nopy } from '../src/nopy.main.js';
const session = (): NopySession =>
({
version: '1.0',
name: 'test',
createdAt: '2026-01-01T00:00:00.000Z',
cubes: [],
hosts: ['web-1'],
auth: { method: 'ssh-key' },
env: {},
}) as NopySession;
/**
* A session as bare as the loader will accept one — no `version`, `timestamp`
* or `name`, which is exactly what a hand-written file looks like and what
* `nopy()` has to fill in.
*/
const session = (): NopySession => ({
cubes: [],
hosts: ['web-1'],
auth: { method: 'ssh-key' },
env: {},
});
const call = (cube: string): DeployCall => ({
cube,
@@ -88,10 +94,12 @@ const call = (cube: string): DeployCall => ({
});
let logSpy: ReturnType<typeof vi.spyOn>;
let errSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
state.config = { hosts: ['web-1'], cubeDirs: [], cubePackages: [], env: {} };
state.loadResult = { cubes: { 'cube-a': {} }, errors: [] };
@@ -102,14 +110,19 @@ beforeEach(() => {
session: session(),
selectedCubes: ['cube-a'],
authMethod: 'ssh-key',
isReplay: false,
replaySource: undefined,
});
executeDeployCalls.mockResolvedValue([
{ cube: 'cube-a', host: 'web-1', success: true, duration: 10 },
]);
});
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
/**
* The two streams, kept apart on purpose: stdout carries the deploy commands
* and pyinfra's own output, everything nopy says about itself goes to stderr.
*/
const stdout = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
const stderr = () => errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
describe('nopy', () => {
it('runs the happy path and reports success', async () => {
@@ -141,7 +154,7 @@ describe('nopy', () => {
session: { ...session(), hosts: ['web-1', 'web-2'] },
selectedCubes: ['cube-a', 'cube-b'],
authMethod: 'ssh-key',
isReplay: false,
replaySource: undefined,
});
await nopy();
@@ -159,13 +172,13 @@ describe('nopy', () => {
expect(runWorkflow).not.toHaveBeenCalled();
});
it('emits the errors as JSON when jsonOutput is set', async () => {
it('reports them on stderr', async () => {
state.loadResult = { cubes: {}, errors: ['bad manifest'] };
await nopy({ jsonOutput: true });
await nopy();
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string);
expect(payload).toEqual({ success: false, errors: ['bad manifest'] });
expect(stderr()).toContain('bad manifest');
expect(stdout()).toBe('');
});
});
@@ -180,7 +193,7 @@ describe('nopy', () => {
await nopy({ continueOnError: true });
const text = output();
const text = stderr();
expect(text).toContain('Configuration');
expect(text).toContain('Hosts:');
expect(text).toContain('Cube dirs:');
@@ -198,7 +211,7 @@ describe('nopy', () => {
await nopy();
const text = output();
const text = stderr();
expect(text).toContain('Configuration');
expect(text).not.toContain('Hosts:');
expect(text).not.toContain('Cube dirs:');
@@ -215,25 +228,20 @@ describe('nopy', () => {
await nopy();
const text = output();
const text = stderr();
expect(text).toContain('~/.nopyrc.json');
expect(text).toContain('./.nopyrc.json');
expect(text).toContain('/etc/nopy/.nopyrc.json');
});
it('is suppressed for JSON output', async () => {
await nopy({ jsonOutput: true });
expect(output()).not.toContain('Configuration');
});
it('is suppressed when replaying a session object', async () => {
await nopy({ replaySession: session() });
expect(output()).not.toContain('Configuration');
expect(stderr()).not.toContain('Configuration');
});
it('is suppressed when replaying a session file', async () => {
await nopy({ loadSession: '/tmp/s.json' });
expect(output()).not.toContain('Configuration');
expect(stderr()).not.toContain('Configuration');
});
});
@@ -247,17 +255,57 @@ describe('nopy', () => {
expect(written.cubes).toEqual(state.cubeSessions);
});
it('does not save a replayed session back to file', async () => {
it('leaves a declared secret out of the recorded env', async () => {
// The session's `env` is a copy of the config's, and used to be copied
// verbatim — writing to disk, in plaintext, the credential that was kept
// out of every cube's `variables` one key below.
state.config = {
...state.config,
env: { PASSWORD: 'hunter2', KEY_DIR: './vault' },
secrets: ['PASSWORD'],
};
await nopy({ saveSession: '/tmp/out.json' });
expect(saveSession.mock.calls[0][0].env).toEqual({ KEY_DIR: './vault' });
});
it('saves a replayed session too', async () => {
runWorkflow.mockResolvedValue({
session: session(),
selectedCubes: ['cube-a'],
authMethod: 'ssh-key',
isReplay: true,
replaySource: 'history',
});
await nopy({ saveSession: '/tmp/out.json' });
expect(saveSession).not.toHaveBeenCalled();
expect(saveSession).toHaveBeenCalledTimes(1);
expect(saveSession.mock.calls[0][1]).toBe('/tmp/out.json');
});
it('stamps version, timestamp and a derived name', async () => {
await nopy({ saveSession: '/tmp/out.json' });
const [written] = saveSession.mock.calls[0];
expect(written.version).toBe('1.0.0');
expect(written.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(written.name).toContain('cube-a');
expect(written.name).toContain('web-1');
});
it('keeps the name and version a replayed session already carried', async () => {
runWorkflow.mockResolvedValue({
session: { ...session(), version: '0.9.0', name: 'hand-written', timestamp: 'then' },
selectedCubes: ['cube-a'],
authMethod: 'ssh-key',
replaySource: 'file',
});
await nopy({ saveSession: '/tmp/out.json' });
const [written] = saveSession.mock.calls[0];
expect(written).toMatchObject({ version: '0.9.0', name: 'hand-written', timestamp: 'then' });
});
it('does not save when no path is given', async () => {
@@ -300,12 +348,12 @@ describe('nopy', () => {
expect(addToHistory).not.toHaveBeenCalled();
});
it('skips history for a replay', async () => {
it('skips history for a replay out of history', async () => {
runWorkflow.mockResolvedValue({
session: session(),
selectedCubes: ['cube-a'],
authMethod: 'ssh-key',
isReplay: true,
replaySource: 'history',
});
await nopy();
@@ -313,6 +361,19 @@ describe('nopy', () => {
expect(addToHistory).not.toHaveBeenCalled();
});
it('records a replay out of a session file', async () => {
runWorkflow.mockResolvedValue({
session: session(),
selectedCubes: ['cube-a'],
authMethod: 'ssh-key',
replaySource: 'file',
});
await nopy();
expect(addToHistory).toHaveBeenCalledTimes(1);
});
it('skips history when nothing would be deployed', async () => {
state.deployCalls = [];
@@ -320,19 +381,36 @@ describe('nopy', () => {
expect(addToHistory).not.toHaveBeenCalled();
});
it('skips history for a print-only run', async () => {
// Same rule as `--dry-run`: nothing was deployed, so nothing belongs at
// the head of the list `-R` repeats.
await nopy({ printOnly: true });
expect(addToHistory).not.toHaveBeenCalled();
});
});
describe('printOnly', () => {
it('prints commands and never executes', async () => {
await nopy({ printOnly: true });
const text = output();
const text = stdout();
expect(text).toContain('Deploy Commands');
expect(text).toContain('# cube-a -> web-1');
expect(text).toContain('pyinfra web-1 -y cube-a.deploy.py');
expect(executeDeployCalls).not.toHaveBeenCalled();
});
it('keeps stdout to the commands and nothing else', async () => {
// The whole point of the split: `nopy -P > plan.txt` has to be the plan.
// The config banner and every log line are on the other stream.
await nopy({ printOnly: true });
expect(stdout()).not.toContain('Configuration');
expect(stderr()).toContain('Configuration');
});
it('reports the command count as the summary total', async () => {
const result = await nopy({ printOnly: true });
@@ -359,17 +437,9 @@ describe('nopy', () => {
const [, options] = executeDeployCalls.mock.calls[0];
options.onProgress({ cube: 'cube-a', host: 'web-1', success: true }, 1, 1);
options.onProgress({ cube: 'cube-b', host: 'web-1', success: false }, 1, 1);
// Exercises both the ✓ and ✗ branches; logtape writes via console.log.
expect(logSpy).toHaveBeenCalled();
});
it('stays silent on progress when jsonOutput is set', async () => {
await nopy({ jsonOutput: true });
const [, options] = executeDeployCalls.mock.calls[0];
const before = logSpy.mock.calls.length;
options.onProgress({ cube: 'cube-a', host: 'web-1', success: true }, 1, 1);
expect(logSpy.mock.calls.length).toBe(before);
// Exercises both the ✓ and ✗ branches; logtape writes via console.error.
expect(stderr()).toContain('cube-a');
expect(stderr()).toContain('cube-b');
});
});
});
+77
View File
@@ -0,0 +1,77 @@
/**
* The variable form, on a terminal that reports no size.
*
* This is the one case that cannot be tested from inside a vitest worker: there
* is no TTY there for enquirer to misread, so the mocked tests in
* `prompts.test.ts` prove only that `rows` is *passed*, never that passing it
* matters. Here a real pty is opened at 0x0 — `pty.fork()`'s own default, and
* what `script -q` and some CI terminals report — and a real form is answered
* through it.
*
* Measured both ways while writing this: with `terminalSize()` removed from
* `nopy.prompts.ts`, the form never renders and the driver times out with
* nothing on the wire.
*
* Needs `python3` for the pty; skipped, loudly, where there is none.
*/
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const EXPECT_PY = fileURLToPath(new URL('../../../scripts/expect.py', import.meta.url));
const TSX = fileURLToPath(new URL('../node_modules/.bin/tsx', import.meta.url));
const PROBE = fileURLToPath(new URL('./fixtures/form-probe.ts', import.meta.url));
const hasPython = spawnSync('python3', ['--version']).status === 0;
/** Down arrow — how the form moves from one field to the next. */
const DOWN = '\u001b[B';
const STEPS = [
{ expect: 'ALPHA', send: 'alpha-typed', settle: 0.6 },
{ send: DOWN, settle: 0.4 },
{ send: 'beta-typed', settle: 0.4 },
{ send: '\r', settle: 1.2 },
];
describe.skipIf(!hasPython)('variable form over a pty', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-pty-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
/** Answers the probe form on a pty of the given size; returns what it assigned. */
const answerForm = (rows: number, cols: number) => {
const stepsPath = path.join(tmpDir, 'steps.json');
const logPath = path.join(tmpDir, 'session.log');
fs.writeFileSync(stepsPath, JSON.stringify(STEPS));
execFileSync('python3', [EXPECT_PY, stepsPath, '--', TSX, PROBE], {
env: {
...process.env,
PTY_ROWS: String(rows),
PTY_COLS: String(cols),
EXPECT_TIMEOUT: '60',
EXPECT_LOG: logPath,
},
stdio: 'pipe',
});
const transcript = fs.readFileSync(logPath, 'utf-8').replace(/\r/g, '');
const line = transcript.split('\n').find((l) => l.startsWith('NOPY_PROBE '));
return line ? JSON.parse(line.slice('NOPY_PROBE '.length)) : undefined;
};
it('collects every field on a terminal reporting 0x0', () => {
expect(answerForm(0, 0)).toEqual({ ALPHA: 'alpha-typed', BETA: 'beta-typed' });
}, 90_000);
});
+72 -20
View File
@@ -55,11 +55,27 @@ const question = (name: string) => questions().find((q) => q.name === name);
/** Grabs the options the last enquirer AutoComplete prompt was constructed with. */
const autoComplete = () => autoCompleteCtor.mock.calls.at(-1)?.[0] as Record<string, any>;
/** Grabs the options the last enquirer Form prompt was constructed with. */
const formOptions = () => formCtor.mock.calls.at(-1)?.[0] as Record<string, any>;
/** Grabs the choices the last enquirer Form prompt was constructed with. */
const formChoices = () => {
const options = formCtor.mock.calls.at(-1)?.[0] as { choices: Record<string, any>[] };
return options.choices;
};
const formChoices = () => formOptions().choices as Record<string, any>[];
/** Runs `body` with the terminal reporting the given size, then puts it back. */
async function withTerminal(
size: { rows: number; columns: number },
body: () => Promise<void>
): Promise<void> {
const was = { rows: process.stdout.rows, columns: process.stdout.columns };
Object.defineProperty(process.stdout, 'rows', { value: size.rows, configurable: true });
Object.defineProperty(process.stdout, 'columns', { value: size.columns, configurable: true });
try {
await body();
} finally {
Object.defineProperty(process.stdout, 'rows', { value: was.rows, configurable: true });
Object.defineProperty(process.stdout, 'columns', { value: was.columns, configurable: true });
}
}
const cube = (id: string, name: string, schema = z.object({})) =>
new Cube(Manifest({ id, name, schema }), `/cubes/${id}`, 'deploy.py');
@@ -113,24 +129,42 @@ describe('CubeSelection', () => {
it('derives page size from the terminal height', async () => {
autoCompleteRun.mockResolvedValue([]);
const rows = process.stdout.rows;
Object.defineProperty(process.stdout, 'rows', { value: 40, configurable: true });
await CubeSelection(cubes);
expect(autoComplete().limit).toBe(35);
await withTerminal({ rows: 40, columns: 200 }, async () => {
await CubeSelection(cubes);
expect(autoComplete().limit).toBe(35);
});
// Falls back to a floor of 10 on a short (or unknown) terminal.
Object.defineProperty(process.stdout, 'rows', { value: 0, configurable: true });
await CubeSelection(cubes);
expect(autoComplete().limit).toBe(19);
Object.defineProperty(process.stdout, 'rows', { value: rows, configurable: true });
// A terminal reporting nothing is floored, not believed.
await withTerminal({ rows: 0, columns: 0 }, async () => {
await CubeSelection(cubes);
expect(autoComplete().limit).toBe(19);
});
});
it('selects nothing when the user cancels', async () => {
it('hands the prompt a window size it can render into', async () => {
autoCompleteRun.mockResolvedValue([]);
// Passing `rows` is what keeps enquirer away from its own `utils.height`,
// which overwrites a good fallback with `getWindowSize()[1]` — zero here.
await withTerminal({ rows: 0, columns: 0 }, async () => {
await CubeSelection(cubes);
expect(autoComplete()).toMatchObject({ rows: 24, columns: 80 });
});
await withTerminal({ rows: 50, columns: 200 }, async () => {
await CubeSelection(cubes);
expect(autoComplete()).toMatchObject({ rows: 50, columns: 200 });
});
});
it('lets a cancellation travel instead of returning an empty selection', async () => {
// An empty selection is a legitimate answer, so swallowing the rejection
// here made "the user backed out" and "the user picked nothing" the same
// event and let the run continue to deploy zero cubes.
autoCompleteRun.mockRejectedValue(new Error('cancelled'));
await expect(CubeSelection(cubes)).resolves.toEqual({ selectedCubes: [] });
await expect(CubeSelection(cubes)).rejects.toThrow('cancelled');
});
});
@@ -418,13 +452,31 @@ describe('VariableAssignment', () => {
expect(variables.get('svc').extra).toBe('kept');
});
it('assigns nothing when the user cancels the form', async () => {
it('hands the form a window size it can render into', async () => {
const variables = new Variables();
formRun.mockResolvedValue({});
// The form is where a zero height actually costs something: enquirer
// renders `Math.min(limit, height)` fields, so a 0-row terminal shows none
// of them and submits `{}` without the user ever seeing the questions.
await withTerminal({ rows: 0, columns: 0 }, async () => {
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(formOptions()).toMatchObject({ rows: 24, columns: 80 });
});
await withTerminal({ rows: 50, columns: 200 }, async () => {
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(formOptions()).toMatchObject({ rows: 50, columns: 200 });
});
});
it('lets a cancelled form travel rather than assigning nothing', async () => {
const variables = new Variables();
formRun.mockRejectedValue(new Error('cancelled'));
await expect(
VariableAssignment(cube('svc', 'Service', schema), variables)
).resolves.toBeUndefined();
await expect(VariableAssignment(cube('svc', 'Service', schema), variables)).rejects.toThrow(
'cancelled'
);
expect(variables.get('svc')).toEqual({});
});
+118 -1
View File
@@ -5,12 +5,14 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createSession,
describeSession,
listSessions,
loadSession,
type NopySession,
SESSION_VERSION,
saveSession,
} from '../src/nopy.session.js';
@@ -48,6 +50,62 @@ describe('createSession', () => {
expect(session.env).toEqual({ KEY: 'value' });
});
it('stamps the format version and a creation time', () => {
const session = createSession({ cubes: [], hosts: ['localhost'], auth: { method: 'ssh' } });
expect(session.version).toBe(SESSION_VERSION);
expect(new Date(session.timestamp!).toISOString()).toBe(session.timestamp);
});
it('lets the caller supply the timestamp', () => {
const session = createSession({
cubes: [],
hosts: ['localhost'],
auth: { method: 'ssh' },
timestamp: '2026-01-01T00:00:00.000Z',
});
expect(session.timestamp).toBe('2026-01-01T00:00:00.000Z');
});
});
describe('describeSession', () => {
const at = '2026-01-01T12:30:00.000Z';
it('names the cubes and the hosts', () => {
const name = describeSession(
{
cubes: [{ key: 'apt:essentials', variables: {} }],
hosts: ['web-1'],
auth: { method: 'ssh' },
},
at
);
expect(name).toContain('apt:essentials');
expect(name).toContain('web-1');
});
it('says so when there is no host', () => {
const name = describeSession({ cubes: [], auth: { method: 'ssh' } }, at);
expect(name).toContain('no host');
});
it('truncates a long cube list and a long host list', () => {
const name = describeSession(
{
cubes: Array.from({ length: 10 }, (_, i) => ({ key: `cube-${i}`, variables: {} })),
hosts: Array.from({ length: 10 }, (_, i) => `host-${i}`),
auth: { method: 'ssh' },
},
at
);
expect(name).toContain('...');
expect(name.split('→')[1]).toContain('...');
});
});
describe('saveSession and loadSession', () => {
@@ -116,6 +174,51 @@ describe('saveSession and loadSession', () => {
await expect(loadSession(sessionPath)).rejects.toThrow('auth');
});
// Half of SESSION_FORMAT.md is about the MJS form, and nothing exercised it.
// Each test needs its own filename: `import()` caches by URL, so a second
// module written to the same path would never be read.
it('loads a session from an MJS default export', async () => {
const mjsPath = path.join(tempDir, 'ok.session.mjs');
fs.writeFileSync(
mjsPath,
'export default { cubes: [{ key: "apt:essentials", variables: {} }], auth: { method: "ssh" } };'
);
await expect(loadSession(mjsPath)).resolves.toMatchObject({
cubes: [{ key: 'apt:essentials', variables: {} }],
});
});
it('rejects an MJS session with no default export', async () => {
const mjsPath = path.join(tempDir, 'no-default.session.mjs');
fs.writeFileSync(mjsPath, 'export const session = {};');
await expect(loadSession(mjsPath)).rejects.toThrow('must export a default object');
});
it('loads a session with no version at all', async () => {
fs.writeFileSync(sessionPath, JSON.stringify({ cubes: [], auth: { method: 'ssh' } }));
const warn = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(loadSession(sessionPath)).resolves.toMatchObject({ cubes: [] });
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
it('warns about an unknown version but still loads it', async () => {
fs.writeFileSync(
sessionPath,
JSON.stringify({ version: '9.9.9', cubes: [], auth: { method: 'ssh' } })
);
const warn = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(loadSession(sessionPath)).resolves.toMatchObject({ version: '9.9.9' });
expect(warn.mock.calls[0][0]).toContain('9.9.9');
warn.mockRestore();
});
});
describe('listSessions', () => {
@@ -154,4 +257,18 @@ describe('listSessions', () => {
expect(result).toHaveLength(1);
expect(result[0].endsWith('test.session.mjs')).toBe(true);
});
it('finds the documented .nopysession.* files', () => {
// The name every example in the README uses, and the one this missed:
// `wild.nopysession.json` does not end in `.session.json`.
fs.writeFileSync(path.join(tempDir, 'wild.nopysession.json'), '{}');
fs.writeFileSync(path.join(tempDir, 'wild.nopysession.mjs'), 'export default {}');
fs.writeFileSync(path.join(tempDir, 'nopysession.json'), '{}');
const result = listSessions(tempDir);
expect(result).toHaveLength(2);
expect(result.some((p) => p.endsWith('wild.nopysession.json'))).toBe(true);
expect(result.some((p) => p.endsWith('wild.nopysession.mjs'))).toBe(true);
});
});
+6 -6
View File
@@ -78,7 +78,7 @@ describe('runInteractiveWorkflow', () => {
expect(result.selectedCubes).toEqual(['cube-a']);
expect(result.authMethod).toBe('ssh-key');
expect(result.isReplay).toBe(false);
expect(result.replaySource).toBeUndefined();
expect(result.session.hosts).toEqual(['web-1']);
expect(result.session.env).toEqual({ GLOBAL: 'value' });
expect(mockHostSelection).toHaveBeenCalledWith(config.hosts);
@@ -150,7 +150,7 @@ describe('runReplayWorkflow', () => {
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
expect(mockLoadSession).toHaveBeenCalledWith('/tmp/s.json');
expect(result.isReplay).toBe(true);
expect(result.replaySource).toBe('file');
expect(result.selectedCubes).toEqual(['cube-a']);
expect(mockHostSelection).not.toHaveBeenCalled();
expect(mockPasswordSelection).not.toHaveBeenCalled();
@@ -225,7 +225,7 @@ describe('runSessionReplayWorkflow', () => {
it('replays an in-memory session without prompting', async () => {
const result = await runSessionReplayWorkflow(session(), cubes, config);
expect(result.isReplay).toBe(true);
expect(result.replaySource).toBe('history');
expect(result.selectedCubes).toEqual(['cube-a']);
expect(mockLoadSession).not.toHaveBeenCalled();
expect(mockHostSelection).not.toHaveBeenCalled();
@@ -287,7 +287,7 @@ describe('runWorkflow dispatch', () => {
it('prefers an in-memory replay session over everything else', async () => {
const result = await runWorkflow('/tmp/s.json', cubes, config, {}, session());
expect(result.isReplay).toBe(true);
expect(result.replaySource).toBe('history');
expect(mockLoadSession).not.toHaveBeenCalled();
expect(mockCubeSelection).not.toHaveBeenCalled();
});
@@ -298,14 +298,14 @@ describe('runWorkflow dispatch', () => {
const result = await runWorkflow('/tmp/s.json', cubes, config);
expect(mockLoadSession).toHaveBeenCalledWith('/tmp/s.json');
expect(result.isReplay).toBe(true);
expect(result.replaySource).toBe('file');
expect(mockCubeSelection).not.toHaveBeenCalled();
});
it('falls back to the interactive workflow', async () => {
const result = await runWorkflow(undefined, cubes, config, { useAuthKey: true });
expect(result.isReplay).toBe(false);
expect(result.replaySource).toBeUndefined();
expect(mockCubeSelection).toHaveBeenCalled();
expect(mockAuthSelection).toHaveBeenCalledWith(true);
});