hardening and bugfixing prior to stable release
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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`);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user