Closes DOCS-AUDIT §2.3.
The cube contract says each schema field is `.describe()`d and that the
description is the prompt label. Whether it was depended on the order the
manifest happened to chain in: zod 4 keys a description to the schema
*instance* and `.default()` returns a new `ZodDefault` around the described
type rather than mutating it, so the wrapper carries no description of its own
and the prompt, reading the outer node, fell back to the bare key.
z.boolean().describe('Update package cache').default(false) -> 'UPDATE'
z.boolean().default(false).describe('Update package cache') -> the sentence
15 of the 22 core cubes are written the first way, so most prompts showed a key.
`promptLabel()` walks down through `default` / `optional` / `nullable` looking
for a description, which makes the two orders equivalent — the answer that
cannot regress, where re-ordering every manifest and hoping the next one written
gets it right can. It discriminates on `zodKind`, not `instanceof`, for the
reason recorded on that helper: a manifest built by a different zod copy fails
every `instanceof` in the module.
The mocked test asserts all four shapes, including a doubly-wrapped
`describe().optional().default()` and a field with no description at all. The
pty test is the one that carries the weight: its probe schema is written in the
losing order, and it now waits for `First value` on a real enquirer render, so
removing the unwrapping fails a test that talks to an actual terminal rather
than to a mocked `Form`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCzYTAm9QUhvLNr2EpdagJ
535 lines
19 KiB
TypeScript
535 lines
19 KiB
TypeScript
/**
|
|
* Tests for nopy.prompts.
|
|
*
|
|
* inquirer and enquirer are mocked so nothing touches a TTY. What is actually
|
|
* under test is the logic wrapped around them: choice construction, the `when`
|
|
* predicates, host-string mapping and zod-driven value coercion.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { z } from 'zod';
|
|
|
|
const { inquirerPrompt, formRun, formCtor, autoCompleteRun, autoCompleteCtor } = vi.hoisted(() => ({
|
|
inquirerPrompt: vi.fn(),
|
|
formRun: vi.fn(),
|
|
formCtor: vi.fn(),
|
|
autoCompleteRun: vi.fn(),
|
|
autoCompleteCtor: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('inquirer', () => ({
|
|
default: { prompt: inquirerPrompt },
|
|
}));
|
|
vi.mock('enquirer', () => ({
|
|
default: {
|
|
Form: class {
|
|
run = formRun;
|
|
constructor(options: unknown) {
|
|
formCtor(options);
|
|
}
|
|
},
|
|
AutoComplete: class {
|
|
run = autoCompleteRun;
|
|
constructor(options: unknown) {
|
|
autoCompleteCtor(options);
|
|
}
|
|
},
|
|
},
|
|
}));
|
|
|
|
import { Cube, Manifest } from '@bitsquare/nopy-cubes';
|
|
import { Variables } from '../src/nopy.common.js';
|
|
import {
|
|
AuthSelection,
|
|
CubeSelection,
|
|
HostSelection,
|
|
PasswordSelection,
|
|
VariableAssignment,
|
|
} from '../src/nopy.prompts.js';
|
|
import { foreignZodSchema } from './helpers/foreign-zod.js';
|
|
|
|
/** Grabs the single question object passed to the last inquirer.prompt call. */
|
|
const questions = () => inquirerPrompt.mock.calls.at(-1)?.[0] as Record<string, any>[];
|
|
const question = (name: string) => questions().find((q) => q.name === name);
|
|
|
|
/** Grabs the options the last enquirer AutoComplete prompt was constructed with. */
|
|
const autoComplete = () => autoCompleteCtor.mock.calls.at(-1)?.[0] as Record<string, any>;
|
|
|
|
/** Grabs the options the last enquirer Form prompt was constructed with. */
|
|
const formOptions = () => formCtor.mock.calls.at(-1)?.[0] as Record<string, any>;
|
|
|
|
/** Grabs the choices the last enquirer Form prompt was constructed with. */
|
|
const formChoices = () => formOptions().choices as Record<string, any>[];
|
|
|
|
/** Runs `body` with the terminal reporting the given size, then puts it back. */
|
|
async function withTerminal(
|
|
size: { rows: number; columns: number },
|
|
body: () => Promise<void>
|
|
): Promise<void> {
|
|
const was = { rows: process.stdout.rows, columns: process.stdout.columns };
|
|
Object.defineProperty(process.stdout, 'rows', { value: size.rows, configurable: true });
|
|
Object.defineProperty(process.stdout, 'columns', { value: size.columns, configurable: true });
|
|
try {
|
|
await body();
|
|
} finally {
|
|
Object.defineProperty(process.stdout, 'rows', { value: was.rows, configurable: true });
|
|
Object.defineProperty(process.stdout, 'columns', { value: was.columns, configurable: true });
|
|
}
|
|
}
|
|
|
|
const cube = (id: string, name: string, schema = z.object({})) =>
|
|
new Cube(Manifest({ id, name, schema }), `/cubes/${id}`, 'deploy.py');
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
});
|
|
|
|
describe('CubeSelection', () => {
|
|
const cubes = {
|
|
b: cube('cube-b', 'Beta'),
|
|
a: cube('cube-a', 'Alpha'),
|
|
};
|
|
|
|
it('returns the selection', async () => {
|
|
autoCompleteRun.mockResolvedValue(['cube-a']);
|
|
|
|
await expect(CubeSelection(cubes)).resolves.toEqual({ selectedCubes: ['cube-a'] });
|
|
});
|
|
|
|
it('sorts choices by cube id', async () => {
|
|
autoCompleteRun.mockResolvedValue([]);
|
|
|
|
await CubeSelection(cubes);
|
|
|
|
const { choices } = autoComplete();
|
|
expect(choices.map((c: { name: string }) => c.name)).toEqual(['cube-a', 'cube-b']);
|
|
expect(choices[0].message).toBe('cube-a - Alpha');
|
|
});
|
|
|
|
it('returns every choice for an undefined filter', async () => {
|
|
autoCompleteRun.mockResolvedValue([]);
|
|
|
|
await CubeSelection(cubes);
|
|
|
|
const { choices, suggest } = autoComplete();
|
|
expect(suggest(undefined, choices)).toHaveLength(2);
|
|
expect(suggest('', choices)).toHaveLength(2);
|
|
});
|
|
|
|
it('fuzzy filters on the visible label', async () => {
|
|
autoCompleteRun.mockResolvedValue([]);
|
|
|
|
await CubeSelection(cubes);
|
|
|
|
const { choices, suggest } = autoComplete();
|
|
expect(suggest('Alph', choices).map((c: { name: string }) => c.name)).toEqual(['cube-a']);
|
|
});
|
|
|
|
it('derives page size from the terminal height', async () => {
|
|
autoCompleteRun.mockResolvedValue([]);
|
|
|
|
await withTerminal({ rows: 40, columns: 200 }, async () => {
|
|
await CubeSelection(cubes);
|
|
expect(autoComplete().limit).toBe(35);
|
|
});
|
|
|
|
// A terminal reporting nothing is floored, not believed.
|
|
await withTerminal({ rows: 0, columns: 0 }, async () => {
|
|
await CubeSelection(cubes);
|
|
expect(autoComplete().limit).toBe(19);
|
|
});
|
|
});
|
|
|
|
it('hands the prompt a window size it can render into', async () => {
|
|
autoCompleteRun.mockResolvedValue([]);
|
|
|
|
// Passing `rows` is what keeps enquirer away from its own `utils.height`,
|
|
// which overwrites a good fallback with `getWindowSize()[1]` — zero here.
|
|
await withTerminal({ rows: 0, columns: 0 }, async () => {
|
|
await CubeSelection(cubes);
|
|
expect(autoComplete()).toMatchObject({ rows: 24, columns: 80 });
|
|
});
|
|
|
|
await withTerminal({ rows: 50, columns: 200 }, async () => {
|
|
await CubeSelection(cubes);
|
|
expect(autoComplete()).toMatchObject({ rows: 50, columns: 200 });
|
|
});
|
|
});
|
|
|
|
it('lets a cancellation travel instead of returning an empty selection', async () => {
|
|
// An empty selection is a legitimate answer, so swallowing the rejection
|
|
// here made "the user backed out" and "the user picked nothing" the same
|
|
// event and let the run continue to deploy zero cubes.
|
|
autoCompleteRun.mockRejectedValue(new Error('cancelled'));
|
|
|
|
await expect(CubeSelection(cubes)).rejects.toThrow('cancelled');
|
|
});
|
|
});
|
|
|
|
describe('AuthSelection', () => {
|
|
it('short-circuits to ssh-key without prompting', async () => {
|
|
await expect(AuthSelection(true)).resolves.toEqual({ authMethod: 'ssh-key' });
|
|
expect(inquirerPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('prompts when no key is forced', async () => {
|
|
inquirerPrompt.mockResolvedValue({ authMethod: 'password', username: 'u', password: 'p' });
|
|
|
|
await expect(AuthSelection()).resolves.toEqual({
|
|
authMethod: 'password',
|
|
username: 'u',
|
|
password: 'p',
|
|
});
|
|
});
|
|
|
|
it('asks for credentials only when the method is not ssh-key', async () => {
|
|
inquirerPrompt.mockResolvedValue({ authMethod: 'ssh-key' });
|
|
|
|
await AuthSelection(false);
|
|
|
|
expect(question('username')?.when({ authMethod: 'password' })).toBe(true);
|
|
expect(question('username')?.when({ authMethod: 'ssh-key' })).toBe(false);
|
|
expect(question('password')?.when({ authMethod: 'password' })).toBe(true);
|
|
expect(question('password')?.when({ authMethod: 'ssh-key' })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('PasswordSelection', () => {
|
|
it('returns the entered password', async () => {
|
|
inquirerPrompt.mockResolvedValue({ password: 'hunter2' });
|
|
|
|
await expect(PasswordSelection('deploy')).resolves.toBe('hunter2');
|
|
expect(question('password')?.message).toContain('deploy');
|
|
});
|
|
});
|
|
|
|
describe('HostSelection', () => {
|
|
it('offers the configured hosts alongside the built-ins', async () => {
|
|
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
|
|
|
|
await HostSelection(['web-1', 'web-2']);
|
|
|
|
expect(question('host')?.choices).toEqual(['docker', 'vagrant', 'web-1', 'web-2', 'custom']);
|
|
});
|
|
|
|
it('returns a plain host as-is', async () => {
|
|
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
|
|
|
|
await expect(HostSelection(['web-1'])).resolves.toBe('web-1');
|
|
});
|
|
|
|
it('returns the custom address when custom is chosen', async () => {
|
|
inquirerPrompt.mockResolvedValue({ host: 'custom', customHost: '10.0.0.5' });
|
|
|
|
await expect(HostSelection([])).resolves.toBe('10.0.0.5');
|
|
});
|
|
|
|
it('prefixes a vagrant machine', async () => {
|
|
inquirerPrompt.mockResolvedValue({ host: 'vagrant', vagrantVM: 'builder' });
|
|
|
|
await expect(HostSelection([])).resolves.toBe('@vagrant/builder');
|
|
});
|
|
|
|
it('prefixes a docker container', async () => {
|
|
inquirerPrompt.mockResolvedValue({ host: 'docker', dockerTarget: 'box' });
|
|
|
|
await expect(HostSelection([])).resolves.toBe('@docker/box');
|
|
});
|
|
|
|
it('prefixes a docker image reference the same way', async () => {
|
|
inquirerPrompt.mockResolvedValue({ host: 'docker', dockerTarget: 'ubuntu:24.04' });
|
|
|
|
await expect(HostSelection([])).resolves.toBe('@docker/ubuntu:24.04');
|
|
});
|
|
|
|
it('trims the docker identifier', async () => {
|
|
inquirerPrompt.mockResolvedValue({ host: 'docker', dockerTarget: ' ubuntu:24.04 ' });
|
|
|
|
await expect(HostSelection([])).resolves.toBe('@docker/ubuntu:24.04');
|
|
});
|
|
|
|
it('rejects an empty docker identifier', async () => {
|
|
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
|
|
|
|
await HostSelection([]);
|
|
|
|
expect(question('dockerTarget')?.validate(' ')).toBe('Required');
|
|
expect(question('dockerTarget')?.validate('ubuntu:24.04')).toBe(true);
|
|
});
|
|
|
|
it('gates the follow-up questions on the chosen host', async () => {
|
|
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
|
|
|
|
await HostSelection([]);
|
|
|
|
expect(question('customHost')?.when({ host: 'custom' })).toBe(true);
|
|
expect(question('customHost')?.when({ host: 'web-1' })).toBe(false);
|
|
expect(question('vagrantVM')?.when({ host: 'vagrant' })).toBe(true);
|
|
expect(question('vagrantVM')?.when({ host: 'web-1' })).toBe(false);
|
|
// The regression: the gate compared against the `runtime:docker` *cube id*,
|
|
// so picking `docker` from the list skipped this question entirely and the
|
|
// host came back as the literal string `docker`.
|
|
expect(question('dockerTarget')?.when({ host: 'docker' })).toBe(true);
|
|
expect(question('dockerTarget')?.when({ host: 'runtime:docker' })).toBe(false);
|
|
expect(question('dockerTarget')?.when({ host: 'web-1' })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('question types', () => {
|
|
it('declares only types the installed inquirer actually ships', async () => {
|
|
// Checked against the real module, not the mock: `list` was accepted for
|
|
// years and inquirer 14 dropped it, which took out host selection entirely
|
|
// — a failure no amount of mocked prompting can see.
|
|
const actual = await vi.importActual<typeof import('inquirer')>('inquirer');
|
|
const supported = Object.keys(actual.createPromptModule().prompts);
|
|
|
|
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
|
|
await HostSelection(['web-1']);
|
|
inquirerPrompt.mockResolvedValue({ authMethod: 'password', username: 'u', password: 'p' });
|
|
await AuthSelection(false);
|
|
inquirerPrompt.mockResolvedValue({ password: 'p' });
|
|
await PasswordSelection('deploy');
|
|
|
|
const declared = new Set(
|
|
inquirerPrompt.mock.calls.flatMap(([asked]: [Record<string, any>[]]) =>
|
|
asked.map((q) => q.type ?? 'input')
|
|
)
|
|
);
|
|
|
|
expect(declared.size).toBeGreaterThan(0);
|
|
expect(supported).toEqual(expect.arrayContaining([...declared]));
|
|
});
|
|
});
|
|
|
|
describe('VariableAssignment', () => {
|
|
const schema = z.object({
|
|
port: z.number().default(8080).describe('Listen port'),
|
|
enabled: z.boolean().default(false),
|
|
name: z.string().default('svc'),
|
|
});
|
|
|
|
it('does nothing when every default is already supplied as a param', async () => {
|
|
const variables = new Variables();
|
|
variables.assign('svc', 'param', { port: 1, enabled: true, name: 'x' });
|
|
|
|
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
|
|
|
expect(formRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does nothing for a cube with no defaults', async () => {
|
|
await VariableAssignment(cube('bare', 'Bare'), new Variables());
|
|
|
|
expect(formRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('leaves out a key a dependency already supplied', async () => {
|
|
const variables = new Variables();
|
|
variables.assign('svc', 'param', { port: 9090 });
|
|
formRun.mockResolvedValue({});
|
|
|
|
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
|
|
|
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 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(wifi, variables);
|
|
|
|
expect(formChoices()).toEqual([
|
|
{ name: 'SSID', message: 'Network name', initial: '' },
|
|
{ name: 'PRIORITY', message: 'PRIORITY', initial: '10' },
|
|
]);
|
|
});
|
|
|
|
it('finds the label whichever side of .default() it was declared on', async () => {
|
|
// zod 4 keys a description to the schema *instance* and `.default()` returns
|
|
// a new wrapper, so `describe().default()` used to prompt with the bare key.
|
|
// 15 of the 22 core cubes were written that way round.
|
|
const both = cube(
|
|
'both',
|
|
'Both',
|
|
z.object({
|
|
BEFORE: z.boolean().describe('Update package cache').default(false),
|
|
AFTER: z.boolean().default(false).describe('Restart afterwards'),
|
|
WRAPPED: z.string().describe('Optional note').optional().default('n/a'),
|
|
NEITHER: z.string().default('x'),
|
|
})
|
|
);
|
|
const variables = new Variables();
|
|
variables.assign('both', 'default', both.getDefaults());
|
|
formRun.mockResolvedValue({});
|
|
|
|
await VariableAssignment(both, variables);
|
|
|
|
expect(formChoices().map((c) => c.message)).toEqual([
|
|
'Update package cache',
|
|
'Restart afterwards',
|
|
'Optional note',
|
|
'NEITHER',
|
|
]);
|
|
});
|
|
|
|
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(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')).toEqual({
|
|
port: 9090,
|
|
enabled: true,
|
|
name: 'api',
|
|
});
|
|
});
|
|
|
|
it('leaves an unparseable number as the raw string', async () => {
|
|
const variables = new Variables();
|
|
formRun.mockResolvedValue({ port: 'not-a-number', enabled: 'no', name: 'api' });
|
|
|
|
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
|
|
|
expect(variables.get('svc').port).toBe('not-a-number');
|
|
expect(variables.get('svc').enabled).toBe(false);
|
|
});
|
|
|
|
it('accepts yes and 1 as truthy booleans', async () => {
|
|
const variables = new Variables();
|
|
formRun.mockResolvedValue({ port: '1', enabled: 'yes', name: 'api' });
|
|
|
|
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
|
|
|
expect(variables.get('svc').enabled).toBe(true);
|
|
});
|
|
|
|
it('unwraps optional and nullable schema types', async () => {
|
|
const nullableSchema = z.object({
|
|
maybe: z.number().nullable().default(1),
|
|
opt: z.number().optional().default(2),
|
|
given: z.number().nullable().default(3),
|
|
});
|
|
const variables = new Variables();
|
|
formRun.mockResolvedValue({ maybe: 'null', opt: '7', given: '42' });
|
|
|
|
await VariableAssignment(cube('svc', 'Service', nullableSchema), variables);
|
|
|
|
expect(variables.get('svc')).toEqual({ maybe: null, opt: 7, given: 42 });
|
|
});
|
|
|
|
it('treats an empty string as null for a nullable field', async () => {
|
|
const nullableSchema = z.object({ maybe: z.number().nullable().default(1) });
|
|
const variables = new Variables();
|
|
formRun.mockResolvedValue({ maybe: '' });
|
|
|
|
await VariableAssignment(cube('svc', 'Service', nullableSchema), variables);
|
|
|
|
expect(variables.get('svc').maybe).toBe(null);
|
|
});
|
|
|
|
it('passes non-string answers through untouched', async () => {
|
|
const variables = new Variables();
|
|
formRun.mockResolvedValue({ port: 9090, enabled: true, name: 'api' });
|
|
|
|
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
|
|
|
expect(variables.get('svc').port).toBe(9090);
|
|
});
|
|
|
|
it('keeps answers for keys the schema does not describe', async () => {
|
|
const variables = new Variables();
|
|
formRun.mockResolvedValue({ port: '1', enabled: 'true', name: 'api', extra: 'kept' });
|
|
|
|
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
|
|
|
expect(variables.get('svc').extra).toBe('kept');
|
|
});
|
|
|
|
it('hands the form a window size it can render into', async () => {
|
|
const variables = new Variables();
|
|
formRun.mockResolvedValue({});
|
|
|
|
// The form is where a zero height actually costs something: enquirer
|
|
// renders `Math.min(limit, height)` fields, so a 0-row terminal shows none
|
|
// of them and submits `{}` without the user ever seeing the questions.
|
|
await withTerminal({ rows: 0, columns: 0 }, async () => {
|
|
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
|
expect(formOptions()).toMatchObject({ rows: 24, columns: 80 });
|
|
});
|
|
|
|
await withTerminal({ rows: 50, columns: 200 }, async () => {
|
|
await VariableAssignment(cube('svc', 'Service', schema), variables);
|
|
expect(formOptions()).toMatchObject({ rows: 50, columns: 200 });
|
|
});
|
|
});
|
|
|
|
it('lets a cancelled form travel rather than assigning nothing', async () => {
|
|
const variables = new Variables();
|
|
formRun.mockRejectedValue(new Error('cancelled'));
|
|
|
|
await expect(VariableAssignment(cube('svc', 'Service', schema), variables)).rejects.toThrow(
|
|
'cancelled'
|
|
);
|
|
expect(variables.get('svc')).toEqual({});
|
|
});
|
|
|
|
it('coerces against a schema built by a different copy of zod', async () => {
|
|
// Guards the discriminant in `coerceValue`: under `instanceof` every check
|
|
// here returns false and the answers stay strings, silently.
|
|
const variables = new Variables();
|
|
formRun.mockResolvedValue({ port: '9090', enabled: 'true', maybe: '' });
|
|
|
|
await VariableAssignment(
|
|
cube(
|
|
'svc',
|
|
'Service',
|
|
foreignZodSchema(
|
|
z.object({
|
|
port: z.number().default(8080),
|
|
enabled: z.boolean().default(false),
|
|
maybe: z.number().nullable().default(1),
|
|
})
|
|
)
|
|
),
|
|
variables
|
|
);
|
|
|
|
expect(variables.get('svc')).toEqual({ port: 9090, enabled: true, maybe: null });
|
|
});
|
|
});
|