This commit is contained in:
@@ -108,19 +108,48 @@ describe('resolveCubePackages', () => {
|
||||
expect(errors[0]).toMatch(/cannot read/);
|
||||
});
|
||||
|
||||
it('reports a package that declares no cubes', () => {
|
||||
install(tmpDir, 'plain', {});
|
||||
it('falls back to cubes/ when the package declares nothing', () => {
|
||||
// The convention. A bundle that ships cubes/ at its root needs no `nopy`
|
||||
// block at all, and one with an unrelated `nopy` block still gets it.
|
||||
const plain = install(tmpDir, 'plain', {});
|
||||
const other = install(tmpDir, 'other', { nopy: { somethingElse: true } });
|
||||
|
||||
const { packages, errors } = resolveCubePackages(
|
||||
['plain', 'other'].map((spec) => ({ spec, from: tmpDir }))
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(packages.map((pkg) => pkg.dirs)).toEqual([
|
||||
[path.join(plain, 'cubes')],
|
||||
[path.join(other, 'cubes')],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a package with neither a declaration nor a cubes/ directory', () => {
|
||||
install(tmpDir, 'bare', {}, []);
|
||||
|
||||
const { packages, errors } = resolveCubePackages([{ spec: 'bare', from: tmpDir }]);
|
||||
|
||||
expect(packages).toEqual([]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toMatch(/has no cubes\/ directory/);
|
||||
expect(errors[0]).toMatch(/declares no "nopy"/);
|
||||
});
|
||||
|
||||
it('reports a malformed declaration instead of falling back to the default', () => {
|
||||
// Each of these ships a usable cubes/ directory. Saying something that does
|
||||
// not parse is not the same as saying nothing, so none of them resolve.
|
||||
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 }))
|
||||
['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/);
|
||||
expect(errors).toHaveLength(3);
|
||||
for (const error of errors) expect(error).toMatch(/must be a non-empty array of strings/);
|
||||
});
|
||||
|
||||
it('reports a cube directory that does not exist', () => {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Tests for nopy.exit.
|
||||
*
|
||||
* The handlers are invoked by calling the listener `installGracefulExit`
|
||||
* registered, not by `process.emit()`-ing the event: vitest listens for
|
||||
* `unhandledRejection` and `uncaughtException` itself and would report a
|
||||
* synthetic one as a failure of the test file.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CANCELLED_EXIT_CODE,
|
||||
exitWithFarewell,
|
||||
FAREWELL,
|
||||
installGracefulExit,
|
||||
isCancellation,
|
||||
restoreTerminal,
|
||||
} from '../src/nopy.exit.js';
|
||||
|
||||
let exit: ReturnType<typeof vi.spyOn>;
|
||||
let stderr: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/** Pretends stdin/stdout are the terminal a prompt would have taken over. */
|
||||
function fakeTerminal(opts: { isTTY: boolean; isRaw?: boolean }) {
|
||||
const setRawMode = vi.fn();
|
||||
const original = {
|
||||
isTTY: process.stdin.isTTY,
|
||||
isRaw: process.stdin.isRaw,
|
||||
setRawMode: process.stdin.setRawMode,
|
||||
outTTY: process.stdout.isTTY,
|
||||
};
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: opts.isTTY, configurable: true });
|
||||
Object.defineProperty(process.stdin, 'isRaw', { value: opts.isRaw ?? false, configurable: true });
|
||||
Object.defineProperty(process.stdin, 'setRawMode', { value: setRawMode, configurable: true });
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: opts.isTTY, configurable: true });
|
||||
|
||||
const restore = () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: original.isTTY, configurable: true });
|
||||
Object.defineProperty(process.stdin, 'isRaw', { value: original.isRaw, configurable: true });
|
||||
Object.defineProperty(process.stdin, 'setRawMode', {
|
||||
value: original.setRawMode,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: original.outTTY, configurable: true });
|
||||
};
|
||||
|
||||
return { setRawMode, restore };
|
||||
}
|
||||
|
||||
/** The listener `installGracefulExit` most recently added for `event`. */
|
||||
const lastListener = (event: string) =>
|
||||
process.listeners(event as 'SIGINT').at(-1) as (reason?: unknown) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('isCancellation', () => {
|
||||
it('recognises enquirer tearing down a readline node already closed', () => {
|
||||
const err = Object.assign(new Error('readline was closed'), { code: 'ERR_USE_AFTER_CLOSE' });
|
||||
|
||||
expect(isCancellation(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises the inquirer cancellations', () => {
|
||||
const exitPrompt = Object.assign(new Error('User force closed the prompt'), {
|
||||
name: 'ExitPromptError',
|
||||
});
|
||||
const cancelPrompt = Object.assign(new Error('Prompt was canceled'), {
|
||||
name: 'CancelPromptError',
|
||||
});
|
||||
|
||||
expect(isCancellation(exitPrompt)).toBe(true);
|
||||
expect(isCancellation(cancelPrompt)).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises the bare values enquirer rejects a cancelled prompt with', () => {
|
||||
expect(isCancellation('')).toBe(true);
|
||||
expect(isCancellation('\x03')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a genuine failure alone', () => {
|
||||
expect(isCancellation(new Error('pyinfra exited 1'))).toBe(false);
|
||||
expect(isCancellation(Object.assign(new Error('nope'), { code: 'ENOENT' }))).toBe(false);
|
||||
expect(isCancellation('boom')).toBe(false);
|
||||
expect(isCancellation(undefined)).toBe(false);
|
||||
expect(isCancellation(null)).toBe(false);
|
||||
expect(isCancellation(7)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreTerminal', () => {
|
||||
it('leaves raw mode and shows the cursor again', () => {
|
||||
const terminal = fakeTerminal({ isTTY: true, isRaw: true });
|
||||
|
||||
restoreTerminal();
|
||||
|
||||
expect(terminal.setRawMode).toHaveBeenCalledWith(false);
|
||||
expect(process.stdout.write).toHaveBeenCalledWith('\x1B[?25h');
|
||||
terminal.restore();
|
||||
});
|
||||
|
||||
it('touches nothing when the output is not a terminal', () => {
|
||||
const terminal = fakeTerminal({ isTTY: false });
|
||||
|
||||
restoreTerminal();
|
||||
|
||||
expect(terminal.setRawMode).not.toHaveBeenCalled();
|
||||
expect(process.stdout.write).not.toHaveBeenCalled();
|
||||
terminal.restore();
|
||||
});
|
||||
|
||||
it('survives a stdin that refuses to leave raw mode', () => {
|
||||
const terminal = fakeTerminal({ isTTY: true, isRaw: true });
|
||||
terminal.setRawMode.mockImplementation(() => {
|
||||
throw new Error('stdin destroyed');
|
||||
});
|
||||
|
||||
expect(() => restoreTerminal()).not.toThrow();
|
||||
terminal.restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exitWithFarewell', () => {
|
||||
it('says goodbye on stderr and exits 130', () => {
|
||||
exitWithFarewell();
|
||||
|
||||
expect(stderr).toHaveBeenCalledWith(`\n${FAREWELL}\n`);
|
||||
expect(exit).toHaveBeenCalledWith(CANCELLED_EXIT_CODE);
|
||||
});
|
||||
|
||||
it('accepts a different exit code', () => {
|
||||
exitWithFarewell(0);
|
||||
|
||||
expect(exit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installGracefulExit', () => {
|
||||
let dispose: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
dispose = installGracefulExit();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('says goodbye on SIGINT', () => {
|
||||
lastListener('SIGINT')();
|
||||
|
||||
expect(stderr).toHaveBeenCalledWith(`\n${FAREWELL}\n`);
|
||||
expect(exit).toHaveBeenCalledWith(CANCELLED_EXIT_CODE);
|
||||
});
|
||||
|
||||
it('says goodbye for the rejection nothing is awaiting', () => {
|
||||
// The shape of the real crash: enquirer's Ctrl-C teardown, which leaves
|
||||
// `prompt.run()` pending forever, so no `catch` in nopy.prompts sees it.
|
||||
lastListener('unhandledRejection')(
|
||||
Object.assign(new Error('readline was closed'), { code: 'ERR_USE_AFTER_CLOSE' })
|
||||
);
|
||||
|
||||
expect(stderr).toHaveBeenCalledWith(`\n${FAREWELL}\n`);
|
||||
expect(exit).toHaveBeenCalledWith(CANCELLED_EXIT_CODE);
|
||||
});
|
||||
|
||||
it('still reports a real crash, loudly, and exits 1', () => {
|
||||
const boom = new Error('everything is on fire');
|
||||
|
||||
lastListener('uncaughtException')(boom);
|
||||
|
||||
expect(stderr).not.toHaveBeenCalled();
|
||||
expect(console.error).toHaveBeenCalledWith(boom.stack);
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('reports a thrown non-error too', () => {
|
||||
lastListener('uncaughtException')('just a string');
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith('just a string');
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('reports an error with no stack by its message', () => {
|
||||
const stackless = new Error('no stack here');
|
||||
stackless.stack = undefined;
|
||||
|
||||
lastListener('uncaughtException')(stackless);
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith('no stack here');
|
||||
});
|
||||
|
||||
it('hands the process back on dispose', () => {
|
||||
const before = {
|
||||
SIGINT: process.listenerCount('SIGINT'),
|
||||
uncaughtException: process.listenerCount('uncaughtException'),
|
||||
unhandledRejection: process.listenerCount('unhandledRejection'),
|
||||
};
|
||||
|
||||
dispose();
|
||||
|
||||
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT - 1);
|
||||
expect(process.listenerCount('uncaughtException')).toBe(before.uncaughtException - 1);
|
||||
expect(process.listenerCount('unhandledRejection')).toBe(before.unhandledRejection - 1);
|
||||
|
||||
// The afterEach disposer runs a second time; make it a no-op.
|
||||
dispose = () => {};
|
||||
});
|
||||
});
|
||||
@@ -218,6 +218,32 @@ describe('HostSelection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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'),
|
||||
|
||||
Reference in New Issue
Block a user