initial transfer
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Tests for nopy.config module
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type LogConfig, logConfigToFlags } from '../src/nopy.config.js';
|
||||
|
||||
describe('logConfigToFlags', () => {
|
||||
it('returns empty array for silent verbosity', () => {
|
||||
const flags = logConfigToFlags({ verbosity: 'silent' });
|
||||
expect(flags).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for undefined config', () => {
|
||||
const flags = logConfigToFlags(undefined);
|
||||
expect(flags).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty config', () => {
|
||||
const flags = logConfigToFlags({});
|
||||
expect(flags).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns -v for info verbosity', () => {
|
||||
const flags = logConfigToFlags({ verbosity: 'info' });
|
||||
expect(flags).toEqual(['-v']);
|
||||
});
|
||||
|
||||
it('returns -vv for verbose verbosity', () => {
|
||||
const flags = logConfigToFlags({ verbosity: 'verbose' });
|
||||
expect(flags).toEqual(['-vv']);
|
||||
});
|
||||
|
||||
it('returns -vvv for trace verbosity', () => {
|
||||
const flags = logConfigToFlags({ verbosity: 'trace' });
|
||||
expect(flags).toEqual(['-vvv']);
|
||||
});
|
||||
|
||||
it('adds --debug when debug is true', () => {
|
||||
const flags = logConfigToFlags({ debug: true });
|
||||
expect(flags).toContain('--debug');
|
||||
});
|
||||
|
||||
it('combines verbosity and debug', () => {
|
||||
const flags = logConfigToFlags({ verbosity: 'verbose', debug: true });
|
||||
expect(flags).toContain('-vv');
|
||||
expect(flags).toContain('--debug');
|
||||
expect(flags).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not add --debug when debug is false', () => {
|
||||
const flags = logConfigToFlags({ verbosity: 'info', debug: false });
|
||||
expect(flags).toEqual(['-v']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Tests for cubes/dependencies module (BuildContext)
|
||||
*/
|
||||
|
||||
import { 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';
|
||||
|
||||
// Mock VariableAssignment to avoid hanging on prompts
|
||||
vi.mock('../src/nopy.prompts.js', async () => {
|
||||
const actual = await vi.importActual('../src/nopy.prompts.js');
|
||||
return {
|
||||
...actual,
|
||||
VariableAssignment: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create a minimal cube for testing
|
||||
*/
|
||||
function createTestCube(id: string, dependencies?: (vars: any) => any[]): Cube {
|
||||
const manifest = Manifest.create({
|
||||
id,
|
||||
name: `Test ${id}`,
|
||||
schema: z.object({}),
|
||||
dependencies,
|
||||
});
|
||||
return new Cube(manifest, `/test/${id}`, 'deploy.py');
|
||||
}
|
||||
|
||||
describe('BuildContext.resolveCube', () => {
|
||||
it('resolves a single cube with no dependencies', async () => {
|
||||
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' });
|
||||
|
||||
await context.resolveCube('cube-a', 'host1');
|
||||
|
||||
expect(context.deployCalls).toHaveLength(1);
|
||||
expect(context.deployCalls[0].cube).toBe('cube-a');
|
||||
expect(context.deployCalls[0].host).toBe('host1');
|
||||
});
|
||||
|
||||
it('resolves dependencies recursively', async () => {
|
||||
const cubeA = createTestCube('cube-a');
|
||||
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' });
|
||||
|
||||
await context.resolveCube('cube-b', 'host1');
|
||||
|
||||
expect(context.deployCalls).toHaveLength(2);
|
||||
// Dependencies should be resolved BEFORE the cube that depends on them
|
||||
expect(context.deployCalls[0].cube).toBe('cube-a');
|
||||
expect(context.deployCalls[1].cube).toBe('cube-b');
|
||||
});
|
||||
|
||||
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']);
|
||||
|
||||
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' });
|
||||
await context1.resolveCube('cube-c', 'host1');
|
||||
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' });
|
||||
await context2.resolveCube('cube-c', 'host1');
|
||||
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' });
|
||||
|
||||
await context.resolveCube('cube-b', 'host1');
|
||||
|
||||
expect(context.deployCalls[0].cube).toBe('cube-a');
|
||||
expect(context.deployCalls[0].env.VAR).toBe('from-b');
|
||||
});
|
||||
|
||||
it('avoids duplicate calls for the same cube on the same host', async () => {
|
||||
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' });
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Tests for cubes/factories module
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { createManifest, manifest } from '../src/cubes/factories.js';
|
||||
|
||||
describe('createManifest', () => {
|
||||
it('creates manifest with basic properties', () => {
|
||||
const m = createManifest({
|
||||
id: 'test-cube',
|
||||
name: 'Test Cube',
|
||||
});
|
||||
|
||||
expect(m.id).toBe('test-cube');
|
||||
expect(m.name).toBe('Test Cube');
|
||||
expect(m.schema).toBeDefined();
|
||||
expect(m.before).toEqual([]);
|
||||
expect(m.after).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts schema', () => {
|
||||
const schema = z.object({
|
||||
VERSION: z.string().default('1.0'),
|
||||
});
|
||||
|
||||
const m = createManifest({
|
||||
name: 'Test Cube',
|
||||
schema,
|
||||
});
|
||||
|
||||
expect(m.schema).toBe(schema);
|
||||
});
|
||||
|
||||
it('manifest is alias for createManifest', () => {
|
||||
expect(manifest).toBe(createManifest);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Integration tests for cubes/loader module
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { fs } from 'zx';
|
||||
import { loadCubes } from '../src/cubes/loader.js';
|
||||
|
||||
describe('loadCubes (Integration)', () => {
|
||||
let tmpDir: string;
|
||||
let originalCwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalCwd = process.cwd();
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nopy-test-'));
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(originalCwd);
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
it('recursively discovers cubes in a nested hierarchy', async () => {
|
||||
await fs.writeFile('.npcubes', '');
|
||||
await fs.writeJson('.nopyrc.json', { cubeDirs: ['./'] });
|
||||
|
||||
await fs.mkdirp('apt/base');
|
||||
await fs.mkdirp('apt/essentials');
|
||||
|
||||
await fs.writeFile(
|
||||
'apt/base/manifest.mjs',
|
||||
`
|
||||
export default {
|
||||
id: 'apt-base',
|
||||
name: 'Apt Base'
|
||||
}
|
||||
`
|
||||
);
|
||||
await fs.writeFile('apt/base/deploy.py', '# deploy');
|
||||
|
||||
await fs.writeFile(
|
||||
'apt/essentials/manifest.mjs',
|
||||
`
|
||||
export default {
|
||||
id: 'apt:essentials',
|
||||
name: 'Apt Essentials',
|
||||
dependencies: () => ['apt-base']
|
||||
}
|
||||
`
|
||||
);
|
||||
await fs.writeFile('apt/essentials/deploy.py', '# deploy');
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(Object.keys(cubes)).toContain('apt-base');
|
||||
expect(Object.keys(cubes)).toContain('apt:essentials');
|
||||
|
||||
expect(cubes['apt-base'].name).toBe('Apt Base');
|
||||
expect(cubes['apt-base'].deployScript).toBe('deploy.py');
|
||||
expect(cubes['apt:essentials'].manifest.dependencies!({})).toContain('apt-base');
|
||||
expect(cubes['apt:essentials'].deployScript).toBe('deploy.py');
|
||||
});
|
||||
|
||||
it('handles relaxed naming conventions (*.manifest.mjs and *.deploy.py)', async () => {
|
||||
await fs.writeFile('.npcubes', '');
|
||||
await fs.writeJson('.nopyrc.json', { cubeDirs: ['./'] });
|
||||
|
||||
await fs.mkdirp('custom');
|
||||
await fs.writeFile(
|
||||
'custom/my.manifest.mjs',
|
||||
`
|
||||
export default { id: 'custom-cube', name: 'Custom Name' }
|
||||
`
|
||||
);
|
||||
await fs.writeFile('custom/my.deploy.py', '# deploy');
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(Object.keys(cubes)).toContain('custom-cube');
|
||||
expect(cubes['custom-cube'].name).toBe('Custom Name');
|
||||
expect(cubes['custom-cube'].deployScript).toBe('my.deploy.py');
|
||||
});
|
||||
|
||||
it('ignores directories without both manifest and deploy files', async () => {
|
||||
await fs.writeFile('.npcubes', '');
|
||||
await fs.writeJson('.nopyrc.json', { cubeDirs: ['./'] });
|
||||
|
||||
await fs.mkdirp('only-manifest');
|
||||
await fs.writeFile(
|
||||
'only-manifest/manifest.mjs',
|
||||
'export default { id: "only-manifest", name: "test" }'
|
||||
);
|
||||
|
||||
await fs.mkdirp('only-deploy');
|
||||
await fs.writeFile('only-deploy/deploy.py', '# deploy');
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
|
||||
expect(Object.keys(cubes)).not.toContain('only-manifest');
|
||||
expect(Object.keys(cubes)).not.toContain('only-deploy');
|
||||
});
|
||||
|
||||
it('loads manifest schema and getDefaults', async () => {
|
||||
await fs.writeFile('.npcubes', '');
|
||||
await fs.writeJson('.nopyrc.json', { cubeDirs: ['./'] });
|
||||
|
||||
await fs.mkdirp('test-schema');
|
||||
await fs.writeFile(
|
||||
'test-schema/manifest.mjs',
|
||||
`
|
||||
import { z } from 'zod';
|
||||
export default {
|
||||
id: 'test-schema',
|
||||
name: 'Test Schema Cube',
|
||||
schema: z.object({
|
||||
PORT: z.string().default('3000'),
|
||||
HOST: z.string().default('localhost'),
|
||||
})
|
||||
}
|
||||
`
|
||||
);
|
||||
await fs.writeFile('test-schema/deploy.py', '# deploy');
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(cubes['test-schema']).toBeDefined();
|
||||
expect(cubes['test-schema'].getDefaults()).toEqual({
|
||||
PORT: '3000',
|
||||
HOST: 'localhost',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Tests for cubes/utils module
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { uniqid } from '../src/cubes/utils.js';
|
||||
|
||||
describe('uniqid', () => {
|
||||
it('generates string of default length (5)', () => {
|
||||
const id = uniqid();
|
||||
expect(id).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('generates string of specified length', () => {
|
||||
expect(uniqid(10)).toHaveLength(10);
|
||||
expect(uniqid(3)).toHaveLength(3);
|
||||
expect(uniqid(20)).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('generates alphanumeric characters only', () => {
|
||||
const id = uniqid(100);
|
||||
expect(id).toMatch(/^[A-Za-z0-9]+$/);
|
||||
});
|
||||
|
||||
it('generates different values on subsequent calls', () => {
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
ids.add(uniqid(10));
|
||||
}
|
||||
// Should have many unique values (some collisions possible but unlikely)
|
||||
expect(ids.size).toBeGreaterThan(90);
|
||||
});
|
||||
|
||||
it('handles edge case of length 1', () => {
|
||||
const id = uniqid(1);
|
||||
expect(id).toHaveLength(1);
|
||||
expect(id).toMatch(/^[A-Za-z0-9]$/);
|
||||
});
|
||||
|
||||
it('handles edge case of length 0', () => {
|
||||
const id = uniqid(0);
|
||||
expect(id).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Tests for nopy.executor module
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type DeployCall,
|
||||
type ExecutionResult,
|
||||
outputExecutionPlan,
|
||||
summarizeResults,
|
||||
} from '../src/nopy.executor.js';
|
||||
|
||||
/**
|
||||
* Helper to create a test deploy call
|
||||
*/
|
||||
function createTestCall(cube: string, host: string, deps: string[] = []): DeployCall {
|
||||
return {
|
||||
cube,
|
||||
host,
|
||||
cwd: `/test/${cube}`,
|
||||
command: ['pyinfra', host, '-y', `${cube}.deploy.py`],
|
||||
env: { VAR: 'value' },
|
||||
dependencies: deps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a test execution result
|
||||
*/
|
||||
function createTestResult(
|
||||
cube: string,
|
||||
host: string,
|
||||
success: boolean,
|
||||
duration = 1000
|
||||
): ExecutionResult {
|
||||
return {
|
||||
cube,
|
||||
host,
|
||||
success,
|
||||
duration,
|
||||
...(success ? {} : { error: new Error('Test error') }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('summarizeResults', () => {
|
||||
it('summarizes successful results', () => {
|
||||
const results: ExecutionResult[] = [
|
||||
createTestResult('cube-a', 'host1', true, 1000),
|
||||
createTestResult('cube-b', 'host1', true, 2000),
|
||||
];
|
||||
|
||||
const summary = summarizeResults(results);
|
||||
|
||||
expect(summary.total).toBe(2);
|
||||
expect(summary.successful).toBe(2);
|
||||
expect(summary.failed).toBe(0);
|
||||
expect(summary.totalDuration).toBe(3000);
|
||||
expect(summary.failures).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('summarizes failed results', () => {
|
||||
const results: ExecutionResult[] = [
|
||||
createTestResult('cube-a', 'host1', true, 1000),
|
||||
createTestResult('cube-b', 'host1', false, 500),
|
||||
];
|
||||
|
||||
const summary = summarizeResults(results);
|
||||
|
||||
expect(summary.total).toBe(2);
|
||||
expect(summary.successful).toBe(1);
|
||||
expect(summary.failed).toBe(1);
|
||||
expect(summary.totalDuration).toBe(1500);
|
||||
expect(summary.failures).toHaveLength(1);
|
||||
expect(summary.failures[0].cube).toBe('cube-b');
|
||||
});
|
||||
|
||||
it('handles empty results', () => {
|
||||
const summary = summarizeResults([]);
|
||||
|
||||
expect(summary.total).toBe(0);
|
||||
expect(summary.successful).toBe(0);
|
||||
expect(summary.failed).toBe(0);
|
||||
expect(summary.totalDuration).toBe(0);
|
||||
});
|
||||
|
||||
it('handles all failed results', () => {
|
||||
const results: ExecutionResult[] = [
|
||||
createTestResult('cube-a', 'host1', false, 100),
|
||||
createTestResult('cube-b', 'host1', false, 200),
|
||||
];
|
||||
|
||||
const summary = summarizeResults(results);
|
||||
|
||||
expect(summary.successful).toBe(0);
|
||||
expect(summary.failed).toBe(2);
|
||||
expect(summary.failures).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('outputExecutionPlan', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('outputs text format by default', () => {
|
||||
const calls = [createTestCall('cube-a', 'host1')];
|
||||
|
||||
outputExecutionPlan(calls);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
const output = consoleLogSpy.mock.calls.map((c) => c[0]).join('\n');
|
||||
expect(output).toContain('Execution Plan');
|
||||
expect(output).toContain('cube-a');
|
||||
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 password variables in text output', () => {
|
||||
const call: DeployCall = {
|
||||
...createTestCall('cube-a', 'host1'),
|
||||
env: { PASSWORD: 'secret', OTHER: 'visible' },
|
||||
};
|
||||
|
||||
outputExecutionPlan([call]);
|
||||
|
||||
const output = consoleLogSpy.mock.calls.map((c) => c[0]).join('\n');
|
||||
expect(output).toContain('********');
|
||||
expect(output).not.toContain('secret');
|
||||
expect(output).toContain('visible');
|
||||
});
|
||||
|
||||
it('shows step numbers', () => {
|
||||
const calls = [createTestCall('cube-a', 'host1'), createTestCall('cube-b', 'host1')];
|
||||
|
||||
outputExecutionPlan(calls);
|
||||
|
||||
const output = consoleLogSpy.mock.calls.map((c) => c[0]).join('\n');
|
||||
expect(output).toContain('Step 1');
|
||||
expect(output).toContain('Step 2');
|
||||
});
|
||||
|
||||
it('shows total count', () => {
|
||||
const calls = [
|
||||
createTestCall('cube-a', 'host1'),
|
||||
createTestCall('cube-b', 'host1'),
|
||||
createTestCall('cube-c', 'host1'),
|
||||
];
|
||||
|
||||
outputExecutionPlan(calls);
|
||||
|
||||
const output = consoleLogSpy.mock.calls.map((c) => c[0]).join('\n');
|
||||
expect(output).toContain('Total: 3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Tests for nopy.history module
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
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,
|
||||
listHistory,
|
||||
loadHistory,
|
||||
removeFromHistory,
|
||||
saveHistory,
|
||||
} from '../src/nopy.history.js';
|
||||
import type { NopySession } from '../src/nopy.session.js';
|
||||
|
||||
describe('Session History', () => {
|
||||
let originalCwd: string;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-history-test-'));
|
||||
process.chdir(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const createTestSession = (cubes: string[] = ['test-cube']): NopySession => ({
|
||||
cubes: cubes.map((key) => ({ key, variables: {} })),
|
||||
hosts: ['@docker/test'],
|
||||
auth: { method: 'ssh-key' },
|
||||
});
|
||||
|
||||
describe('loadHistory', () => {
|
||||
it('returns empty history when file does not exist', () => {
|
||||
const history = loadHistory();
|
||||
expect(history.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('loads existing history file', () => {
|
||||
const testHistory: SessionHistory = {
|
||||
entries: [
|
||||
{
|
||||
id: 'test-id',
|
||||
name: 'Test Session',
|
||||
timestamp: new Date().toISOString(),
|
||||
session: createTestSession(),
|
||||
},
|
||||
],
|
||||
};
|
||||
fs.writeFileSync(HISTORY_FILE, JSON.stringify(testHistory));
|
||||
|
||||
const history = loadHistory();
|
||||
expect(history.entries).toHaveLength(1);
|
||||
expect(history.entries[0].id).toBe('test-id');
|
||||
});
|
||||
|
||||
it('returns empty history on invalid JSON', () => {
|
||||
fs.writeFileSync(HISTORY_FILE, 'invalid json');
|
||||
const history = loadHistory();
|
||||
expect(history.entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveHistory', () => {
|
||||
it('creates history file', () => {
|
||||
const history: SessionHistory = {
|
||||
entries: [
|
||||
{
|
||||
id: 'test-id',
|
||||
name: 'Test',
|
||||
timestamp: new Date().toISOString(),
|
||||
session: createTestSession(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
saveHistory(history);
|
||||
|
||||
expect(fs.existsSync(HISTORY_FILE)).toBe(true);
|
||||
const content = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
|
||||
expect(content.entries).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToHistory', () => {
|
||||
it('adds session to empty history', () => {
|
||||
const session = createTestSession(['apt:essentials']);
|
||||
const entry = addToHistory(session);
|
||||
|
||||
expect(entry.id).toBeDefined();
|
||||
expect(entry.session).toBe(session);
|
||||
|
||||
const history = loadHistory();
|
||||
expect(history.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds newest entries first', () => {
|
||||
addToHistory(createTestSession(['first']));
|
||||
addToHistory(createTestSession(['second']));
|
||||
|
||||
const history = loadHistory();
|
||||
expect(history.entries[0].session.cubes[0].key).toBe('second');
|
||||
expect(history.entries[1].session.cubes[0].key).toBe('first');
|
||||
});
|
||||
|
||||
it('respects max entries limit', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
addToHistory(createTestSession([`cube-${i}`]), 3);
|
||||
}
|
||||
|
||||
const history = loadHistory();
|
||||
expect(history.entries).toHaveLength(3);
|
||||
// Should have newest entries
|
||||
expect(history.entries[0].session.cubes[0].key).toBe('cube-4');
|
||||
expect(history.entries[1].session.cubes[0].key).toBe('cube-3');
|
||||
expect(history.entries[2].session.cubes[0].key).toBe('cube-2');
|
||||
});
|
||||
|
||||
it('generates descriptive name', () => {
|
||||
const session = createTestSession(['apt:essentials', 'runtime:docker']);
|
||||
const entry = addToHistory(session);
|
||||
|
||||
expect(entry.name).toContain('apt:essentials');
|
||||
expect(entry.name).toContain('runtime:docker');
|
||||
expect(entry.name).toContain('@docker/test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLastSession', () => {
|
||||
it('returns undefined for empty history', () => {
|
||||
expect(getLastSession()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns most recent session', () => {
|
||||
addToHistory(createTestSession(['first']));
|
||||
addToHistory(createTestSession(['second']));
|
||||
|
||||
const last = getLastSession();
|
||||
expect(last?.session.cubes[0].key).toBe('second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionById', () => {
|
||||
it('returns undefined for non-existent ID', () => {
|
||||
expect(getSessionById('non-existent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finds session by ID', () => {
|
||||
const entry = addToHistory(createTestSession(['test']));
|
||||
|
||||
const found = getSessionById(entry.id);
|
||||
expect(found?.id).toBe(entry.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listHistory', () => {
|
||||
it('returns empty array for no history', () => {
|
||||
expect(listHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all entries', () => {
|
||||
addToHistory(createTestSession(['a']));
|
||||
addToHistory(createTestSession(['b']));
|
||||
addToHistory(createTestSession(['c']));
|
||||
|
||||
expect(listHistory()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearHistory', () => {
|
||||
it('removes all entries', () => {
|
||||
addToHistory(createTestSession(['a']));
|
||||
addToHistory(createTestSession(['b']));
|
||||
|
||||
clearHistory();
|
||||
|
||||
expect(listHistory()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFromHistory', () => {
|
||||
it('returns false for non-existent ID', () => {
|
||||
expect(removeFromHistory('non-existent')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes specific entry', () => {
|
||||
const entry1 = addToHistory(createTestSession(['a']));
|
||||
const entry2 = addToHistory(createTestSession(['b']));
|
||||
|
||||
const removed = removeFromHistory(entry1.id);
|
||||
|
||||
expect(removed).toBe(true);
|
||||
expect(listHistory()).toHaveLength(1);
|
||||
expect(getSessionById(entry1.id)).toBeUndefined();
|
||||
expect(getSessionById(entry2.id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatHistoryList', () => {
|
||||
it('shows message for empty history', () => {
|
||||
const output = formatHistoryList([]);
|
||||
expect(output).toContain('No sessions');
|
||||
});
|
||||
|
||||
it('formats entries with numbers and IDs', () => {
|
||||
const entries: HistoryEntry[] = [
|
||||
{
|
||||
id: 'abc123',
|
||||
name: 'Test Session',
|
||||
timestamp: new Date().toISOString(),
|
||||
session: createTestSession(),
|
||||
},
|
||||
];
|
||||
|
||||
const output = formatHistoryList(entries);
|
||||
expect(output).toContain('[1]');
|
||||
expect(output).toContain('Test Session');
|
||||
expect(output).toContain('abc123');
|
||||
expect(output).toContain('Total: 1');
|
||||
});
|
||||
|
||||
it('marks first entry with arrow', () => {
|
||||
const entries: HistoryEntry[] = [
|
||||
{
|
||||
id: 'first',
|
||||
name: 'First',
|
||||
timestamp: new Date().toISOString(),
|
||||
session: createTestSession(),
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
name: 'Second',
|
||||
timestamp: new Date().toISOString(),
|
||||
session: createTestSession(),
|
||||
},
|
||||
];
|
||||
|
||||
const output = formatHistoryList(entries);
|
||||
expect(output).toContain('→ [1]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Tests for cube hooks using BuildContext
|
||||
*/
|
||||
|
||||
import { describe, expect, it } 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';
|
||||
|
||||
describe('Cube Hooks', () => {
|
||||
const createMockCube = (id: string, name: string, manifest: Partial<Manifest> = {}): Cube => {
|
||||
const m = Manifest.create({
|
||||
id,
|
||||
name,
|
||||
schema: z.object({}),
|
||||
...manifest,
|
||||
});
|
||||
return new Cube(m, `/tmp/${id}`, 'deploy.py');
|
||||
};
|
||||
|
||||
it('should execute hooks in the correct order', async () => {
|
||||
const order: string[] = [];
|
||||
|
||||
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', {});
|
||||
}],
|
||||
dependencies: () => ['dep'],
|
||||
}),
|
||||
'before-hook': createMockCube('before-hook', 'Before Hook'),
|
||||
'after-hook': createMockCube('after-hook', 'After Hook'),
|
||||
'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,
|
||||
vars,
|
||||
{ hosts: ['host1'], cubes: [] } as any,
|
||||
{ env: {} } as any,
|
||||
{ method: 'ssh' }
|
||||
);
|
||||
|
||||
await context.resolveCube('main', 'host1');
|
||||
|
||||
const callOrder = context.deployCalls.map(c => c.cube);
|
||||
|
||||
// Expected order:
|
||||
// 1. main:before (hook runs)
|
||||
// 2. before-hook (resolved via exec in before hook)
|
||||
// 3. dep (dependency of main)
|
||||
// 4. main (the cube itself)
|
||||
// 5. main:after (hook runs)
|
||||
// 6. after-hook (resolved via exec in after hook)
|
||||
|
||||
expect(order).toEqual(['main:before', 'main:after']);
|
||||
expect(callOrder).toEqual(['before-hook', 'dep', 'main', 'after-hook']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Tests for nopy.session module
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
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,
|
||||
saveSession,
|
||||
separateEnvAndCubeVariables,
|
||||
} from '../src/nopy.session.js';
|
||||
|
||||
describe('createSession', () => {
|
||||
it('creates session with required fields', () => {
|
||||
const session = createSession({
|
||||
cubes: [{ key: 'test', variables: {} }],
|
||||
hosts: ['localhost'],
|
||||
auth: { method: 'ssh' },
|
||||
});
|
||||
|
||||
expect(session.cubes).toHaveLength(1);
|
||||
expect(session.hosts).toEqual(['localhost']);
|
||||
expect(session.auth.method).toBe('ssh');
|
||||
});
|
||||
|
||||
it('includes optional name', () => {
|
||||
const session = createSession({
|
||||
name: 'My Session',
|
||||
cubes: [],
|
||||
hosts: ['localhost'],
|
||||
auth: { method: 'ssh-key' },
|
||||
});
|
||||
|
||||
expect(session.name).toBe('My Session');
|
||||
});
|
||||
|
||||
it('includes optional env', () => {
|
||||
const session = createSession({
|
||||
cubes: [],
|
||||
hosts: ['localhost'],
|
||||
auth: { method: 'ssh-key' },
|
||||
env: { KEY: 'value' },
|
||||
});
|
||||
|
||||
expect(session.env).toEqual({ KEY: 'value' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSession and loadSession', () => {
|
||||
let tempDir: string;
|
||||
let sessionPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-test-'));
|
||||
sessionPath = path.join(tempDir, 'test.session.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('saves and loads session', async () => {
|
||||
const session: NopySession = {
|
||||
cubes: [{ key: 'apt:essentials', variables: { UPDATE: true } }],
|
||||
hosts: ['@docker/test'],
|
||||
auth: { method: 'ssh-key' },
|
||||
};
|
||||
|
||||
saveSession(session, sessionPath);
|
||||
const loaded = await loadSession(sessionPath);
|
||||
|
||||
expect(loaded.cubes).toEqual(session.cubes);
|
||||
expect(loaded.hosts).toEqual(session.hosts);
|
||||
expect(loaded.auth).toEqual(session.auth);
|
||||
});
|
||||
|
||||
it('creates directory if not exists', () => {
|
||||
const nestedPath = path.join(tempDir, 'nested', 'dir', 'session.json');
|
||||
const session: NopySession = {
|
||||
cubes: [],
|
||||
hosts: ['localhost'],
|
||||
auth: { method: 'ssh' },
|
||||
};
|
||||
|
||||
saveSession(session, nestedPath);
|
||||
|
||||
expect(fs.existsSync(nestedPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('throws on missing file', async () => {
|
||||
await expect(loadSession('/nonexistent/path.json')).rejects.toThrow('Session file not found');
|
||||
});
|
||||
|
||||
it('throws on invalid extension', async () => {
|
||||
const invalidPath = path.join(tempDir, 'test.txt');
|
||||
fs.writeFileSync(invalidPath, '{}');
|
||||
|
||||
await expect(loadSession(invalidPath)).rejects.toThrow('Unsupported session file format');
|
||||
});
|
||||
|
||||
it('validates required cubes field', async () => {
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({ hosts: ['localhost'], auth: { method: 'ssh' } })
|
||||
);
|
||||
|
||||
await expect(loadSession(sessionPath)).rejects.toThrow('cubes');
|
||||
});
|
||||
|
||||
it('validates required auth field', async () => {
|
||||
fs.writeFileSync(sessionPath, JSON.stringify({ cubes: [], hosts: ['localhost'] }));
|
||||
|
||||
await expect(loadSession(sessionPath)).rejects.toThrow('auth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSessions', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns empty array for nonexistent directory', () => {
|
||||
const result = listSessions('/nonexistent/dir');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds .session.json files', () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'test.session.json'), '{}');
|
||||
fs.writeFileSync(path.join(tempDir, 'other.session.json'), '{}');
|
||||
fs.writeFileSync(path.join(tempDir, 'not-a-session.json'), '{}');
|
||||
|
||||
const result = listSessions(tempDir);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.some((p) => p.endsWith('test.session.json'))).toBe(true);
|
||||
expect(result.some((p) => p.endsWith('other.session.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('finds .session.mjs files', () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'test.session.mjs'), 'export default {}');
|
||||
|
||||
const result = listSessions(tempDir);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].endsWith('test.session.mjs')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterInternalVariables', () => {
|
||||
it('removes customize key', () => {
|
||||
const input = { customize: true, VAR_A: 'a', VAR_B: 'b' };
|
||||
const result = filterInternalVariables(input);
|
||||
|
||||
expect(result).toEqual({ VAR_A: 'a', VAR_B: 'b' });
|
||||
expect('customize' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns empty object for internal-only input', () => {
|
||||
const result = filterInternalVariables({ customize: true });
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('preserves all non-internal keys', () => {
|
||||
const input = { A: 1, B: 'two', C: true };
|
||||
const result = filterInternalVariables(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('separateEnvAndCubeVariables', () => {
|
||||
it('separates env variables from cube variables', () => {
|
||||
const allVars = { ENV_VAR: 'env', CUBE_VAR: 'cube' };
|
||||
const envVars = { ENV_VAR: 'original' };
|
||||
|
||||
const result = separateEnvAndCubeVariables(allVars, envVars);
|
||||
|
||||
expect(result.env).toEqual({ ENV_VAR: 'env' });
|
||||
expect(result.cubeVars).toEqual({ CUBE_VAR: 'cube' });
|
||||
});
|
||||
|
||||
it('handles all env variables', () => {
|
||||
const allVars = { A: 1, B: 2 };
|
||||
const envVars = { A: 0, B: 0 };
|
||||
|
||||
const result = separateEnvAndCubeVariables(allVars, envVars);
|
||||
|
||||
expect(result.env).toEqual({ A: 1, B: 2 });
|
||||
expect(result.cubeVars).toEqual({});
|
||||
});
|
||||
|
||||
it('handles all cube variables', () => {
|
||||
const allVars = { A: 1, B: 2 };
|
||||
const envVars = {};
|
||||
|
||||
const result = separateEnvAndCubeVariables(allVars, envVars);
|
||||
|
||||
expect(result.env).toEqual({});
|
||||
expect(result.cubeVars).toEqual({ A: 1, B: 2 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user