Add release pipeline and upgrade toolchain to TypeScript 7
Publish snapshot / snapshot (push) Failing after 1m58s
Publish snapshot / snapshot (push) Failing after 1m58s
Publishing infrastructure - Three Gitea workflows: ci.yml (PRs, non-main pushes), publish-snapshot.yml (main -> Gitea under dist-tag @main) and release.yml (tags -> Gitea + npmjs) - Tag-driven releases as <package-dir>-v<version>; the manifest stays the source of truth and release.yml refuses to run if tag and manifest disagree - Every publish is idempotent: each step checks the registry first, so a run that fails on the second registry can simply be re-run - Hard coverage gate (85% branches) shared by CI, the pre-push hook and local runs, since the thresholds live in vitest.config.ts rather than a CI flag - README.PUBLISH.md documents the whole mechanism Toolchain - TypeScript 7 native compiler; drop tsgo and ts-node, use tsx for dev runs - Biome 1.9 -> 2.x, Vitest 1 -> 4, zod 3 -> 4, inquirer 8 -> 14, pnpm 11.17.0 - Replace inquirer-checkbox-plus-prompt, which is peer-capped at inquirer <9, with enquirer's AutoComplete; the CubeSelection contract is unchanged - Stand in for zod 4's removed z.AnyZodObject with a local AnyObjectSchema Repo hygiene - Stop tracking dist/; ignore coverage/, *.tsbuildinfo, .npmrc* and release.json - Drop package-lock.json in favour of pnpm-lock.yaml Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Tests for nopy.config loading, merging and path resolution.
|
||||
*
|
||||
* findConfigFiles() walks from cwd up to the filesystem root and also consults
|
||||
* $HOME, so every test runs inside a fresh mkdtemp directory with HOME pointed
|
||||
* at an empty directory. Without that, a developer's own ~/.nopyrc.json would
|
||||
* leak into the merge result and make these tests machine-dependent.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getConfigPaths, loadConfig, type NopyConfigFile, saveConfig } from '../src/nopy.config.js';
|
||||
|
||||
describe('config loading', () => {
|
||||
let originalCwd: string;
|
||||
let originalHome: string | undefined;
|
||||
let rootDir: string;
|
||||
let emptyHome: string;
|
||||
|
||||
const write = (dir: string, config: NopyConfigFile) => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.nopyrc.json'), JSON.stringify(config, null, 2));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
originalHome = process.env.HOME;
|
||||
rootDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-config-')));
|
||||
emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-home-')));
|
||||
process.env.HOME = emptyHome;
|
||||
process.chdir(rootDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
fs.rmSync(emptyHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('discovery', () => {
|
||||
it('throws a helpful error when no config exists anywhere', () => {
|
||||
expect(() => loadConfig()).toThrow(/No \.nopyrc\.json found/);
|
||||
});
|
||||
|
||||
it('loads a config from the current directory', () => {
|
||||
write(rootDir, { hosts: ['web-1'] });
|
||||
expect(loadConfig().hosts).toEqual(['web-1']);
|
||||
});
|
||||
|
||||
it('applies defaults for properties the file omits', () => {
|
||||
write(rootDir, { hosts: ['web-1'] });
|
||||
const config = loadConfig();
|
||||
expect(config.cubeDirs).toEqual([]);
|
||||
expect(config.env).toEqual({});
|
||||
});
|
||||
|
||||
it('finds a config in a parent directory', () => {
|
||||
write(rootDir, { hosts: ['parent-host'] });
|
||||
const child = path.join(rootDir, 'a', 'b');
|
||||
fs.mkdirSync(child, { recursive: true });
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().hosts).toEqual(['parent-host']);
|
||||
});
|
||||
|
||||
it('picks up $HOME config at the lowest priority', () => {
|
||||
write(emptyHome, { hosts: ['home-host'] });
|
||||
write(rootDir, { hosts: ['project-host'] });
|
||||
|
||||
// Root-first ordering means the home value is merged in first.
|
||||
expect(loadConfig().hosts).toEqual(['home-host', 'project-host']);
|
||||
});
|
||||
|
||||
it('does not duplicate the home config when cwd is $HOME', () => {
|
||||
write(emptyHome, { hosts: ['home-host'] });
|
||||
process.chdir(emptyHome);
|
||||
|
||||
expect(getConfigPaths().filter((p) => p.startsWith(emptyHome))).toHaveLength(1);
|
||||
expect(loadConfig().hosts).toEqual(['home-host']);
|
||||
});
|
||||
|
||||
it('tolerates an unset HOME', () => {
|
||||
process.env.HOME = '';
|
||||
write(rootDir, { hosts: ['web-1'] });
|
||||
expect(loadConfig().hosts).toEqual(['web-1']);
|
||||
});
|
||||
|
||||
it('reports discovered config paths parent-first', () => {
|
||||
write(rootDir, { hosts: ['parent'] });
|
||||
const child = path.join(rootDir, 'child');
|
||||
write(child, { hosts: ['child'] });
|
||||
process.chdir(child);
|
||||
|
||||
const paths = getConfigPaths();
|
||||
expect(paths).toEqual([path.join(rootDir, '.nopyrc.json'), path.join(child, '.nopyrc.json')]);
|
||||
});
|
||||
|
||||
it('wraps malformed JSON with the offending path', () => {
|
||||
fs.writeFileSync(path.join(rootDir, '.nopyrc.json'), '{ not valid json');
|
||||
expect(() => loadConfig()).toThrow(/Failed to load config .*\.nopyrc\.json/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge strategy', () => {
|
||||
const nested = () => {
|
||||
const child = path.join(rootDir, 'child');
|
||||
fs.mkdirSync(child, { recursive: true });
|
||||
return child;
|
||||
};
|
||||
|
||||
it('concatenates arrays and de-duplicates primitives', () => {
|
||||
const child = nested();
|
||||
write(rootDir, { hosts: ['a', 'b'] });
|
||||
write(child, { hosts: ['b', 'c'] });
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().hosts).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('replaces arrays entirely under the override strategy', () => {
|
||||
const child = nested();
|
||||
write(rootDir, { hosts: ['a', 'b'] });
|
||||
write(child, { hosts: ['only-me'], resolution: { hosts: 'override' } });
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().hosts).toEqual(['only-me']);
|
||||
});
|
||||
|
||||
it('deep merges nested objects', () => {
|
||||
const child = nested();
|
||||
write(rootDir, { env: { SHARED: 'parent', ONLY_PARENT: 'p' } });
|
||||
write(child, { env: { SHARED: 'child', ONLY_CHILD: 'c' } });
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().env).toEqual({
|
||||
SHARED: 'child',
|
||||
ONLY_PARENT: 'p',
|
||||
ONLY_CHILD: 'c',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets a child primitive override a parent primitive', () => {
|
||||
const child = nested();
|
||||
write(rootDir, { log: { verbosity: 'info', debug: true } });
|
||||
write(child, { log: { verbosity: 'trace' } });
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().log).toEqual({ verbosity: 'trace', debug: true });
|
||||
});
|
||||
|
||||
it('adds properties the parent never defined', () => {
|
||||
const child = nested();
|
||||
write(rootDir, { hosts: ['a'] });
|
||||
write(child, { execution: { continueOnError: true } });
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().execution).toEqual({ continueOnError: true });
|
||||
});
|
||||
|
||||
it('keeps arrays of objects without de-duplicating them', () => {
|
||||
const child = nested();
|
||||
write(rootDir, { env: { list: [{ a: 1 }] } as never });
|
||||
write(child, { env: { list: [{ a: 1 }] } as never });
|
||||
process.chdir(child);
|
||||
|
||||
expect((loadConfig().env as Record<string, unknown>).list).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('never surfaces the resolution key in the merged config', () => {
|
||||
write(rootDir, { hosts: ['a'], resolution: { hosts: 'override' } });
|
||||
expect(loadConfig()).not.toHaveProperty('resolution');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relative path resolution', () => {
|
||||
it('resolves ./ cubeDirs against the config file location', () => {
|
||||
write(rootDir, { cubeDirs: ['./cubes'] });
|
||||
expect(loadConfig().cubeDirs).toEqual([path.join(rootDir, 'cubes')]);
|
||||
});
|
||||
|
||||
it('resolves ../ cubeDirs against the config file location', () => {
|
||||
const child = path.join(rootDir, 'child');
|
||||
write(child, { cubeDirs: ['../shared-cubes'] });
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().cubeDirs).toEqual([path.join(rootDir, 'shared-cubes')]);
|
||||
});
|
||||
|
||||
it('resolves bare paths containing a separator', () => {
|
||||
write(rootDir, { cubeDirs: ['nested/cubes'] });
|
||||
expect(loadConfig().cubeDirs).toEqual([path.join(rootDir, 'nested', 'cubes')]);
|
||||
});
|
||||
|
||||
it('leaves absolute cubeDirs untouched', () => {
|
||||
write(rootDir, { cubeDirs: ['/opt/cubes'] });
|
||||
expect(loadConfig().cubeDirs).toEqual(['/opt/cubes']);
|
||||
});
|
||||
|
||||
it('leaves ~ and URL-like values untouched', () => {
|
||||
write(rootDir, { cubeDirs: ['~/cubes', 'https://example.com/cubes'] });
|
||||
expect(loadConfig().cubeDirs).toEqual(['~/cubes', 'https://example.com/cubes']);
|
||||
});
|
||||
|
||||
it('leaves a bare single-segment name untouched', () => {
|
||||
write(rootDir, { cubeDirs: ['cubes'] });
|
||||
expect(loadConfig().cubeDirs).toEqual(['cubes']);
|
||||
});
|
||||
|
||||
it('does not resolve paths for non-path properties such as hosts', () => {
|
||||
write(rootDir, { hosts: ['@docker/ubuntu', './not-a-path'] });
|
||||
expect(loadConfig().hosts).toEqual(['@docker/ubuntu', './not-a-path']);
|
||||
});
|
||||
|
||||
it('resolves each config file against its own directory', () => {
|
||||
const child = path.join(rootDir, 'child');
|
||||
write(rootDir, { cubeDirs: ['./cubes'] });
|
||||
write(child, { cubeDirs: ['./cubes'] });
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().cubeDirs).toEqual([
|
||||
path.join(rootDir, 'cubes'),
|
||||
path.join(child, 'cubes'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveConfig', () => {
|
||||
it('writes a new config file at the given path', () => {
|
||||
const target = path.join(rootDir, 'custom.json');
|
||||
saveConfig({ hosts: ['web-1'] }, target);
|
||||
|
||||
expect(JSON.parse(fs.readFileSync(target, 'utf-8'))).toEqual({ hosts: ['web-1'] });
|
||||
});
|
||||
|
||||
it('defaults to .nopyrc.json in the cwd', () => {
|
||||
saveConfig({ hosts: ['web-1'] });
|
||||
const written = path.join(rootDir, '.nopyrc.json');
|
||||
|
||||
expect(fs.existsSync(written)).toBe(true);
|
||||
expect(JSON.parse(fs.readFileSync(written, 'utf-8')).hosts).toEqual(['web-1']);
|
||||
});
|
||||
|
||||
it('shallow merges over an existing file', () => {
|
||||
write(rootDir, { hosts: ['old'], env: { KEEP: '1' } });
|
||||
saveConfig({ hosts: ['new'] });
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(path.join(rootDir, '.nopyrc.json'), 'utf-8'));
|
||||
expect(result).toEqual({ hosts: ['new'], env: { KEEP: '1' } });
|
||||
});
|
||||
|
||||
it('starts fresh when the existing file is unparseable', () => {
|
||||
fs.writeFileSync(path.join(rootDir, '.nopyrc.json'), '{{{ broken');
|
||||
saveConfig({ hosts: ['new'] });
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(path.join(rootDir, '.nopyrc.json'), 'utf-8'));
|
||||
expect(result).toEqual({ hosts: ['new'] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type LogConfig, logConfigToFlags } from '../src/nopy.config.js';
|
||||
import { logConfigToFlags } from '../src/nopy.config.js';
|
||||
|
||||
describe('logConfigToFlags', () => {
|
||||
it('returns empty array for silent verbosity', () => {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Edge cases for BuildContext: unknown cubes, session replay and auth flags.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { BuildContext } from '../src/cubes/dependencies.js';
|
||||
import { Cube, Manifest } from '../src/cubes/types.js';
|
||||
import { Variables } from '../src/nopy.common.js';
|
||||
import type { NopyConfig } from '../src/nopy.config.js';
|
||||
import type { NopySession } from '../src/nopy.session.js';
|
||||
|
||||
vi.mock('../src/nopy.prompts.js', async () => {
|
||||
const actual = await vi.importActual('../src/nopy.prompts.js');
|
||||
return { ...actual, VariableAssignment: vi.fn() };
|
||||
});
|
||||
|
||||
import { VariableAssignment } from '../src/nopy.prompts.js';
|
||||
|
||||
const testCube = (id: string, schema = z.object({})) =>
|
||||
new Cube(Manifest.create({ id, name: `Test ${id}`, schema }), `/test/${id}`, 'deploy.py');
|
||||
|
||||
const config = { env: {} } as NopyConfig;
|
||||
const session = (cubes: NopySession['cubes'] = []) => ({ cubes }) as NopySession;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('BuildContext error handling', () => {
|
||||
it('throws when the requested cube does not exist', async () => {
|
||||
const context = new BuildContext({}, new Variables(), session(), config, { method: 'ssh' });
|
||||
|
||||
await expect(context.resolveCube('ghost', 'host1')).rejects.toThrow('Cube not found: ghost');
|
||||
});
|
||||
|
||||
it('throws when a dependency does not exist', async () => {
|
||||
const cubeB = new Cube(
|
||||
Manifest.create({
|
||||
id: 'cube-b',
|
||||
name: 'B',
|
||||
schema: z.object({}),
|
||||
dependencies: () => ['ghost'],
|
||||
}),
|
||||
'/test/cube-b',
|
||||
'deploy.py'
|
||||
);
|
||||
const context = new BuildContext({ 'cube-b': cubeB }, new Variables(), session(), config, {
|
||||
method: 'ssh',
|
||||
});
|
||||
|
||||
await expect(context.resolveCube('cube-b', 'host1')).rejects.toThrow('Cube not found: ghost');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BuildContext session replay', () => {
|
||||
it('takes variables from the session instead of prompting', async () => {
|
||||
const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') }));
|
||||
const vars = new Variables();
|
||||
const context = new BuildContext(
|
||||
{ 'cube-a': cube },
|
||||
vars,
|
||||
session([{ key: 'cube-a', variables: { PORT: '9090' } }]),
|
||||
config,
|
||||
{ method: 'ssh' },
|
||||
{ isSessionReplay: true }
|
||||
);
|
||||
|
||||
await context.resolveCube('cube-a', 'host1');
|
||||
|
||||
expect(VariableAssignment).not.toHaveBeenCalled();
|
||||
expect(context.deployCalls[0].env.PORT).toBe('9090');
|
||||
});
|
||||
|
||||
it('falls back to schema defaults when the session has no entry for the cube', async () => {
|
||||
const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') }));
|
||||
const context = new BuildContext(
|
||||
{ 'cube-a': cube },
|
||||
new Variables(),
|
||||
session([{ key: 'other', variables: { PORT: '9090' } }]),
|
||||
config,
|
||||
{ method: 'ssh' },
|
||||
{ isSessionReplay: true }
|
||||
);
|
||||
|
||||
await context.resolveCube('cube-a', 'host1');
|
||||
|
||||
expect(VariableAssignment).not.toHaveBeenCalled();
|
||||
expect(context.deployCalls[0].env.PORT).toBe('3000');
|
||||
});
|
||||
|
||||
it('prompts when not replaying', async () => {
|
||||
const context = new BuildContext(
|
||||
{ 'cube-a': testCube('cube-a') },
|
||||
new Variables(),
|
||||
session(),
|
||||
config,
|
||||
{ method: 'ssh' }
|
||||
);
|
||||
|
||||
await context.resolveCube('cube-a', 'host1');
|
||||
|
||||
expect(VariableAssignment).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BuildContext command construction', () => {
|
||||
const build = (auth: { method: string; username?: string; password?: string }) => {
|
||||
const context = new BuildContext(
|
||||
{ 'cube-a': testCube('cube-a') },
|
||||
new Variables(),
|
||||
session(),
|
||||
config,
|
||||
auth
|
||||
);
|
||||
return context.resolveCube('cube-a', 'host1').then(() => context);
|
||||
};
|
||||
|
||||
it('adds --user/--password for complete password auth', async () => {
|
||||
const context = await build({ method: 'password', username: 'deploy', password: 'pw' });
|
||||
|
||||
expect(context.deployCalls[0].command.join(' ')).toContain('--user deploy --password pw');
|
||||
});
|
||||
|
||||
it('omits credentials for ssh auth', async () => {
|
||||
const context = await build({ method: 'ssh' });
|
||||
|
||||
expect(context.deployCalls[0].command.join(' ')).not.toContain('--user');
|
||||
});
|
||||
|
||||
it('omits credentials when the password is missing', async () => {
|
||||
const context = await build({ method: 'password', username: 'deploy' });
|
||||
|
||||
expect(context.deployCalls[0].command.join(' ')).not.toContain('--user');
|
||||
});
|
||||
|
||||
it('omits credentials when the username is missing', async () => {
|
||||
const context = await build({ method: 'password', password: 'pw' });
|
||||
|
||||
expect(context.deployCalls[0].command.join(' ')).not.toContain('--user');
|
||||
});
|
||||
|
||||
it('passes cube variables as --data flags and points at the deploy script', async () => {
|
||||
const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') }));
|
||||
const context = new BuildContext({ 'cube-a': cube }, new Variables(), session(), config, {
|
||||
method: 'ssh',
|
||||
});
|
||||
|
||||
await context.resolveCube('cube-a', 'host1');
|
||||
const command = context.deployCalls[0].command.join(' ');
|
||||
|
||||
expect(command).toContain('--data "PORT=3000"');
|
||||
expect(command).toContain('--chdir /test/cube-a');
|
||||
expect(command).toContain('/test/cube-a/deploy.py');
|
||||
expect(context.deployCalls[0].cwd).toBe('/test/cube-a');
|
||||
});
|
||||
|
||||
it('builds a separate call per host but records the cube session once', async () => {
|
||||
const context = new BuildContext(
|
||||
{ 'cube-a': testCube('cube-a') },
|
||||
new Variables(),
|
||||
session(),
|
||||
config,
|
||||
{ method: 'ssh' }
|
||||
);
|
||||
|
||||
await context.resolveCube('cube-a', 'host1');
|
||||
await context.resolveCube('cube-a', 'host2');
|
||||
|
||||
expect(context.deployCalls.map((c) => c.host)).toEqual(['host1', 'host2']);
|
||||
expect(context.cubeSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('applies caller overrides as params', async () => {
|
||||
const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') }));
|
||||
const context = new BuildContext({ 'cube-a': cube }, new Variables(), session(), config, {
|
||||
method: 'ssh',
|
||||
});
|
||||
|
||||
await context.resolveCube('cube-a', 'host1', { PORT: '8080' });
|
||||
|
||||
expect(context.deployCalls[0].env.PORT).toBe('8080');
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,9 @@ describe('BuildContext.resolveCube', () => {
|
||||
const cubeA = createTestCube('cube-a');
|
||||
const cubes = { 'cube-a': cubeA };
|
||||
const vars = new Variables();
|
||||
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' });
|
||||
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, {
|
||||
method: 'ssh',
|
||||
});
|
||||
|
||||
await context.resolveCube('cube-a', 'host1');
|
||||
|
||||
@@ -49,7 +51,9 @@ describe('BuildContext.resolveCube', () => {
|
||||
const cubeB = createTestCube('cube-b', () => ['cube-a']);
|
||||
const cubes = { 'cube-a': cubeA, 'cube-b': cubeB };
|
||||
const vars = new Variables();
|
||||
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' });
|
||||
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, {
|
||||
method: 'ssh',
|
||||
});
|
||||
|
||||
await context.resolveCube('cube-b', 'host1');
|
||||
|
||||
@@ -62,35 +66,41 @@ describe('BuildContext.resolveCube', () => {
|
||||
it('resolves dynamic dependencies based on variables', async () => {
|
||||
const cubeA = createTestCube('cube-a');
|
||||
const cubeB = createTestCube('cube-b');
|
||||
const cubeC = createTestCube('cube-c', (vars) => vars.USE_A ? ['cube-a'] : ['cube-b']);
|
||||
|
||||
const cubeC = createTestCube('cube-c', (vars) => (vars.USE_A ? ['cube-a'] : ['cube-b']));
|
||||
|
||||
cubeC.manifest.schema = z.object({ USE_A: z.boolean().default(true) });
|
||||
|
||||
const cubes = { 'cube-a': cubeA, 'cube-b': cubeB, 'cube-c': cubeC };
|
||||
|
||||
|
||||
// Test with USE_A = true
|
||||
const vars1 = new Variables();
|
||||
const context1 = new BuildContext(cubes, vars1, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' });
|
||||
const context1 = new BuildContext(cubes, vars1, { cubes: [] } as any, { env: {} } as any, {
|
||||
method: 'ssh',
|
||||
});
|
||||
await context1.resolveCube('cube-c', 'host1');
|
||||
expect(context1.deployCalls.map(c => c.cube)).toEqual(['cube-a', 'cube-c']);
|
||||
expect(context1.deployCalls.map((c) => c.cube)).toEqual(['cube-a', 'cube-c']);
|
||||
|
||||
// Test with USE_A = false
|
||||
const vars2 = new Variables();
|
||||
vars2.assign('cube-c', 'params', { USE_A: false });
|
||||
const context2 = new BuildContext(cubes, vars2, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' });
|
||||
const context2 = new BuildContext(cubes, vars2, { cubes: [] } as any, { env: {} } as any, {
|
||||
method: 'ssh',
|
||||
});
|
||||
await context2.resolveCube('cube-c', 'host1');
|
||||
expect(context2.deployCalls.map(c => c.cube)).toEqual(['cube-b', 'cube-c']);
|
||||
expect(context2.deployCalls.map((c) => c.cube)).toEqual(['cube-b', 'cube-c']);
|
||||
});
|
||||
|
||||
it('passes variables to dependencies', async () => {
|
||||
const cubeA = createTestCube('cube-a');
|
||||
cubeA.manifest.schema = z.object({ VAR: z.string() });
|
||||
|
||||
|
||||
const cubeB = createTestCube('cube-b', () => [['cube-a', { VAR: 'from-b' }]]);
|
||||
|
||||
|
||||
const cubes = { 'cube-a': cubeA, 'cube-b': cubeB };
|
||||
const vars = new Variables();
|
||||
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' });
|
||||
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, {
|
||||
method: 'ssh',
|
||||
});
|
||||
|
||||
await context.resolveCube('cube-b', 'host1');
|
||||
|
||||
@@ -102,14 +112,16 @@ describe('BuildContext.resolveCube', () => {
|
||||
const cubeA = createTestCube('cube-a');
|
||||
const cubeB = createTestCube('cube-b', () => ['cube-a']);
|
||||
const cubeC = createTestCube('cube-c', () => ['cube-a', 'cube-b']);
|
||||
|
||||
|
||||
const cubes = { 'cube-a': cubeA, 'cube-b': cubeB, 'cube-c': cubeC };
|
||||
const vars = new Variables();
|
||||
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' });
|
||||
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, {
|
||||
method: 'ssh',
|
||||
});
|
||||
|
||||
await context.resolveCube('cube-c', 'host1');
|
||||
|
||||
// Execution order: cube-a, cube-b, cube-c
|
||||
expect(context.deployCalls.map(c => c.cube)).toEqual(['cube-a', 'cube-b', 'cube-c']);
|
||||
expect(context.deployCalls.map((c) => c.cube)).toEqual(['cube-a', 'cube-b', 'cube-c']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Error and discovery edge cases for cubes/loader.
|
||||
*
|
||||
* Runs against a real temp directory because loadCubes() dynamically imports
|
||||
* manifest files — there is no seam worth faking here.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { findCubeDirectories, getCube, loadCubes } from '../src/cubes/loader.js';
|
||||
|
||||
describe('loader edge cases', () => {
|
||||
let originalCwd: string;
|
||||
let originalHome: string | undefined;
|
||||
let tmpDir: string;
|
||||
let emptyHome: string;
|
||||
|
||||
const cube = (dir: string, manifest: string, deployName = 'deploy.py') => {
|
||||
fs.mkdirSync(path.join(tmpDir, dir), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, dir, 'manifest.mjs'), manifest);
|
||||
fs.writeFileSync(path.join(tmpDir, dir, deployName), '# deploy');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
originalHome = process.env.HOME;
|
||||
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-loader-')));
|
||||
emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-loader-home-')));
|
||||
process.env.HOME = emptyHome;
|
||||
process.chdir(tmpDir);
|
||||
fs.writeFileSync(path.join(tmpDir, '.nopyrc.json'), JSON.stringify({ cubeDirs: ['./'] }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.rmSync(emptyHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('findCubeDirectories', () => {
|
||||
it('includes directories from cubeDirs', () => {
|
||||
expect(findCubeDirectories()).toContain(tmpDir);
|
||||
});
|
||||
|
||||
it('includes directories marked with a .npcubes file', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, '.nopyrc.json'), JSON.stringify({ cubeDirs: [] }));
|
||||
fs.writeFileSync(path.join(tmpDir, '.npcubes'), '');
|
||||
const nested = path.join(tmpDir, 'a', 'b');
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
process.chdir(nested);
|
||||
|
||||
expect(findCubeDirectories()).toContain(tmpDir);
|
||||
});
|
||||
|
||||
it('does not treat a .npcubes directory as a marker', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, '.nopyrc.json'), JSON.stringify({ cubeDirs: [] }));
|
||||
fs.mkdirSync(path.join(tmpDir, '.npcubes'));
|
||||
|
||||
expect(findCubeDirectories()).not.toContain(tmpDir);
|
||||
});
|
||||
|
||||
it('de-duplicates a directory listed twice', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.nopyrc.json'),
|
||||
JSON.stringify({ cubeDirs: ['./', tmpDir] })
|
||||
);
|
||||
fs.writeFileSync(path.join(tmpDir, '.npcubes'), '');
|
||||
|
||||
expect(findCubeDirectories().filter((d) => d === tmpDir)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCubes', () => {
|
||||
it('derives the id from a [bracket] name prefix', async () => {
|
||||
cube('bracketed', 'export default { name: "[apt:base] Apt Base" }');
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(cubes['apt:base'].name).toBe('[apt:base] Apt Base');
|
||||
});
|
||||
|
||||
it('falls back to the directory name when no id is derivable', async () => {
|
||||
cube('fallback-id', 'export default { name: "No Id Here" }');
|
||||
|
||||
const { cubes } = await loadCubes();
|
||||
|
||||
expect(cubes['fallback-id']).toBeDefined();
|
||||
});
|
||||
|
||||
it('defaults the schema when the manifest omits one', async () => {
|
||||
cube('no-schema', 'export default { id: "no-schema", name: "No Schema" }');
|
||||
|
||||
const { cubes } = await loadCubes();
|
||||
|
||||
expect(cubes['no-schema'].getDefaults()).toEqual({});
|
||||
});
|
||||
|
||||
it('reports a manifest whose default export is not an object', async () => {
|
||||
cube('bad-export', 'export default "just a string"');
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
|
||||
expect(cubes['bad-export']).toBeUndefined();
|
||||
expect(errors[0]).toMatch(/Invalid manifest export/);
|
||||
});
|
||||
|
||||
it('reports a manifest with no default export', async () => {
|
||||
cube('no-export', 'export const nothing = 1;');
|
||||
|
||||
const { errors } = await loadCubes();
|
||||
|
||||
expect(errors[0]).toMatch(/Invalid manifest export/);
|
||||
});
|
||||
|
||||
it('reports a manifest missing a name', async () => {
|
||||
cube('no-name', 'export default { id: "no-name" }');
|
||||
|
||||
const { errors } = await loadCubes();
|
||||
|
||||
expect(errors[0]).toMatch(/missing 'name'/);
|
||||
});
|
||||
|
||||
it('reports a manifest that fails to import', async () => {
|
||||
cube('broken', 'this is not valid javascript !!!');
|
||||
|
||||
const { errors } = await loadCubes();
|
||||
|
||||
expect(errors[0]).toMatch(/Failed to load manifest/);
|
||||
});
|
||||
|
||||
it('reports duplicate cube ids', async () => {
|
||||
cube('first', 'export default { id: "dup", name: "First" }');
|
||||
cube('second', 'export default { id: "dup", name: "Second" }');
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
|
||||
expect(Object.keys(cubes)).toEqual(['dup']);
|
||||
expect(errors[0]).toMatch(/Duplicate cube id 'dup'/);
|
||||
});
|
||||
|
||||
it('skips hidden and node_modules directories', async () => {
|
||||
cube('.hidden/inner', 'export default { id: "hidden", name: "Hidden" }');
|
||||
cube('node_modules/pkg', 'export default { id: "vendored", name: "Vendored" }');
|
||||
cube('visible', 'export default { id: "visible", name: "Visible" }');
|
||||
|
||||
const { cubes } = await loadCubes();
|
||||
|
||||
expect(Object.keys(cubes)).toEqual(['visible']);
|
||||
});
|
||||
|
||||
it('ignores configured cube directories that do not exist', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.nopyrc.json'),
|
||||
JSON.stringify({ cubeDirs: ['./', './does-not-exist'] })
|
||||
);
|
||||
cube('visible', 'export default { id: "visible", name: "Visible" }');
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(cubes.visible).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCube', () => {
|
||||
it('returns a single cube by id', async () => {
|
||||
cube('one', 'export default { id: "one", name: "One" }');
|
||||
|
||||
await expect(getCube('one')).resolves.toMatchObject({ id: 'one' });
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown id', async () => {
|
||||
await expect(getCube('nope')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -99,7 +99,7 @@ describe('loadCubes (Integration)', () => {
|
||||
await fs.mkdirp('only-deploy');
|
||||
await fs.writeFile('only-deploy/deploy.py', '# deploy');
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
const { cubes } = await loadCubes();
|
||||
|
||||
expect(Object.keys(cubes)).not.toContain('only-manifest');
|
||||
expect(Object.keys(cubes)).not.toContain('only-deploy');
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Tests for the executeDeployCalls path of nopy.executor.
|
||||
*
|
||||
* execa is mocked so no pyinfra process is ever spawned. Note the shape:
|
||||
* the module calls execa({ shell: true })(command, opts), so the mock is a
|
||||
* factory returning the runner.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const runner = vi.fn();
|
||||
|
||||
vi.mock('execa', () => ({
|
||||
execa: vi.fn(() => runner),
|
||||
}));
|
||||
|
||||
import { execa } from 'execa';
|
||||
import { type DeployCall, executeDeployCalls } from '../src/nopy.executor.js';
|
||||
|
||||
const call = (cube: string, host = 'web-1'): DeployCall => ({
|
||||
cube,
|
||||
host,
|
||||
cwd: `/cubes/${cube}`,
|
||||
command: ['pyinfra', host, '-y', `${cube}.deploy.py`],
|
||||
env: {},
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runner.mockResolvedValue({ exitCode: 0 });
|
||||
});
|
||||
|
||||
describe('executeDeployCalls', () => {
|
||||
it('returns early without spawning anything for an empty list', async () => {
|
||||
const results = await executeDeployCalls([]);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints the plan and skips execution on a dry run', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
const results = await executeDeployCalls([call('cube-a')], { dryRun: true });
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
expect(logSpy.mock.calls.map((c) => c[0]).join('\n')).toContain('Execution Plan');
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('runs the joined command in the call cwd with inherited stdio', async () => {
|
||||
await executeDeployCalls([call('cube-a')]);
|
||||
|
||||
expect(execa).toHaveBeenCalledWith({ shell: true });
|
||||
expect(runner).toHaveBeenCalledWith('pyinfra web-1 -y cube-a.deploy.py', {
|
||||
cwd: '/cubes/cube-a',
|
||||
stdio: 'inherit',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports success with a non-negative duration', async () => {
|
||||
const [result] = await executeDeployCalls([call('cube-a')]);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.cube).toBe('cube-a');
|
||||
expect(result.host).toBe('web-1');
|
||||
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('captures a thrown Error as a failed result rather than rejecting', async () => {
|
||||
runner.mockRejectedValue(new Error('exit code 1'));
|
||||
|
||||
const [result] = await executeDeployCalls([call('cube-a')]);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
expect(result.error?.message).toBe('exit code 1');
|
||||
});
|
||||
|
||||
it('wraps a non-Error rejection into an Error', async () => {
|
||||
runner.mockRejectedValue('boom');
|
||||
|
||||
const [result] = await executeDeployCalls([call('cube-a')]);
|
||||
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
expect(result.error?.message).toBe('boom');
|
||||
});
|
||||
|
||||
it('stops after the first failure by default', async () => {
|
||||
runner.mockRejectedValueOnce(new Error('nope')).mockResolvedValue({ exitCode: 0 });
|
||||
|
||||
const results = await executeDeployCalls([call('cube-a'), call('cube-b')]);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].cube).toBe('cube-a');
|
||||
expect(runner).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps going past a failure when continueOnError is set', async () => {
|
||||
runner.mockRejectedValueOnce(new Error('nope')).mockResolvedValue({ exitCode: 0 });
|
||||
|
||||
const results = await executeDeployCalls([call('cube-a'), call('cube-b')], {
|
||||
continueOnError: true,
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.success)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('invokes onStart before each call', async () => {
|
||||
const onStart = vi.fn();
|
||||
|
||||
await executeDeployCalls([call('cube-a'), call('cube-b', 'web-2')], { onStart });
|
||||
|
||||
expect(onStart.mock.calls).toEqual([
|
||||
['cube-a', 'web-1'],
|
||||
['cube-b', 'web-2'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('invokes onProgress with running completed/total counts', async () => {
|
||||
const onProgress = vi.fn();
|
||||
|
||||
await executeDeployCalls([call('cube-a'), call('cube-b')], { onProgress });
|
||||
|
||||
expect(onProgress).toHaveBeenCalledTimes(2);
|
||||
expect(onProgress.mock.calls[0].slice(1)).toEqual([1, 2]);
|
||||
expect(onProgress.mock.calls[1].slice(1)).toEqual([2, 2]);
|
||||
});
|
||||
|
||||
it('reports progress for the failing call before stopping', async () => {
|
||||
const onProgress = vi.fn();
|
||||
runner.mockRejectedValue(new Error('nope'));
|
||||
|
||||
await executeDeployCalls([call('cube-a'), call('cube-b')], { onProgress });
|
||||
|
||||
expect(onProgress).toHaveBeenCalledTimes(1);
|
||||
expect(onProgress.mock.calls[0][0].success).toBe(false);
|
||||
});
|
||||
|
||||
it('works without any callbacks supplied', async () => {
|
||||
await expect(executeDeployCalls([call('cube-a')])).resolves.toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* Tests for nopy.executor module
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type DeployCall,
|
||||
type ExecutionResult,
|
||||
@@ -104,6 +104,12 @@ describe('outputExecutionPlan', () => {
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
// vitest reuses an existing spy rather than re-wrapping, so recorded calls
|
||||
// would otherwise leak from one test into the next.
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('outputs text format by default', () => {
|
||||
const calls = [createTestCall('cube-a', 'host1')];
|
||||
|
||||
|
||||
@@ -7,17 +7,17 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HISTORY_FILE,
|
||||
type HistoryEntry,
|
||||
type SessionHistory,
|
||||
addToHistory,
|
||||
clearHistory,
|
||||
formatHistoryList,
|
||||
getLastSession,
|
||||
getSessionById,
|
||||
HISTORY_FILE,
|
||||
type HistoryEntry,
|
||||
listHistory,
|
||||
loadHistory,
|
||||
removeFromHistory,
|
||||
type SessionHistory,
|
||||
saveHistory,
|
||||
} from '../src/nopy.history.js';
|
||||
import type { NopySession } from '../src/nopy.session.js';
|
||||
|
||||
@@ -24,24 +24,28 @@ describe('Cube Hooks', () => {
|
||||
|
||||
const cubes: Record<string, Cube> = {
|
||||
main: createMockCube('main', 'Main Cube', {
|
||||
before: [async ({ exec }) => {
|
||||
order.push('main:before');
|
||||
await exec('before-hook', {});
|
||||
}],
|
||||
after: [async ({ exec }) => {
|
||||
order.push('main:after');
|
||||
await exec('after-hook', {});
|
||||
}],
|
||||
before: [
|
||||
async ({ exec }) => {
|
||||
order.push('main:before');
|
||||
await exec('before-hook', {});
|
||||
},
|
||||
],
|
||||
after: [
|
||||
async ({ exec }) => {
|
||||
order.push('main:after');
|
||||
await exec('after-hook', {});
|
||||
},
|
||||
],
|
||||
dependencies: () => ['dep'],
|
||||
}),
|
||||
'before-hook': createMockCube('before-hook', 'Before Hook'),
|
||||
'after-hook': createMockCube('after-hook', 'After Hook'),
|
||||
'dep': createMockCube('dep', 'Dependency'),
|
||||
dep: createMockCube('dep', 'Dependency'),
|
||||
};
|
||||
|
||||
// Note: buildDeployCall also records the main cube execution
|
||||
// We can't easily spy on buildDeployCall, but we can see the resulting deployCalls order
|
||||
|
||||
|
||||
const vars = new Variables();
|
||||
const context = new BuildContext(
|
||||
cubes,
|
||||
@@ -53,7 +57,7 @@ describe('Cube Hooks', () => {
|
||||
|
||||
await context.resolveCube('main', 'host1');
|
||||
|
||||
const callOrder = context.deployCalls.map(c => c.cube);
|
||||
const callOrder = context.deployCalls.map((c) => c.cube);
|
||||
|
||||
// Expected order:
|
||||
// 1. main:before (hook runs)
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Tests for the nopy() orchestrator.
|
||||
*
|
||||
* Every collaborator is mocked: this module's job is wiring and branching, and
|
||||
* the pieces it wires (config loading, cube loading, dependency resolution,
|
||||
* execution) are covered by their own suites.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { NopyConfig } from '../src/nopy.config.js';
|
||||
import type { DeployCall } from '../src/nopy.executor.js';
|
||||
import type { NopySession } from '../src/nopy.session.js';
|
||||
|
||||
// vi.mock factories are hoisted above module scope, so everything they close
|
||||
// over has to be created inside vi.hoisted.
|
||||
const {
|
||||
state,
|
||||
resolveCube,
|
||||
loadCubes,
|
||||
loadConfig,
|
||||
getConfigPaths,
|
||||
runWorkflow,
|
||||
executeDeployCalls,
|
||||
addToHistory,
|
||||
saveSession,
|
||||
} = vi.hoisted(() => {
|
||||
const state = {
|
||||
config: {} as NopyConfig,
|
||||
loadResult: { cubes: {} as Record<string, unknown>, errors: [] as string[] },
|
||||
deployCalls: [] as DeployCall[],
|
||||
cubeSessions: [] as unknown[],
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
resolveCube: vi.fn(),
|
||||
loadCubes: vi.fn(async () => state.loadResult),
|
||||
loadConfig: vi.fn(() => state.config),
|
||||
getConfigPaths: vi.fn(() => ['/project/.nopyrc.json']),
|
||||
runWorkflow: vi.fn(),
|
||||
executeDeployCalls: vi.fn(async () => [] as unknown[]),
|
||||
addToHistory: vi.fn(),
|
||||
saveSession: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
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 }));
|
||||
vi.mock('../src/cubes/dependencies.js', () => ({
|
||||
BuildContext: class {
|
||||
resolveCube = resolveCube;
|
||||
get deployCalls() {
|
||||
return state.deployCalls;
|
||||
}
|
||||
get cubeSessions() {
|
||||
return state.cubeSessions;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock('../src/nopy.executor.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/nopy.executor.js')>();
|
||||
return { ...actual, executeDeployCalls };
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
const call = (cube: string): DeployCall => ({
|
||||
cube,
|
||||
host: 'web-1',
|
||||
cwd: `/cubes/${cube}`,
|
||||
command: ['pyinfra', 'web-1', '-y', `${cube}.deploy.py`],
|
||||
env: {},
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
state.config = { hosts: ['web-1'], cubeDirs: [], env: {} };
|
||||
state.loadResult = { cubes: { 'cube-a': {} }, errors: [] };
|
||||
state.deployCalls = [call('cube-a')];
|
||||
state.cubeSessions = [{ key: 'cube-a', variables: {} }];
|
||||
|
||||
runWorkflow.mockResolvedValue({
|
||||
session: session(),
|
||||
selectedCubes: ['cube-a'],
|
||||
authMethod: 'ssh-key',
|
||||
isReplay: false,
|
||||
});
|
||||
executeDeployCalls.mockResolvedValue([
|
||||
{ cube: 'cube-a', host: 'web-1', success: true, duration: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
describe('nopy', () => {
|
||||
it('runs the happy path and reports success', async () => {
|
||||
const result = await nopy();
|
||||
|
||||
expect(result?.success).toBe(true);
|
||||
expect(result?.summary).toEqual({
|
||||
total: 1,
|
||||
successful: 1,
|
||||
failed: 0,
|
||||
totalDuration: 10,
|
||||
});
|
||||
expect(resolveCube).toHaveBeenCalledWith('cube-a', 'web-1');
|
||||
});
|
||||
|
||||
it('reports failure when any call fails', async () => {
|
||||
executeDeployCalls.mockResolvedValue([
|
||||
{ cube: 'cube-a', host: 'web-1', success: false, duration: 5, error: new Error('x') },
|
||||
]);
|
||||
|
||||
const result = await nopy();
|
||||
|
||||
expect(result?.success).toBe(false);
|
||||
expect(result?.summary.failed).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves every cube against every host', async () => {
|
||||
runWorkflow.mockResolvedValue({
|
||||
session: { ...session(), hosts: ['web-1', 'web-2'] },
|
||||
selectedCubes: ['cube-a', 'cube-b'],
|
||||
authMethod: 'ssh-key',
|
||||
isReplay: false,
|
||||
});
|
||||
|
||||
await nopy();
|
||||
|
||||
expect(resolveCube).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
describe('cube loading errors', () => {
|
||||
it('aborts and returns undefined', async () => {
|
||||
state.loadResult = { cubes: {}, errors: ['bad manifest'] };
|
||||
|
||||
const result = await nopy();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(runWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits the errors as JSON when jsonOutput is set', async () => {
|
||||
state.loadResult = { cubes: {}, errors: ['bad manifest'] };
|
||||
|
||||
await nopy({ jsonOutput: true });
|
||||
|
||||
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string);
|
||||
expect(payload).toEqual({ success: false, errors: ['bad manifest'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('config banner', () => {
|
||||
it('prints the active configuration in interactive mode', async () => {
|
||||
state.config = {
|
||||
hosts: ['web-1'],
|
||||
cubeDirs: ['/cubes'],
|
||||
env: { TOKEN: 'secret', EMPTY: '' },
|
||||
};
|
||||
|
||||
await nopy({ continueOnError: true });
|
||||
|
||||
const text = output();
|
||||
expect(text).toContain('Configuration');
|
||||
expect(text).toContain('Hosts:');
|
||||
expect(text).toContain('Cube dirs:');
|
||||
expect(text).toContain('continue-on-error');
|
||||
// Values are never echoed, only their presence.
|
||||
expect(text).toContain('TOKEN: <VALUE>');
|
||||
expect(text).toContain('EMPTY: <EMPTY>');
|
||||
expect(text).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('omits empty sections', async () => {
|
||||
state.config = { hosts: [], cubeDirs: [], env: {} };
|
||||
|
||||
await nopy();
|
||||
|
||||
const text = output();
|
||||
expect(text).toContain('Configuration');
|
||||
expect(text).not.toContain('Hosts:');
|
||||
expect(text).not.toContain('Cube dirs:');
|
||||
expect(text).not.toContain('Env vars:');
|
||||
});
|
||||
|
||||
it('shortens paths under cwd and under HOME', async () => {
|
||||
getConfigPaths.mockReturnValue([
|
||||
`${process.env.HOME}/.nopyrc.json`,
|
||||
`${process.cwd()}/.nopyrc.json`,
|
||||
'/etc/nopy/.nopyrc.json',
|
||||
]);
|
||||
|
||||
await nopy();
|
||||
|
||||
const text = output();
|
||||
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');
|
||||
});
|
||||
|
||||
it('is suppressed when replaying a session file', async () => {
|
||||
await nopy({ loadSession: '/tmp/s.json' });
|
||||
expect(output()).not.toContain('Configuration');
|
||||
});
|
||||
});
|
||||
|
||||
describe('session persistence', () => {
|
||||
it('saves the session when a path is given', async () => {
|
||||
await nopy({ saveSession: '/tmp/out.json' });
|
||||
|
||||
expect(saveSession).toHaveBeenCalledTimes(1);
|
||||
const [written, path] = saveSession.mock.calls[0];
|
||||
expect(path).toBe('/tmp/out.json');
|
||||
expect(written.cubes).toEqual(state.cubeSessions);
|
||||
});
|
||||
|
||||
it('does not save a replayed session back to file', async () => {
|
||||
runWorkflow.mockResolvedValue({
|
||||
session: session(),
|
||||
selectedCubes: ['cube-a'],
|
||||
authMethod: 'ssh-key',
|
||||
isReplay: true,
|
||||
});
|
||||
|
||||
await nopy({ saveSession: '/tmp/out.json' });
|
||||
|
||||
expect(saveSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not save when no path is given', async () => {
|
||||
await nopy();
|
||||
expect(saveSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('history', () => {
|
||||
it('records the session with the default size', async () => {
|
||||
await nopy();
|
||||
|
||||
expect(addToHistory).toHaveBeenCalledTimes(1);
|
||||
expect(addToHistory.mock.calls[0][1]).toBe(10);
|
||||
});
|
||||
|
||||
it('honours a configured maxSessions', async () => {
|
||||
state.config = { ...state.config, history: { maxSessions: 3 } };
|
||||
|
||||
await nopy();
|
||||
|
||||
expect(addToHistory.mock.calls[0][1]).toBe(3);
|
||||
});
|
||||
|
||||
it('respects autoSave: false', async () => {
|
||||
state.config = { ...state.config, history: { autoSave: false } };
|
||||
|
||||
await nopy();
|
||||
|
||||
expect(addToHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips history on a dry run', async () => {
|
||||
await nopy({ dryRun: true });
|
||||
expect(addToHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips history when the caller opts out', async () => {
|
||||
await nopy({ saveToHistory: false });
|
||||
expect(addToHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips history for a replay', async () => {
|
||||
runWorkflow.mockResolvedValue({
|
||||
session: session(),
|
||||
selectedCubes: ['cube-a'],
|
||||
authMethod: 'ssh-key',
|
||||
isReplay: true,
|
||||
});
|
||||
|
||||
await nopy();
|
||||
|
||||
expect(addToHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips history when nothing would be deployed', async () => {
|
||||
state.deployCalls = [];
|
||||
|
||||
await nopy();
|
||||
|
||||
expect(addToHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('printOnly', () => {
|
||||
it('prints commands and never executes', async () => {
|
||||
await nopy({ printOnly: true });
|
||||
|
||||
const text = output();
|
||||
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('reports the command count as the summary total', async () => {
|
||||
const result = await nopy({ printOnly: true });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
results: [],
|
||||
summary: { total: 1, successful: 0, failed: 0, totalDuration: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('execution options', () => {
|
||||
it('forwards dryRun and continueOnError to the executor', async () => {
|
||||
await nopy({ dryRun: true, continueOnError: true });
|
||||
|
||||
const [, options] = executeDeployCalls.mock.calls[0];
|
||||
expect(options.dryRun).toBe(true);
|
||||
expect(options.continueOnError).toBe(true);
|
||||
});
|
||||
|
||||
it('logs progress lines in interactive mode', async () => {
|
||||
await 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Tests for nopy.prompts.
|
||||
*
|
||||
* inquirer and enquirer are mocked so nothing touches a TTY. What is actually
|
||||
* under test is the logic wrapped around them: choice construction, the `when`
|
||||
* predicates, host-string mapping and zod-driven value coercion.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
const { inquirerPrompt, formRun, autoCompleteRun, autoCompleteCtor } = vi.hoisted(() => ({
|
||||
inquirerPrompt: vi.fn(),
|
||||
formRun: vi.fn(),
|
||||
autoCompleteRun: vi.fn(),
|
||||
autoCompleteCtor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('inquirer', () => ({
|
||||
default: { prompt: inquirerPrompt },
|
||||
}));
|
||||
vi.mock('enquirer', () => ({
|
||||
default: {
|
||||
Form: class {
|
||||
run = formRun;
|
||||
},
|
||||
AutoComplete: class {
|
||||
run = autoCompleteRun;
|
||||
constructor(options: unknown) {
|
||||
autoCompleteCtor(options);
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { Cube, Manifest } from '../src/cubes/types.js';
|
||||
import { Variables } from '../src/nopy.common.js';
|
||||
import {
|
||||
AuthSelection,
|
||||
CubeSelection,
|
||||
HostSelection,
|
||||
PasswordSelection,
|
||||
VariableAssignment,
|
||||
} from '../src/nopy.prompts.js';
|
||||
|
||||
/** Grabs the single question object passed to the last inquirer.prompt call. */
|
||||
const questions = () => inquirerPrompt.mock.calls.at(-1)?.[0] as Record<string, any>[];
|
||||
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>;
|
||||
|
||||
const cube = (id: string, name: string, schema = z.object({})) =>
|
||||
new Cube(Manifest({ id, name, schema }), `/cubes/${id}`, 'deploy.py');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
});
|
||||
|
||||
describe('CubeSelection', () => {
|
||||
const cubes = {
|
||||
b: cube('cube-b', 'Beta'),
|
||||
a: cube('cube-a', 'Alpha'),
|
||||
};
|
||||
|
||||
it('returns the selection', async () => {
|
||||
autoCompleteRun.mockResolvedValue(['cube-a']);
|
||||
|
||||
await expect(CubeSelection(cubes)).resolves.toEqual({ selectedCubes: ['cube-a'] });
|
||||
});
|
||||
|
||||
it('sorts choices by cube id', async () => {
|
||||
autoCompleteRun.mockResolvedValue([]);
|
||||
|
||||
await CubeSelection(cubes);
|
||||
|
||||
const { choices } = autoComplete();
|
||||
expect(choices.map((c: { name: string }) => c.name)).toEqual(['cube-a', 'cube-b']);
|
||||
expect(choices[0].message).toBe('cube-a - Alpha');
|
||||
});
|
||||
|
||||
it('returns every choice for an undefined filter', async () => {
|
||||
autoCompleteRun.mockResolvedValue([]);
|
||||
|
||||
await CubeSelection(cubes);
|
||||
|
||||
const { choices, suggest } = autoComplete();
|
||||
expect(suggest(undefined, choices)).toHaveLength(2);
|
||||
expect(suggest('', choices)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('fuzzy filters on the visible label', async () => {
|
||||
autoCompleteRun.mockResolvedValue([]);
|
||||
|
||||
await CubeSelection(cubes);
|
||||
|
||||
const { choices, suggest } = autoComplete();
|
||||
expect(suggest('Alph', choices).map((c: { name: string }) => c.name)).toEqual(['cube-a']);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// 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 });
|
||||
});
|
||||
|
||||
it('selects nothing when the user cancels', async () => {
|
||||
autoCompleteRun.mockRejectedValue(new Error('cancelled'));
|
||||
|
||||
await expect(CubeSelection(cubes)).resolves.toEqual({ selectedCubes: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthSelection', () => {
|
||||
it('short-circuits to ssh-key without prompting', async () => {
|
||||
await expect(AuthSelection(true)).resolves.toEqual({ authMethod: 'ssh-key' });
|
||||
expect(inquirerPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts when no key is forced', async () => {
|
||||
inquirerPrompt.mockResolvedValue({ authMethod: 'password', username: 'u', password: 'p' });
|
||||
|
||||
await expect(AuthSelection()).resolves.toEqual({
|
||||
authMethod: 'password',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
});
|
||||
});
|
||||
|
||||
it('asks for credentials only when the method is not ssh-key', async () => {
|
||||
inquirerPrompt.mockResolvedValue({ authMethod: 'ssh-key' });
|
||||
|
||||
await AuthSelection(false);
|
||||
|
||||
expect(question('username')?.when({ authMethod: 'password' })).toBe(true);
|
||||
expect(question('username')?.when({ authMethod: 'ssh-key' })).toBe(false);
|
||||
expect(question('password')?.when({ authMethod: 'password' })).toBe(true);
|
||||
expect(question('password')?.when({ authMethod: 'ssh-key' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasswordSelection', () => {
|
||||
it('returns the entered password', async () => {
|
||||
inquirerPrompt.mockResolvedValue({ password: 'hunter2' });
|
||||
|
||||
await expect(PasswordSelection('deploy')).resolves.toBe('hunter2');
|
||||
expect(question('password')?.message).toContain('deploy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HostSelection', () => {
|
||||
it('offers the configured hosts alongside the built-ins', async () => {
|
||||
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
|
||||
|
||||
await HostSelection(['web-1', 'web-2']);
|
||||
|
||||
expect(question('host')?.choices).toEqual(['docker', 'vagrant', 'web-1', 'web-2', 'custom']);
|
||||
});
|
||||
|
||||
it('returns a plain host as-is', async () => {
|
||||
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
|
||||
|
||||
await expect(HostSelection(['web-1'])).resolves.toBe('web-1');
|
||||
});
|
||||
|
||||
it('returns the custom address when custom is chosen', async () => {
|
||||
inquirerPrompt.mockResolvedValue({ host: 'custom', customHost: '10.0.0.5' });
|
||||
|
||||
await expect(HostSelection([])).resolves.toBe('10.0.0.5');
|
||||
});
|
||||
|
||||
it('prefixes a vagrant machine', async () => {
|
||||
inquirerPrompt.mockResolvedValue({ host: 'vagrant', vagrantVM: 'builder' });
|
||||
|
||||
await expect(HostSelection([])).resolves.toBe('@vagrant/builder');
|
||||
});
|
||||
|
||||
it('prefixes a docker container', async () => {
|
||||
inquirerPrompt.mockResolvedValue({ host: 'runtime:docker', dockerContainer: 'box' });
|
||||
|
||||
await expect(HostSelection([])).resolves.toBe('@docker/box');
|
||||
});
|
||||
|
||||
it('gates the follow-up questions on the chosen host', async () => {
|
||||
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
|
||||
|
||||
await HostSelection([]);
|
||||
|
||||
expect(question('customHost')?.when({ host: 'custom' })).toBe(true);
|
||||
expect(question('customHost')?.when({ host: 'web-1' })).toBe(false);
|
||||
expect(question('vagrantVM')?.when({ host: 'vagrant' })).toBe(true);
|
||||
expect(question('vagrantVM')?.when({ host: 'web-1' })).toBe(false);
|
||||
expect(question('dockerContainer')?.when({ host: 'runtime:docker' })).toBe(true);
|
||||
expect(question('dockerContainer')?.when({ host: 'web-1' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VariableAssignment', () => {
|
||||
const schema = z.object({
|
||||
port: z.number().default(8080).describe('Listen port'),
|
||||
enabled: z.boolean().default(false),
|
||||
name: z.string().default('svc'),
|
||||
});
|
||||
|
||||
it('does nothing when every default is already supplied as a param', async () => {
|
||||
const variables = new Variables();
|
||||
variables.assign('svc', 'params', { port: 1, enabled: true, name: 'x' });
|
||||
|
||||
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
||||
|
||||
expect(formRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing for a cube with no defaults', async () => {
|
||||
await VariableAssignment(cube('bare', 'Bare'), new Variables());
|
||||
|
||||
expect(formRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only asks about the variables still missing', async () => {
|
||||
const variables = new Variables();
|
||||
variables.assign('svc', 'params', { port: 9090 });
|
||||
formRun.mockResolvedValue({});
|
||||
|
||||
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
||||
|
||||
expect(formRun).toHaveBeenCalled();
|
||||
expect(variables.get('svc', 'prompts')).toEqual({});
|
||||
});
|
||||
|
||||
it('coerces answers using the schema and stores them under prompts', async () => {
|
||||
const variables = new Variables();
|
||||
formRun.mockResolvedValue({ port: '9090', enabled: 'true', name: 'api' });
|
||||
|
||||
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
||||
|
||||
expect(variables.get('svc', 'prompts')).toEqual({
|
||||
port: 9090,
|
||||
enabled: true,
|
||||
name: 'api',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an unparseable number as the raw string', async () => {
|
||||
const variables = new Variables();
|
||||
formRun.mockResolvedValue({ port: 'not-a-number', enabled: 'no', name: 'api' });
|
||||
|
||||
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
||||
|
||||
expect(variables.get('svc', 'prompts').port).toBe('not-a-number');
|
||||
expect(variables.get('svc', 'prompts').enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts yes and 1 as truthy booleans', async () => {
|
||||
const variables = new Variables();
|
||||
formRun.mockResolvedValue({ port: '1', enabled: 'yes', name: 'api' });
|
||||
|
||||
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
||||
|
||||
expect(variables.get('svc', 'prompts').enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('unwraps optional and nullable schema types', async () => {
|
||||
const nullableSchema = z.object({
|
||||
maybe: z.number().nullable().default(1),
|
||||
opt: z.number().optional().default(2),
|
||||
});
|
||||
const variables = new Variables();
|
||||
formRun.mockResolvedValue({ maybe: 'null', opt: '7' });
|
||||
|
||||
await VariableAssignment(cube('svc', 'Service', nullableSchema), variables);
|
||||
|
||||
expect(variables.get('svc', 'prompts')).toEqual({ maybe: null, opt: 7 });
|
||||
});
|
||||
|
||||
it('treats an empty string as null for a nullable field', async () => {
|
||||
const nullableSchema = z.object({ maybe: z.number().nullable().default(1) });
|
||||
const variables = new Variables();
|
||||
formRun.mockResolvedValue({ maybe: '' });
|
||||
|
||||
await VariableAssignment(cube('svc', 'Service', nullableSchema), variables);
|
||||
|
||||
expect(variables.get('svc', 'prompts').maybe).toBe(null);
|
||||
});
|
||||
|
||||
it('passes non-string answers through untouched', async () => {
|
||||
const variables = new Variables();
|
||||
formRun.mockResolvedValue({ port: 9090, enabled: true, name: 'api' });
|
||||
|
||||
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
||||
|
||||
expect(variables.get('svc', 'prompts').port).toBe(9090);
|
||||
});
|
||||
|
||||
it('keeps answers for keys the schema does not describe', async () => {
|
||||
const variables = new Variables();
|
||||
formRun.mockResolvedValue({ port: '1', enabled: 'true', name: 'api', extra: 'kept' });
|
||||
|
||||
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
||||
|
||||
expect(variables.get('svc', 'prompts').extra).toBe('kept');
|
||||
});
|
||||
|
||||
it('assigns nothing when the user cancels the form', async () => {
|
||||
const variables = new Variables();
|
||||
formRun.mockRejectedValue(new Error('cancelled'));
|
||||
|
||||
await expect(
|
||||
VariableAssignment(cube('svc', 'Service', schema), variables)
|
||||
).resolves.toBeUndefined();
|
||||
expect(variables.get('svc', 'prompts')).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,11 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
type NopySession,
|
||||
createSession,
|
||||
filterInternalVariables,
|
||||
listSessions,
|
||||
loadSession,
|
||||
type NopySession,
|
||||
saveSession,
|
||||
separateEnvAndCubeVariables,
|
||||
} from '../src/nopy.session.js';
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Tests for nopy.workflow module.
|
||||
*
|
||||
* The prompt layer is the only I/O in this module, so mocking nopy.prompts
|
||||
* exercises every branch without touching a TTY.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../src/nopy.prompts.js', () => ({
|
||||
CubeSelection: vi.fn(),
|
||||
HostSelection: vi.fn(),
|
||||
AuthSelection: vi.fn(),
|
||||
PasswordSelection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../src/nopy.session.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/nopy.session.js')>();
|
||||
return { ...actual, loadSession: vi.fn() };
|
||||
});
|
||||
|
||||
import type { Cube } from '../src/cubes/index.js';
|
||||
import type { NopyConfig } from '../src/nopy.config.js';
|
||||
import {
|
||||
AuthSelection,
|
||||
CubeSelection,
|
||||
HostSelection,
|
||||
PasswordSelection,
|
||||
} from '../src/nopy.prompts.js';
|
||||
import type { NopySession } from '../src/nopy.session.js';
|
||||
import { loadSession } from '../src/nopy.session.js';
|
||||
import {
|
||||
runInteractiveWorkflow,
|
||||
runReplayWorkflow,
|
||||
runSessionReplayWorkflow,
|
||||
runWorkflow,
|
||||
} from '../src/nopy.workflow.js';
|
||||
|
||||
const mockCubeSelection = vi.mocked(CubeSelection);
|
||||
const mockHostSelection = vi.mocked(HostSelection);
|
||||
const mockAuthSelection = vi.mocked(AuthSelection);
|
||||
const mockPasswordSelection = vi.mocked(PasswordSelection);
|
||||
const mockLoadSession = vi.mocked(loadSession);
|
||||
|
||||
const config: NopyConfig = {
|
||||
hosts: ['web-1', 'web-2'],
|
||||
cubeDirs: [],
|
||||
env: { GLOBAL: 'value' },
|
||||
};
|
||||
|
||||
const cubes = {
|
||||
'cube-a': { id: 'cube-a', name: 'Cube A' } as Cube,
|
||||
};
|
||||
|
||||
const session = (overrides: Partial<NopySession> = {}): NopySession =>
|
||||
({
|
||||
version: '1.0',
|
||||
name: 'test-session',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
cubes: [{ key: 'cube-a', variables: {} }],
|
||||
hosts: ['web-1'],
|
||||
auth: { method: 'ssh-key' },
|
||||
env: {},
|
||||
...overrides,
|
||||
}) as NopySession;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCubeSelection.mockResolvedValue({ selectedCubes: ['cube-a'] });
|
||||
mockHostSelection.mockResolvedValue('web-1');
|
||||
mockAuthSelection.mockResolvedValue({ authMethod: 'ssh-key' });
|
||||
mockPasswordSelection.mockResolvedValue('s3cret');
|
||||
});
|
||||
|
||||
describe('runInteractiveWorkflow', () => {
|
||||
it('collects cubes, host and auth into a fresh session', async () => {
|
||||
const result = await runInteractiveWorkflow(cubes, config);
|
||||
|
||||
expect(result.selectedCubes).toEqual(['cube-a']);
|
||||
expect(result.authMethod).toBe('ssh-key');
|
||||
expect(result.isReplay).toBe(false);
|
||||
expect(result.session.hosts).toEqual(['web-1']);
|
||||
expect(result.session.env).toEqual({ GLOBAL: 'value' });
|
||||
expect(mockHostSelection).toHaveBeenCalledWith(config.hosts);
|
||||
});
|
||||
|
||||
it('forwards useAuthKey to the auth prompt', async () => {
|
||||
await runInteractiveWorkflow(cubes, config, { useAuthKey: true });
|
||||
expect(mockAuthSelection).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('carries username and password through from password auth', async () => {
|
||||
mockAuthSelection.mockResolvedValue({
|
||||
authMethod: 'password',
|
||||
username: 'deploy',
|
||||
password: 'hunter2',
|
||||
});
|
||||
|
||||
const result = await runInteractiveWorkflow(cubes, config);
|
||||
|
||||
expect(result.username).toBe('deploy');
|
||||
expect(result.password).toBe('hunter2');
|
||||
expect(result.session.auth.username).toBe('deploy');
|
||||
});
|
||||
|
||||
it('skips the auth prompt entirely for vagrant hosts', async () => {
|
||||
mockHostSelection.mockResolvedValue('@vagrant/default');
|
||||
|
||||
const result = await runInteractiveWorkflow(cubes, config);
|
||||
|
||||
expect(mockAuthSelection).not.toHaveBeenCalled();
|
||||
expect(result.authMethod).toBe('ssh');
|
||||
expect(result.username).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips the auth prompt entirely for docker hosts', async () => {
|
||||
mockHostSelection.mockResolvedValue('@docker/box');
|
||||
|
||||
const result = await runInteractiveWorkflow(cubes, config);
|
||||
|
||||
expect(mockAuthSelection).not.toHaveBeenCalled();
|
||||
expect(result.authMethod).toBe('ssh');
|
||||
});
|
||||
|
||||
it('proceeds when the user selects nothing', async () => {
|
||||
mockCubeSelection.mockResolvedValue({ selectedCubes: [] });
|
||||
|
||||
const result = await runInteractiveWorkflow(cubes, config);
|
||||
|
||||
expect(result.selectedCubes).toEqual([]);
|
||||
});
|
||||
|
||||
it('never stores a password on the session', async () => {
|
||||
mockAuthSelection.mockResolvedValue({
|
||||
authMethod: 'password',
|
||||
username: 'deploy',
|
||||
password: 'hunter2',
|
||||
});
|
||||
|
||||
const result = await runInteractiveWorkflow(cubes, config);
|
||||
|
||||
expect(JSON.stringify(result.session)).not.toContain('hunter2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runReplayWorkflow', () => {
|
||||
it('replays a session file without prompting', async () => {
|
||||
mockLoadSession.mockResolvedValue(session());
|
||||
|
||||
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
|
||||
|
||||
expect(mockLoadSession).toHaveBeenCalledWith('/tmp/s.json');
|
||||
expect(result.isReplay).toBe(true);
|
||||
expect(result.selectedCubes).toEqual(['cube-a']);
|
||||
expect(mockHostSelection).not.toHaveBeenCalled();
|
||||
expect(mockPasswordSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tolerates a session referencing an unknown cube', async () => {
|
||||
mockLoadSession.mockResolvedValue(
|
||||
session({ cubes: [{ key: 'ghost-cube', variables: {} }] } as Partial<NopySession>)
|
||||
);
|
||||
|
||||
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
|
||||
|
||||
expect(result.selectedCubes).toEqual(['ghost-cube']);
|
||||
});
|
||||
|
||||
it('prompts for a host when the session has none', async () => {
|
||||
mockLoadSession.mockResolvedValue(session({ hosts: [] }));
|
||||
|
||||
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
|
||||
|
||||
expect(mockHostSelection).toHaveBeenCalledWith(config.hosts);
|
||||
expect(result.session.hosts).toEqual(['web-1']);
|
||||
});
|
||||
|
||||
it('prompts for a host when hosts is missing entirely', async () => {
|
||||
mockLoadSession.mockResolvedValue(session({ hosts: undefined }));
|
||||
|
||||
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
|
||||
|
||||
expect(result.session.hosts).toEqual(['web-1']);
|
||||
});
|
||||
|
||||
it('re-prompts only for the password when a username is stored', async () => {
|
||||
mockLoadSession.mockResolvedValue(
|
||||
session({ auth: { method: 'password', username: 'deploy' } })
|
||||
);
|
||||
|
||||
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
|
||||
|
||||
expect(mockPasswordSelection).toHaveBeenCalledWith('deploy');
|
||||
expect(mockAuthSelection).not.toHaveBeenCalled();
|
||||
expect(result.password).toBe('s3cret');
|
||||
expect(result.username).toBe('deploy');
|
||||
});
|
||||
|
||||
it('falls back to the full auth prompt when the username is missing', async () => {
|
||||
mockLoadSession.mockResolvedValue(session({ auth: { method: 'password' } }));
|
||||
mockAuthSelection.mockResolvedValue({
|
||||
authMethod: 'password',
|
||||
username: 'recovered',
|
||||
password: 'fresh',
|
||||
});
|
||||
|
||||
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
|
||||
|
||||
expect(mockAuthSelection).toHaveBeenCalledWith(false);
|
||||
expect(mockPasswordSelection).not.toHaveBeenCalled();
|
||||
expect(result.username).toBe('recovered');
|
||||
expect(result.password).toBe('fresh');
|
||||
});
|
||||
|
||||
it('propagates load failures', async () => {
|
||||
mockLoadSession.mockRejectedValue(new Error('missing file'));
|
||||
|
||||
await expect(runReplayWorkflow('/tmp/nope.json', cubes, config)).rejects.toThrow(
|
||||
'missing file'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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.selectedCubes).toEqual(['cube-a']);
|
||||
expect(mockLoadSession).not.toHaveBeenCalled();
|
||||
expect(mockHostSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tolerates a session referencing an unknown cube', async () => {
|
||||
const result = await runSessionReplayWorkflow(
|
||||
session({ cubes: [{ key: 'ghost-cube', variables: {} }] } as Partial<NopySession>),
|
||||
cubes,
|
||||
config
|
||||
);
|
||||
|
||||
expect(result.selectedCubes).toEqual(['ghost-cube']);
|
||||
});
|
||||
|
||||
it('prompts for a host when the session has none', async () => {
|
||||
const result = await runSessionReplayWorkflow(session({ hosts: [] }), cubes, config);
|
||||
|
||||
expect(mockHostSelection).toHaveBeenCalled();
|
||||
expect(result.session.hosts).toEqual(['web-1']);
|
||||
});
|
||||
|
||||
it('prompts for a host when hosts is missing entirely', async () => {
|
||||
const result = await runSessionReplayWorkflow(session({ hosts: undefined }), cubes, config);
|
||||
|
||||
expect(result.session.hosts).toEqual(['web-1']);
|
||||
});
|
||||
|
||||
it('re-prompts only for the password when a username is stored', async () => {
|
||||
const result = await runSessionReplayWorkflow(
|
||||
session({ auth: { method: 'password', username: 'deploy' } }),
|
||||
cubes,
|
||||
config
|
||||
);
|
||||
|
||||
expect(mockPasswordSelection).toHaveBeenCalledWith('deploy');
|
||||
expect(result.password).toBe('s3cret');
|
||||
});
|
||||
|
||||
it('falls back to the full auth prompt when the username is missing', async () => {
|
||||
mockAuthSelection.mockResolvedValue({
|
||||
authMethod: 'password',
|
||||
username: 'recovered',
|
||||
password: 'fresh',
|
||||
});
|
||||
|
||||
const result = await runSessionReplayWorkflow(
|
||||
session({ auth: { method: 'password' } }),
|
||||
cubes,
|
||||
config
|
||||
);
|
||||
|
||||
expect(mockAuthSelection).toHaveBeenCalledWith(false);
|
||||
expect(result.username).toBe('recovered');
|
||||
});
|
||||
});
|
||||
|
||||
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(mockLoadSession).not.toHaveBeenCalled();
|
||||
expect(mockCubeSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the session file when no in-memory session is given', async () => {
|
||||
mockLoadSession.mockResolvedValue(session());
|
||||
|
||||
const result = await runWorkflow('/tmp/s.json', cubes, config);
|
||||
|
||||
expect(mockLoadSession).toHaveBeenCalledWith('/tmp/s.json');
|
||||
expect(result.isReplay).toBe(true);
|
||||
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(mockCubeSelection).toHaveBeenCalled();
|
||||
expect(mockAuthSelection).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user