[refactor] moving cubes into own package"
Publish snapshot / snapshot (push) Successful in 1m2s

[fix] default parameter run records parameters in session for replay[fix] remove default parameters for several cubes
This commit is contained in:
Benjamin Diedrichsen
2026-07-28 12:18:10 +02:00
parent ac050c4459
commit 6ecb2c366f
130 changed files with 3386 additions and 520 deletions
+162 -37
View File
@@ -1,78 +1,203 @@
/**
* Tests for the Variables scope container.
* Tests for Variable and Variables.
*
* The merge order is what makes a non-interactive run configurable, so it is
* pinned down here rather than left to the callers to demonstrate.
* Two things are pinned down here rather than left to the callers to
* demonstrate: the precedence between origins, which is what makes a
* non-interactive run configurable, and the tie-break between two assignments
* sharing an origin, which is what keeps a losing dependency visible instead of
* overwritten.
*/
import { describe, expect, it } from 'vitest';
import { Variables } from '../src/nopy.common.js';
import { MASK, Variable, Variables } from '../src/nopy.common.js';
describe('Variables.assign', () => {
it('creates the scope entry on first assignment and merges afterwards', () => {
const variables = new Variables();
describe('Variable ordering', () => {
it('is born with its first assignment', () => {
const variable = new Variable('cube-a', 'PORT', { value: 22, origin: 'default' });
variables.assign('cube-a', 'defaults', { A: 1 });
variables.assign('cube-a', 'defaults', { B: 2 });
expect(variables.get('cube-a', 'defaults')).toEqual({ A: 1, B: 2 });
expect(variable.value).toBe(22);
expect(variable.origin).toBe('default');
expect(variable.assignments).toHaveLength(1);
});
it('defaults to an empty assignment', () => {
const variables = new Variables();
it('lets a higher origin win however late it arrives', () => {
const variable = new Variable('cube-a', 'PORT', { value: 22, origin: 'default' });
variables.assign('cube-a', 'prompts');
variable.assign({ value: 8080, origin: 'param' });
variable.assign({ value: 2222, origin: 'env' });
expect(variables.get('cube-a', 'prompts')).toEqual({});
expect(variable.value).toBe(8080);
expect(variable.origin).toBe('param');
});
it('keeps scopes and cubes apart', () => {
const variables = new Variables();
it('keeps the newest of two assignments sharing an origin', () => {
const variable = new Variable('cube-a', 'PORT', { value: 1, origin: 'param' });
variables.assign('cube-a', 'params', { A: 1 });
variable.assign({ value: 2, origin: 'param' });
expect(variables.get('cube-a', 'prompts')).toEqual({});
expect(variables.get('cube-b', 'params')).toEqual({});
expect(variable.value).toBe(2);
// The displaced one is still on record — that is the whole point of the
// trace, and a sort that broke ties by rank alone would lose it.
expect(variable.ordered.map((a) => a.value)).toEqual([2, 1]);
});
it('keeps the raw trace in assignment order, newest first', () => {
const variable = new Variable('cube-a', 'PORT', { value: 22, origin: 'default' });
variable.assign({ value: 8080, origin: 'param' });
variable.assign({ value: 2222, origin: 'env' });
expect(variable.assignments.map((a) => a.origin)).toEqual(['env', 'param', 'default']);
expect(variable.ordered.map((a) => a.origin)).toEqual(['param', 'env', 'default']);
});
it('ranks every origin', () => {
const variable = new Variable('cube-a', 'PORT', { value: 'd', origin: 'default' });
variable.assign({ value: 'e', origin: 'env' });
expect(variable.value).toBe('e');
variable.assign({ value: 's', origin: 'session' });
expect(variable.value).toBe('s');
variable.assign({ value: 'p', origin: 'prompt' });
expect(variable.value).toBe('p');
variable.assign({ value: 'x', origin: 'param' });
expect(variable.value).toBe('x');
});
it('never yields the value of a redacted variable when serialised', () => {
const variable = new Variable('cube-a', 'PASSWORD', { value: 'hunter2', origin: 'prompt' });
variable.redacted = true;
expect(JSON.parse(JSON.stringify(variable))).toEqual({
cube: 'cube-a',
name: 'PASSWORD',
value: MASK,
origin: 'prompt',
});
});
});
describe('Variables.get precedence', () => {
it('lets global env override a schema default', () => {
const variables = new Variables({ PORT: 2222 });
variables.assign('cube-a', 'defaults', { PORT: 22 });
describe('Variables.assign', () => {
it('creates a variable on first assignment and appends afterwards', () => {
const variables = new Variables();
expect(variables.get('cube-a').PORT).toBe(2222);
variables.assign('cube-a', 'default', { A: 1 });
variables.assign('cube-a', 'prompt', { A: 2 });
expect(variables.of('cube-a', 'A')?.assignments).toHaveLength(2);
expect(variables.get('cube-a')).toEqual({ A: 2 });
});
it('lets a prompt override global env', () => {
it('tolerates an empty assignment', () => {
const variables = new Variables();
variables.assign('cube-a', 'prompt');
expect(variables.get('cube-a')).toEqual({});
});
it('keeps cubes apart', () => {
const variables = new Variables();
variables.assign('cube-a', 'param', { A: 1 });
expect(variables.get('cube-b')).toEqual({});
expect(variables.of('cube-b', 'A')).toBeUndefined();
});
});
describe('Variables precedence', () => {
it('lets config env override a schema default', () => {
const variables = new Variables({ PORT: 2222 });
variables.assign('cube-a', 'defaults', { PORT: 22 });
variables.assign('cube-a', 'prompts', { PORT: 8080 });
variables.assign('cube-a', 'default', { PORT: 22 });
expect(variables.get('cube-a').PORT).toBe(2222);
expect(variables.of('cube-a', 'PORT')?.origin).toBe('env');
});
it('lets a prompt override config env', () => {
const variables = new Variables({ PORT: 2222 });
variables.assign('cube-a', 'default', { PORT: 22 });
variables.assign('cube-a', 'prompt', { PORT: 8080 });
expect(variables.get('cube-a').PORT).toBe(8080);
});
it('lets a replayed session value override config env', () => {
const variables = new Variables({ PORT: 2222 });
variables.assign('cube-a', 'default', { PORT: 22 });
variables.assign('cube-a', 'session', { PORT: 3000 });
expect(variables.get('cube-a').PORT).toBe(3000);
});
it('lets a dependency param override everything else', () => {
const variables = new Variables({ PORT: 2222 });
variables.assign('cube-a', 'defaults', { PORT: 22 });
variables.assign('cube-a', 'prompts', { PORT: 8080 });
variables.assign('cube-a', 'params', { PORT: 9090 });
variables.assign('cube-a', 'default', { PORT: 22 });
variables.assign('cube-a', 'prompt', { PORT: 8080 });
variables.assign('cube-a', 'param', { PORT: 9090 });
expect(variables.get('cube-a').PORT).toBe(9090);
});
it('merges keys from every scope', () => {
it('merges keys from every origin', () => {
const variables = new Variables({ G: 'g' });
variables.assign('cube-a', 'defaults', { D: 'd' });
variables.assign('cube-a', 'prompts', { P: 'p' });
variables.assign('cube-a', 'params', { X: 'x' });
variables.assign('cube-a', 'default', { D: 'd' });
variables.assign('cube-a', 'prompt', { P: 'p' });
variables.assign('cube-a', 'param', { X: 'x' });
expect(variables.get('cube-a')).toEqual({ G: 'g', D: 'd', P: 'p', X: 'x' });
});
it('applies global env to every cube', () => {
it('applies config env to every cube it is asked about', () => {
const variables = new Variables({ SHARED: 'yes' });
expect(variables.get('anything').SHARED).toBe('yes');
variables.assign('cube-a', 'default', {});
variables.assign('cube-b', 'default', {});
expect(variables.get('cube-a').SHARED).toBe('yes');
expect(variables.get('cube-b').SHARED).toBe('yes');
expect(variables.of('cube-a', 'SHARED')?.origin).toBe('env');
});
});
describe('Variables secrets', () => {
it('excludes a declared secret from what a session records', () => {
const variables = new Variables();
variables.declareSecrets('cube-a', ['PASSWORD']);
variables.assign('cube-a', 'prompt', { USER: 'bob', PASSWORD: 'hunter2' });
expect(variables.get('cube-a')).toEqual({ USER: 'bob', PASSWORD: 'hunter2' });
expect(variables.persistable('cube-a')).toEqual({ USER: 'bob' });
});
it('marks values that arrived before the declaration', () => {
const variables = new Variables();
variables.assign('cube-a', 'prompt', { PASSWORD: 'hunter2' });
variables.declareSecrets('cube-a', ['PASSWORD']);
expect(variables.of('cube-a', 'PASSWORD')?.redacted).toBe(true);
expect(variables.persistable('cube-a')).toEqual({});
});
it('redacts a secret supplied through config env', () => {
const variables = new Variables({ PASSWORD: 'from-env' });
variables.declareSecrets('cube-a', ['PASSWORD']);
variables.assign('cube-a', 'default', {});
expect(variables.get('cube-a').PASSWORD).toBe('from-env');
expect(variables.persistable('cube-a')).toEqual({});
});
it('keeps secret declarations per cube', () => {
const variables = new Variables();
variables.declareSecrets('cube-a', ['PASSWORD']);
variables.assign('cube-a', 'prompt', { PASSWORD: 'a' });
variables.assign('cube-b', 'prompt', { PASSWORD: 'b' });
expect(variables.persistable('cube-a')).toEqual({});
expect(variables.persistable('cube-b')).toEqual({ PASSWORD: 'b' });
});
});
@@ -231,6 +231,53 @@ describe('config loading', () => {
});
});
describe('cubePackages', () => {
it('tags each package with the directory of the config that named it', () => {
const child = path.join(rootDir, 'child');
write(rootDir, { cubePackages: ['@acme/cubes-net'] });
write(child, { cubePackages: ['@acme/cubes-caddy'] });
process.chdir(child);
// Not a path, so nothing is rewritten — but resolution has to start from
// the config that asked, which is the only place `from` can come from.
expect(loadConfig().cubePackages).toEqual([
{ spec: '@acme/cubes-net', from: rootDir },
{ spec: '@acme/cubes-caddy', from: child },
]);
});
it('defaults to an empty list', () => {
write(rootDir, { hosts: ['web-1'] });
expect(loadConfig().cubePackages).toEqual([]);
});
it('lets a child config replace the list with an override strategy', () => {
const child = path.join(rootDir, 'child');
write(rootDir, { cubePackages: ['@acme/cubes-net'] });
write(child, {
cubePackages: ['@acme/cubes-caddy'],
resolution: { cubePackages: 'override' },
});
process.chdir(child);
expect(loadConfig().cubePackages).toEqual([{ spec: '@acme/cubes-caddy', from: child }]);
});
it('keeps both entries when parent and child name the same package', () => {
// Refs are objects, so the primitives-only dedupe in mergeValue does not
// fire. resolveCubePackages collapses them, last-wins.
const child = path.join(rootDir, 'child');
write(rootDir, { cubePackages: ['@acme/cubes-net'] });
write(child, { cubePackages: ['@acme/cubes-net'] });
process.chdir(child);
expect(loadConfig().cubePackages).toEqual([
{ spec: '@acme/cubes-net', from: rootDir },
{ spec: '@acme/cubes-net', from: child },
]);
});
});
describe('saveConfig', () => {
it('writes a new config file at the given path', () => {
const target = path.join(rootDir, 'custom.json');
@@ -2,10 +2,10 @@
* Edge cases for BuildContext: unknown cubes, session replay and auth flags.
*/
import { type AnyObjectSchema, Cube, Manifest } from '@bitsquare/nopy-cube';
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';
@@ -20,6 +20,14 @@ 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');
/** A cube whose PASSWORD the manifest declares a secret. */
const secretCube = (id: string, schema: AnyObjectSchema) =>
new Cube(
Manifest.create({ id, name: `Test ${id}`, schema, secrets: ['PASSWORD'] }),
`/test/${id}`,
'deploy.py'
);
const config = { env: {} } as NopyConfig;
const session = (cubes: NopySession['cubes'] = []) => ({ cubes }) as NopySession;
@@ -102,6 +110,134 @@ describe('BuildContext session replay', () => {
expect(VariableAssignment).toHaveBeenCalled();
});
it('lets a recorded value beat config env', async () => {
const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') }));
const context = new BuildContext(
{ 'cube-a': cube },
new Variables({ PORT: '2222' }),
session([{ key: 'cube-a', variables: { PORT: '9090' } }]),
{ env: { PORT: '2222' } } as NopyConfig,
{ method: 'ssh' },
{ isSessionReplay: true }
);
await context.resolveCube('cube-a', 'host1');
expect(context.deployCalls[0].env.PORT).toBe('9090');
});
});
describe('BuildContext replay gaps', () => {
const replay = (cube: Cube, recorded: Record<string, string> = {}, options = {}) =>
new BuildContext(
{ [cube.id]: cube },
new Variables(),
session([{ key: cube.id, variables: recorded }]),
config,
{ method: 'ssh' },
{ isSessionReplay: true, ...options }
);
it('asks for a required variable the session never recorded', async () => {
const cube = testCube('cube-a', z.object({ SSID: z.string() }));
vi.mocked(VariableAssignment).mockImplementation(async (_cube, variables) => {
variables.assign('cube-a', 'prompt', { SSID: 'typed' });
});
const context = replay(cube);
await context.resolveCube('cube-a', 'host1');
expect(VariableAssignment).toHaveBeenCalledWith(cube, expect.anything(), { keys: ['SSID'] });
expect(context.deployCalls[0].env.SSID).toBe('typed');
});
it('asks for a secret even though a default already filled it in', async () => {
const cube = secretCube('cube-a', z.object({ PASSWORD: z.string().default('changeme') }));
vi.mocked(VariableAssignment).mockImplementation(async (_cube, variables) => {
variables.assign('cube-a', 'prompt', { PASSWORD: 'real' });
});
const context = replay(cube);
await context.resolveCube('cube-a', 'host1');
expect(VariableAssignment).toHaveBeenCalledWith(cube, expect.anything(), {
keys: ['PASSWORD'],
});
expect(context.deployCalls[0].env.PASSWORD).toBe('real');
});
it('asks nothing when the session covers everything', async () => {
const cube = testCube('cube-a', z.object({ SSID: z.string() }));
await replay(cube, { SSID: 'recorded' }).resolveCube('cube-a', 'host1');
expect(VariableAssignment).not.toHaveBeenCalled();
});
it('refuses to deploy when the form was cancelled', async () => {
const cube = testCube('cube-a', z.object({ SSID: z.string() }));
// The real VariableAssignment swallows a cancelled form, so the gap check
// has to run again afterwards or the cube ships without the variable.
vi.mocked(VariableAssignment).mockResolvedValue(undefined);
const context = replay(cube);
await expect(context.resolveCube('cube-a', 'host1')).rejects.toThrow(
'Cube "cube-a" is missing SSID and cannot be deployed.'
);
expect(context.deployCalls).toHaveLength(0);
});
it('cannot fill a gap when --use-defaults forbids prompting', async () => {
const cube = secretCube('cube-a', z.object({ PASSWORD: z.string().default('changeme') }));
const context = replay(cube, {}, { useDefaults: true });
await expect(context.resolveCube('cube-a', 'host1')).rejects.toThrow(
/cannot be replayed with --use-defaults: PASSWORD/
);
});
});
describe('BuildContext session recording', () => {
it('records every value the run settled on, not only the prompted ones', async () => {
const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') }));
const context = new BuildContext(
{ 'cube-a': cube },
new Variables({ REGION: 'eu' }),
session(),
config,
{ method: 'ssh' },
{ useDefaults: true }
);
await context.resolveCube('cube-a', 'host1');
expect(context.cubeSessions[0].variables).toEqual({ PORT: '3000', REGION: 'eu' });
});
it('keeps a declared secret out of the session', async () => {
const cube = secretCube(
'cube-a',
z.object({ USER: z.string().default('bob'), PASSWORD: z.string().default('changeme') })
);
const context = new BuildContext(
{ 'cube-a': cube },
new Variables(),
session(),
config,
{ method: 'ssh' },
{ useDefaults: true }
);
await context.resolveCube('cube-a', 'host1');
// Still handed to pyinfra — just never written down.
expect(context.deployCalls[0].env.PASSWORD).toBe('changeme');
expect(context.deployCalls[0].secrets).toEqual(['PASSWORD']);
expect(context.cubeSessions[0].variables).toEqual({ USER: 'bob' });
});
});
describe('BuildContext --use-defaults', () => {
@@ -2,10 +2,10 @@
* Tests for cubes/dependencies module (BuildContext)
*/
import { Cube, Manifest } from '@bitsquare/nopy-cube';
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
@@ -82,7 +82,7 @@ describe('BuildContext.resolveCube', () => {
// Test with USE_A = false
const vars2 = new Variables();
vars2.assign('cube-c', 'params', { USE_A: false });
vars2.assign('cube-c', 'param', { USE_A: false });
const context2 = new BuildContext(cubes, vars2, { cubes: [] } as any, { env: {} } as any, {
method: 'ssh',
});
@@ -1,39 +0,0 @@
/**
* 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);
});
});
@@ -103,6 +103,36 @@ describe('loader edge cases', () => {
expect(cubes['no-schema'].getDefaults()).toEqual({});
});
it('reports a secrets entry that names no schema key', async () => {
// A typo here protects nothing and is invisible at runtime — the value
// would simply be persisted.
cube(
'typo',
`import { z } from 'zod';
export default { id: 'typo', name: 'Typo', secrets: ['PASSWROD'],
schema: z.object({ PASSWORD: z.string().default('x') }) };`
);
const { errors } = await loadCubes();
expect(errors[0]).toMatch(/'secrets' names PASSWROD, which is not in the schema/);
});
it('accepts a secrets entry that matches a schema key', async () => {
cube(
'ok',
`import { z } from 'zod';
export default { id: 'ok', name: 'Ok', secrets: ['PASSWORD'],
schema: z.object({ PASSWORD: z.string().default('x') }) };`
);
const { cubes, errors } = await loadCubes();
expect(errors).toEqual([]);
expect(cubes.ok.isSecret('PASSWORD')).toBe(true);
expect(cubes.ok.isSecret('OTHER')).toBe(false);
});
it('reports a manifest whose default export is not an object', async () => {
cube('bad-export', 'export default "just a string"');
@@ -219,6 +249,91 @@ describe('loader edge cases', () => {
});
});
describe('cubePackages', () => {
/** Installs a cube package into the temp project's node_modules. */
const installPackage = (name: string, cubeId: string) => {
const root = path.join(tmpDir, 'node_modules', name);
const cubeDir = path.join(root, 'cubes', cubeId);
fs.mkdirSync(cubeDir, { recursive: true });
fs.writeFileSync(
path.join(root, 'package.json'),
JSON.stringify({ name, nopy: { cubes: ['./cubes'] } })
);
fs.writeFileSync(
path.join(cubeDir, 'manifest.mjs'),
`export default { id: "${cubeId}", name: "From a package" }`
);
fs.writeFileSync(path.join(cubeDir, 'deploy.py'), '# deploy');
return cubeDir;
};
const config = (extra: Record<string, unknown>) =>
fs.writeFileSync(path.join(tmpDir, '.nopyrc.json'), JSON.stringify(extra));
it('loads cubes from a package and records where they came from', async () => {
const cubeDir = installPackage('@acme/cubes-net', 'net:vpn');
config({ cubeDirs: [], cubePackages: ['@acme/cubes-net'] });
const { cubes, errors } = await loadCubes();
expect(errors).toEqual([]);
expect(cubes['net:vpn'].dir).toBe(cubeDir);
expect(cubes['net:vpn'].source).toEqual({
type: 'package',
packageName: '@acme/cubes-net',
dir: path.join(tmpDir, 'node_modules', '@acme/cubes-net', 'cubes'),
});
});
it('marks a cube found under a plain directory as directory-sourced', async () => {
cube('local', 'export default { id: "local", name: "Local" }');
const { cubes } = await loadCubes();
expect(cubes.local.source).toEqual({ type: 'dir', dir: tmpDir });
});
it('still skips a node_modules tree nobody asked for', async () => {
installPackage('@acme/cubes-net', 'net:vpn');
config({ cubeDirs: ['./'] });
const { cubes, errors } = await loadCubes();
expect(errors).toEqual([]);
expect(cubes['net:vpn']).toBeUndefined();
});
it('names the package and the directory when both claim one id', async () => {
installPackage('@acme/cubes-net', 'clash');
cube('local', 'export default { id: "clash", name: "Local" }');
config({ cubeDirs: ['./'], cubePackages: ['@acme/cubes-net'] });
const { errors } = await loadCubes();
expect(errors).toHaveLength(1);
expect(errors[0]).toMatch(/Duplicate cube id 'clash' from 2 sources/);
// Labels are padded to a common width, so match the pair, not the gap.
expect(errors[0]).toMatch(
new RegExp(`^\\s+directory\\s+${path.join(tmpDir, 'local')}$`, 'm')
);
expect(errors[0]).toMatch(
new RegExp(
`^\\s+package @acme/cubes-net\\s+${path.join(tmpDir, 'node_modules/@acme/cubes-net/cubes/clash')}$`,
'm'
)
);
});
it('aborts when a named package is not installed', async () => {
config({ cubeDirs: [], cubePackages: ['@acme/missing'] });
const { cubes, errors } = await loadCubes();
expect(cubes).toEqual({});
expect(errors[0]).toMatch(/'@acme\/missing' is not installed/);
});
});
describe('getCube', () => {
it('returns a single cube by id', async () => {
cube('one', 'export default { id: "one", name: "One" }');
+168
View File
@@ -0,0 +1,168 @@
/**
* Resolution of the packages named in `cubePackages`.
*
* Builds real `node_modules` trees under os.tmpdir() rather than faking the
* filesystem: what is under test is Node's own resolution, including the
* symlink layout pnpm produces, and neither survives a mock.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { resolveCubePackages } from '../src/cubes/packages.js';
describe('resolveCubePackages', () => {
let tmpDir: string;
/** Writes a package into `<root>/node_modules/<name>`, cubes and all. */
const install = (
root: string,
name: string,
manifest: Record<string, unknown>,
cubeDirs: string[] = ['cubes']
) => {
const dir = path.join(root, 'node_modules', name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, ...manifest }));
for (const cubeDir of cubeDirs) fs.mkdirSync(path.join(dir, cubeDir), { recursive: true });
return dir;
};
beforeEach(() => {
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-packages-')));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('resolves a scoped package to its cube directories', () => {
const dir = install(tmpDir, '@acme/cubes-net', { nopy: { cubes: ['./cubes'] } });
const { packages, errors } = resolveCubePackages([{ spec: '@acme/cubes-net', from: tmpDir }]);
expect(errors).toEqual([]);
expect(packages).toEqual([
{ name: '@acme/cubes-net', root: dir, dirs: [path.join(dir, 'cubes')] },
]);
});
it('resolves an unscoped package and every directory it declares', () => {
const dir = install(tmpDir, 'cubes-net', { nopy: { cubes: ['./cubes', './extra'] } }, [
'cubes',
'extra',
]);
const { packages, errors } = resolveCubePackages([{ spec: 'cubes-net', from: tmpDir }]);
expect(errors).toEqual([]);
expect(packages[0].dirs).toEqual([path.join(dir, 'cubes'), path.join(dir, 'extra')]);
});
it('resolves through a symlinked package directory, as pnpm installs it', () => {
// pnpm puts the real package under .pnpm and symlinks it into place, which
// is why the resolver reads package.json instead of scanning node_modules.
const store = path.join(tmpDir, 'store', 'cubes-net');
fs.mkdirSync(path.join(store, 'cubes'), { recursive: true });
fs.writeFileSync(
path.join(store, 'package.json'),
JSON.stringify({ name: 'cubes-net', nopy: { cubes: ['./cubes'] } })
);
fs.mkdirSync(path.join(tmpDir, 'node_modules'), { recursive: true });
fs.symlinkSync(store, path.join(tmpDir, 'node_modules', 'cubes-net'));
const { packages, errors } = resolveCubePackages([{ spec: 'cubes-net', from: tmpDir }]);
expect(errors).toEqual([]);
expect(packages[0].dirs).toEqual([path.join(tmpDir, 'node_modules', 'cubes-net', 'cubes')]);
});
it('resolves from the declaring config directory, not the working directory', () => {
// The package is installed next to the config that names it. Nothing at the
// process cwd can see it, which is the case a package named in
// ~/.nopyrc.json always hits.
const elsewhere = path.join(tmpDir, 'elsewhere');
fs.mkdirSync(elsewhere, { recursive: true });
install(elsewhere, 'cubes-net', { nopy: { cubes: ['./cubes'] } });
expect(resolveCubePackages([{ spec: 'cubes-net', from: elsewhere }]).errors).toEqual([]);
expect(resolveCubePackages([{ spec: 'cubes-net', from: tmpDir }]).errors).toHaveLength(1);
});
it('reports a package that is not installed', () => {
const { packages, errors } = resolveCubePackages([{ spec: '@acme/missing', from: tmpDir }]);
expect(packages).toEqual([]);
expect(errors[0]).toMatch(/'@acme\/missing' is not installed/);
expect(errors[0]).toContain(tmpDir);
});
it('reports a package.json that cannot be parsed', () => {
const dir = path.join(tmpDir, 'node_modules', 'broken');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'package.json'), '{ not json');
const { errors } = resolveCubePackages([{ spec: 'broken', from: tmpDir }]);
expect(errors[0]).toMatch(/cannot read/);
});
it('reports a package that declares no cubes', () => {
install(tmpDir, 'plain', {});
install(tmpDir, 'empty', { nopy: { cubes: [] } });
install(tmpDir, 'wrong-type', { nopy: { cubes: 'cubes' } });
install(tmpDir, 'not-strings', { nopy: { cubes: [1] } });
const { packages, errors } = resolveCubePackages(
['plain', 'empty', 'wrong-type', 'not-strings'].map((spec) => ({ spec, from: tmpDir }))
);
expect(packages).toEqual([]);
expect(errors).toHaveLength(4);
for (const error of errors) expect(error).toMatch(/declares no cubes/);
});
it('reports a cube directory that does not exist', () => {
install(tmpDir, 'cubes-net', { nopy: { cubes: ['./nope'] } });
const { packages, errors } = resolveCubePackages([{ spec: 'cubes-net', from: tmpDir }]);
expect(packages).toEqual([]);
expect(errors[0]).toMatch(/'\.\/nope' does not exist/);
});
it('reports a cube directory that points outside the package', () => {
install(tmpDir, 'cubes-net', { nopy: { cubes: ['../../..'] } });
const { errors } = resolveCubePackages([{ spec: 'cubes-net', from: tmpDir }]);
expect(errors[0]).toMatch(/points outside the package/);
});
it('keeps the directories that are valid when a sibling entry is not', () => {
const dir = install(tmpDir, 'cubes-net', { nopy: { cubes: ['./cubes', './nope'] } });
const { packages, errors } = resolveCubePackages([{ spec: 'cubes-net', from: tmpDir }]);
expect(errors).toHaveLength(1);
expect(packages[0].dirs).toEqual([path.join(dir, 'cubes')]);
});
it('resolves a package named by two configs once, from the more specific one', () => {
// Merge order is root-first, so the last ref came from the config nearest
// the working directory — and only that one is guaranteed to resolve.
const child = path.join(tmpDir, 'child');
fs.mkdirSync(child, { recursive: true });
const dir = install(child, 'cubes-net', { nopy: { cubes: ['./cubes'] } });
const { packages, errors } = resolveCubePackages([
{ spec: 'cubes-net', from: path.join(tmpDir, 'nowhere') },
{ spec: 'cubes-net', from: child },
]);
expect(errors).toEqual([]);
expect(packages).toHaveLength(1);
expect(packages[0].root).toBe(dir);
});
});
@@ -0,0 +1,107 @@
/**
* Tests for the manifest resolve hook.
*
* Every case runs in a child `node` process. Vitest resolves a dynamic import
* through vite, which finds `zod` from the project root whether or not the hook
* is installed — so a test run inside the worker passes either way and proves
* nothing. Only real Node resolution can tell the two apart.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const CUBES_SRC = fileURLToPath(new URL('../src/cubes', import.meta.url));
const HOOK = pathToFileURL(path.join(CUBES_SRC, 'resolve-hook.mjs')).href;
// Only ever handed to createRequire, which wants a path inside the package and
// never opens it. This is the same URL loader.ts registers with.
const FROM = pathToFileURL(path.join(CUBES_SRC, 'loader.ts')).href;
describe('resolve-hook', () => {
let tmpDir: string;
/** Runs `manifest.mjs` in a fresh Node process and returns its default export. */
const importManifest = (source: string, { withHook } = { withHook: true }) => {
fs.writeFileSync(path.join(tmpDir, 'manifest.mjs'), source);
const manifest = pathToFileURL(path.join(tmpDir, 'manifest.mjs')).href;
const script = [
withHook ? "import module from 'node:module';" : '',
withHook
? `module.register(${JSON.stringify(HOOK)}, ${JSON.stringify(FROM)}, ` +
`{ data: { from: ${JSON.stringify(FROM)} } });`
: '',
`const loaded = await import(${JSON.stringify(manifest)})`,
' .then((m) => m.default, (err) => ({ failed: err.code ?? String(err) }));',
'console.log(JSON.stringify(loaded));',
]
.filter(Boolean)
.join('\n');
return JSON.parse(
execFileSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf-8' })
);
};
beforeEach(() => {
// Under os.tmpdir() precisely because nothing above it links zod: this is a
// cube in a directory the user pointed `cubeDirs` at, nothing more.
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-hook-')));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('cannot import zod from a bare directory without the hook', () => {
const result = importManifest("import 'zod';\nexport default { ok: true };", {
withHook: false,
});
expect(result).toEqual({ failed: 'ERR_MODULE_NOT_FOUND' });
});
it('resolves zod from the running CLI', () => {
const result = importManifest(
"import { z } from 'zod';\nexport default { ok: typeof z.object === 'function' };"
);
expect(result).toEqual({ ok: true });
});
it('resolves a subpath of a covered package', () => {
const result = importManifest(
"import pkg from '@bitsquare/nopy/package.json' with { type: 'json' };\n" +
'export default { name: pkg.name };'
);
expect(result).toEqual({ name: '@bitsquare/nopy' });
});
it('leaves anything else to fail as it would have', () => {
const result = importManifest("import 'no-such-package';\nexport default { ok: true };");
expect(result).toEqual({ failed: 'ERR_MODULE_NOT_FOUND' });
});
it('prefers a copy the cube can already see', () => {
// The whole point of trying normal resolution first: a consumer with its
// own zod keeps it, so the hook can never introduce version skew.
const stub = path.join(tmpDir, 'node_modules', 'zod');
fs.mkdirSync(stub, { recursive: true });
fs.writeFileSync(
path.join(stub, 'package.json'),
JSON.stringify({ name: 'zod', version: '0.0.0', type: 'module', main: 'index.js' })
);
fs.writeFileSync(path.join(stub, 'index.js'), "export const z = { from: 'the cube' };");
const result = importManifest(
"import { z } from 'zod';\nexport default { from: z.from ?? 'the CLI' };"
);
expect(result).toEqual({ from: 'the cube' });
});
});
-111
View File
@@ -1,111 +0,0 @@
/**
* Tests for the Cube runtime wrapper: default extraction and the required-key
* check that `--use-defaults` relies on.
*/
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { Cube, Manifest } from '../src/cubes/types.js';
import { foreignZodSchema } from './helpers/foreign-zod.js';
const cube = (schema: z.ZodObject<any>) =>
new Cube(Manifest.create({ id: 'c', name: 'C', schema }), '/cubes/c', 'deploy.py');
describe('Cube.getDefaults', () => {
it('resolves every default when the whole schema parses', () => {
const c = cube(
z.object({
PORT: z.number().default(8080),
NAME: z.string().default('svc'),
})
);
expect(c.getDefaults()).toEqual({ PORT: 8080, NAME: 'svc' });
});
it('keeps the declared defaults when one field has none', () => {
const c = cube(
z.object({
SSID: z.string(),
PRIORITY: z.number().default(10),
HIDDEN: z.boolean().default(false),
})
);
expect(c.getDefaults()).toEqual({ PRIORITY: 10, HIDDEN: false });
});
it('unwraps a default sitting under optional or nullable', () => {
const c = cube(
z.object({
REQUIRED: z.string(),
A: z.number().default(1).optional(),
B: z.number().default(2).nullable(),
C: z.number().optional().default(3),
})
);
expect(c.getDefaults()).toEqual({ A: 1, B: 2, C: 3 });
});
it('evaluates a lazily declared default', () => {
const c = cube(
z.object({ REQUIRED: z.string(), TOKEN: z.string().default(() => 'generated') })
);
expect(c.getDefaults()).toEqual({ TOKEN: 'generated' });
});
it('omits an optional field that declares no default', () => {
const c = cube(z.object({ REQUIRED: z.string(), MAYBE: z.string().optional() }));
expect(c.getDefaults()).toEqual({});
});
it('returns an empty object for an empty schema', () => {
expect(cube(z.object({})).getDefaults()).toEqual({});
});
it('reads defaults off a schema built by a different copy of zod', () => {
// The per-field fallback reads zod's internals directly. Under `instanceof`
// a foreign schema yields no defaults at all, without erroring.
const c = cube(
foreignZodSchema(
z.object({
REQUIRED: z.string(),
PRIORITY: z.number().default(10),
NESTED: z.number().default(2).optional(),
})
)
);
expect(c.getDefaults()).toEqual({ PRIORITY: 10, NESTED: 2 });
});
});
describe('Cube.requiredKeys', () => {
it('lists the fields with neither a default nor optionality', () => {
const c = cube(
z.object({
SSID: z.string(),
PASSWORD: z.string(),
PRIORITY: z.number().default(10),
NOTE: z.string().optional(),
})
);
expect(c.requiredKeys()).toEqual(['SSID', 'PASSWORD']);
});
it('treats a nullable field without a default as required', () => {
const c = cube(z.object({ MAYBE: z.string().nullable() }));
expect(c.requiredKeys()).toEqual(['MAYBE']);
});
it('is empty when every field can fill itself in', () => {
const c = cube(z.object({ A: z.string().default('a'), B: z.string().optional() }));
expect(c.requiredKeys()).toEqual([]);
});
});
-44
View File
@@ -1,44 +0,0 @@
/**
* 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('');
});
});
+66 -3
View File
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
type DeployCall,
type ExecutionResult,
maskCommand,
outputExecutionPlan,
summarizeResults,
} from '../src/nopy.executor.js';
@@ -136,17 +137,33 @@ describe('outputExecutionPlan', () => {
expect(parsed.plan[0].host).toBe('host1');
});
it('masks password variables in text output', () => {
it('masks variables the manifest declared secret', () => {
const call: DeployCall = {
...createTestCall('cube-a', 'host1'),
env: { PASSWORD: 'secret', OTHER: 'visible' },
command: ['pyinfra', 'host1', '-y', '--data "PASSWORD=hunter2"', '--data "OTHER=visible"'],
env: { PASSWORD: 'hunter2', OTHER: 'visible' },
secrets: ['PASSWORD'],
};
outputExecutionPlan([call]);
const output = consoleLogSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('********');
expect(output).not.toContain('secret');
// Both the variable list and the command line above it — the command used
// to be printed unmasked, which defeated the masking entirely.
expect(output).not.toContain('hunter2');
expect(output).toContain('visible');
});
it('leaves a password-looking variable alone when the manifest says nothing', () => {
const call: DeployCall = {
...createTestCall('cube-a', 'host1'),
env: { PASSWORD: 'visible' },
};
outputExecutionPlan([call]);
const output = consoleLogSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('visible');
});
@@ -173,3 +190,49 @@ describe('outputExecutionPlan', () => {
expect(output).toContain('Total: 3');
});
});
describe('maskCommand', () => {
const call = (command: string[], secrets?: string[]): DeployCall => ({
...createTestCall('cube-a', 'host1'),
command,
secrets,
});
it('replaces the value of a declared secret', () => {
const masked = maskCommand(
call(['pyinfra', 'host1', '--data "PASSWORD=hunter2"'], ['PASSWORD'])
);
expect(masked).toBe('pyinfra host1 --data "PASSWORD=********"');
});
it('leaves other data alone', () => {
const masked = maskCommand(
call(['--data "SSID=home"', '--data "PASSWORD=hunter2"'], ['PASSWORD'])
);
expect(masked).toBe('--data "SSID=home" --data "PASSWORD=********"');
});
it('masks a value containing spaces up to the closing quote', () => {
const masked = maskCommand(call(['--data "PASSWORD=two words"', '--chdir /x'], ['PASSWORD']));
expect(masked).toBe('--data "PASSWORD=********" --chdir /x');
});
it('masks an empty secret value', () => {
expect(maskCommand(call(['--data "PASSWORD="'], ['PASSWORD']))).toBe(
'--data "PASSWORD=********"'
);
});
it('masks the ssh password whether or not the cube declares secrets', () => {
const masked = maskCommand(call(['pyinfra', 'host1', '--user bob --password s3cr3t', '-y']));
expect(masked).toBe('pyinfra host1 --user bob --password ******** -y');
});
it('returns the command untouched when there is nothing to hide', () => {
expect(maskCommand(call(['pyinfra', 'host1', '-y']))).toBe('pyinfra host1 -y');
});
});
+1 -1
View File
@@ -2,10 +2,10 @@
* Tests for cube hooks using BuildContext
*/
import { Cube, Manifest } from '@bitsquare/nopy-cube';
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', () => {
+6 -2
View File
@@ -93,7 +93,7 @@ beforeEach(() => {
vi.clearAllMocks();
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
state.config = { hosts: ['web-1'], cubeDirs: [], env: {} };
state.config = { hosts: ['web-1'], cubeDirs: [], cubePackages: [], env: {} };
state.loadResult = { cubes: { 'cube-a': {} }, errors: [] };
state.deployCalls = [call('cube-a')];
state.cubeSessions = [{ key: 'cube-a', variables: {} }];
@@ -174,6 +174,7 @@ describe('nopy', () => {
state.config = {
hosts: ['web-1'],
cubeDirs: ['/cubes'],
cubePackages: [{ spec: '@acme/cubes-net', from: '/project' }],
env: { TOKEN: 'secret', EMPTY: '' },
};
@@ -183,6 +184,8 @@ describe('nopy', () => {
expect(text).toContain('Configuration');
expect(text).toContain('Hosts:');
expect(text).toContain('Cube dirs:');
// Named by package, not by wherever it resolved to on disk.
expect(text).toContain('Cube pkgs: @acme/cubes-net');
expect(text).toContain('continue-on-error');
// Values are never echoed, only their presence.
expect(text).toContain('TOKEN: <VALUE>');
@@ -191,7 +194,7 @@ describe('nopy', () => {
});
it('omits empty sections', async () => {
state.config = { hosts: [], cubeDirs: [], env: {} };
state.config = { hosts: [], cubeDirs: [], cubePackages: [], env: {} };
await nopy();
@@ -199,6 +202,7 @@ describe('nopy', () => {
expect(text).toContain('Configuration');
expect(text).not.toContain('Hosts:');
expect(text).not.toContain('Cube dirs:');
expect(text).not.toContain('Cube pkgs:');
expect(text).not.toContain('Env vars:');
});
+40 -22
View File
@@ -37,7 +37,7 @@ vi.mock('enquirer', () => ({
},
}));
import { Cube, Manifest } from '../src/cubes/types.js';
import { Cube, Manifest } from '@bitsquare/nopy-cube';
import { Variables } from '../src/nopy.common.js';
import {
AuthSelection,
@@ -227,7 +227,7 @@ describe('VariableAssignment', () => {
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' });
variables.assign('svc', 'param', { port: 1, enabled: true, name: 'x' });
await VariableAssignment(cube('svc', 'Service', schema), variables);
@@ -240,25 +240,30 @@ describe('VariableAssignment', () => {
expect(formRun).not.toHaveBeenCalled();
});
it('only asks about the variables still missing', async () => {
it('leaves out a key a dependency already supplied', async () => {
const variables = new Variables();
variables.assign('svc', 'params', { port: 9090 });
variables.assign('svc', 'param', { port: 9090 });
formRun.mockResolvedValue({});
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(formRun).toHaveBeenCalled();
expect(variables.get('svc', 'prompts')).toEqual({});
expect(formChoices().map((c) => c.name)).toEqual(['enabled', 'name']);
});
it('asks about a field that declares no default, with an empty initial value', async () => {
const required = z.object({
SSID: z.string().describe('Network name'),
PRIORITY: z.number().default(10),
});
const wifi = cube(
'wifi',
'WiFi',
z.object({
SSID: z.string().describe('Network name'),
PRIORITY: z.number().default(10),
})
);
const variables = new Variables();
variables.assign('wifi', 'default', wifi.getDefaults());
formRun.mockResolvedValue({});
await VariableAssignment(cube('wifi', 'WiFi', required), new Variables());
await VariableAssignment(wifi, variables);
expect(formChoices()).toEqual([
{ name: 'SSID', message: 'Network name', initial: '' },
@@ -267,21 +272,34 @@ describe('VariableAssignment', () => {
});
it('offers the value the run would use, not the bare schema default', async () => {
const svc = cube('svc', 'Service', schema);
const variables = new Variables({ port: 2222 });
variables.assign('svc', 'default', svc.getDefaults());
formRun.mockResolvedValue({});
await VariableAssignment(cube('svc', 'Service', schema), variables);
await VariableAssignment(svc, variables);
expect(formChoices().find((c) => c.name === 'port')?.initial).toBe('2222');
});
it('asks only about the given keys', async () => {
const svc = cube('svc', 'Service', schema);
const variables = new Variables();
variables.assign('svc', 'default', svc.getDefaults());
formRun.mockResolvedValue({});
await VariableAssignment(svc, variables, { keys: ['name'] });
expect(formChoices()).toEqual([{ name: 'name', message: 'name', initial: 'svc' }]);
});
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({
expect(variables.get('svc')).toEqual({
port: 9090,
enabled: true,
name: 'api',
@@ -294,8 +312,8 @@ describe('VariableAssignment', () => {
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);
expect(variables.get('svc').port).toBe('not-a-number');
expect(variables.get('svc').enabled).toBe(false);
});
it('accepts yes and 1 as truthy booleans', async () => {
@@ -304,7 +322,7 @@ describe('VariableAssignment', () => {
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(variables.get('svc', 'prompts').enabled).toBe(true);
expect(variables.get('svc').enabled).toBe(true);
});
it('unwraps optional and nullable schema types', async () => {
@@ -318,7 +336,7 @@ describe('VariableAssignment', () => {
await VariableAssignment(cube('svc', 'Service', nullableSchema), variables);
expect(variables.get('svc', 'prompts')).toEqual({ maybe: null, opt: 7, given: 42 });
expect(variables.get('svc')).toEqual({ maybe: null, opt: 7, given: 42 });
});
it('treats an empty string as null for a nullable field', async () => {
@@ -328,7 +346,7 @@ describe('VariableAssignment', () => {
await VariableAssignment(cube('svc', 'Service', nullableSchema), variables);
expect(variables.get('svc', 'prompts').maybe).toBe(null);
expect(variables.get('svc').maybe).toBe(null);
});
it('passes non-string answers through untouched', async () => {
@@ -337,7 +355,7 @@ describe('VariableAssignment', () => {
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(variables.get('svc', 'prompts').port).toBe(9090);
expect(variables.get('svc').port).toBe(9090);
});
it('keeps answers for keys the schema does not describe', async () => {
@@ -346,7 +364,7 @@ describe('VariableAssignment', () => {
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(variables.get('svc', 'prompts').extra).toBe('kept');
expect(variables.get('svc').extra).toBe('kept');
});
it('assigns nothing when the user cancels the form', async () => {
@@ -356,7 +374,7 @@ describe('VariableAssignment', () => {
await expect(
VariableAssignment(cube('svc', 'Service', schema), variables)
).resolves.toBeUndefined();
expect(variables.get('svc', 'prompts')).toEqual({});
expect(variables.get('svc')).toEqual({});
});
it('coerces against a schema built by a different copy of zod', async () => {
@@ -380,6 +398,6 @@ describe('VariableAssignment', () => {
variables
);
expect(variables.get('svc', 'prompts')).toEqual({ port: 9090, enabled: true, maybe: null });
expect(variables.get('svc')).toEqual({ port: 9090, enabled: true, maybe: null });
});
});
-55
View File
@@ -8,12 +8,10 @@ import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
createSession,
filterInternalVariables,
listSessions,
loadSession,
type NopySession,
saveSession,
separateEnvAndCubeVariables,
} from '../src/nopy.session.js';
describe('createSession', () => {
@@ -157,56 +155,3 @@ describe('listSessions', () => {
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 });
});
});