[fix] default parameter run records parameters in session for replay[fix] remove default parameters for several cubes
This commit is contained in:
@@ -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" }');
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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:');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user