587ff2cf47
Publish snapshot / snapshot (push) Failing after 1m58s
Publishing infrastructure - Three Gitea workflows: ci.yml (PRs, non-main pushes), publish-snapshot.yml (main -> Gitea under dist-tag @main) and release.yml (tags -> Gitea + npmjs) - Tag-driven releases as <package-dir>-v<version>; the manifest stays the source of truth and release.yml refuses to run if tag and manifest disagree - Every publish is idempotent: each step checks the registry first, so a run that fails on the second registry can simply be re-run - Hard coverage gate (85% branches) shared by CI, the pre-push hook and local runs, since the thresholds live in vitest.config.ts rather than a CI flag - README.PUBLISH.md documents the whole mechanism Toolchain - TypeScript 7 native compiler; drop tsgo and ts-node, use tsx for dev runs - Biome 1.9 -> 2.x, Vitest 1 -> 4, zod 3 -> 4, inquirer 8 -> 14, pnpm 11.17.0 - Replace inquirer-checkbox-plus-prompt, which is peer-capped at inquirer <9, with enquirer's AutoComplete; the CubeSelection contract is unchanged - Stand in for zod 4's removed z.AnyZodObject with a local AnyObjectSchema Repo hygiene - Stop tracking dist/; ignore coverage/, *.tsbuildinfo, .npmrc* and release.json - Drop package-lock.json in favour of pnpm-lock.yaml Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
/**
|
|
* Tests for nopy.workflow module.
|
|
*
|
|
* The prompt layer is the only I/O in this module, so mocking nopy.prompts
|
|
* exercises every branch without touching a TTY.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../src/nopy.prompts.js', () => ({
|
|
CubeSelection: vi.fn(),
|
|
HostSelection: vi.fn(),
|
|
AuthSelection: vi.fn(),
|
|
PasswordSelection: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../src/nopy.session.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../src/nopy.session.js')>();
|
|
return { ...actual, loadSession: vi.fn() };
|
|
});
|
|
|
|
import type { Cube } from '../src/cubes/index.js';
|
|
import type { NopyConfig } from '../src/nopy.config.js';
|
|
import {
|
|
AuthSelection,
|
|
CubeSelection,
|
|
HostSelection,
|
|
PasswordSelection,
|
|
} from '../src/nopy.prompts.js';
|
|
import type { NopySession } from '../src/nopy.session.js';
|
|
import { loadSession } from '../src/nopy.session.js';
|
|
import {
|
|
runInteractiveWorkflow,
|
|
runReplayWorkflow,
|
|
runSessionReplayWorkflow,
|
|
runWorkflow,
|
|
} from '../src/nopy.workflow.js';
|
|
|
|
const mockCubeSelection = vi.mocked(CubeSelection);
|
|
const mockHostSelection = vi.mocked(HostSelection);
|
|
const mockAuthSelection = vi.mocked(AuthSelection);
|
|
const mockPasswordSelection = vi.mocked(PasswordSelection);
|
|
const mockLoadSession = vi.mocked(loadSession);
|
|
|
|
const config: NopyConfig = {
|
|
hosts: ['web-1', 'web-2'],
|
|
cubeDirs: [],
|
|
env: { GLOBAL: 'value' },
|
|
};
|
|
|
|
const cubes = {
|
|
'cube-a': { id: 'cube-a', name: 'Cube A' } as Cube,
|
|
};
|
|
|
|
const session = (overrides: Partial<NopySession> = {}): NopySession =>
|
|
({
|
|
version: '1.0',
|
|
name: 'test-session',
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
cubes: [{ key: 'cube-a', variables: {} }],
|
|
hosts: ['web-1'],
|
|
auth: { method: 'ssh-key' },
|
|
env: {},
|
|
...overrides,
|
|
}) as NopySession;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockCubeSelection.mockResolvedValue({ selectedCubes: ['cube-a'] });
|
|
mockHostSelection.mockResolvedValue('web-1');
|
|
mockAuthSelection.mockResolvedValue({ authMethod: 'ssh-key' });
|
|
mockPasswordSelection.mockResolvedValue('s3cret');
|
|
});
|
|
|
|
describe('runInteractiveWorkflow', () => {
|
|
it('collects cubes, host and auth into a fresh session', async () => {
|
|
const result = await runInteractiveWorkflow(cubes, config);
|
|
|
|
expect(result.selectedCubes).toEqual(['cube-a']);
|
|
expect(result.authMethod).toBe('ssh-key');
|
|
expect(result.isReplay).toBe(false);
|
|
expect(result.session.hosts).toEqual(['web-1']);
|
|
expect(result.session.env).toEqual({ GLOBAL: 'value' });
|
|
expect(mockHostSelection).toHaveBeenCalledWith(config.hosts);
|
|
});
|
|
|
|
it('forwards useAuthKey to the auth prompt', async () => {
|
|
await runInteractiveWorkflow(cubes, config, { useAuthKey: true });
|
|
expect(mockAuthSelection).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it('carries username and password through from password auth', async () => {
|
|
mockAuthSelection.mockResolvedValue({
|
|
authMethod: 'password',
|
|
username: 'deploy',
|
|
password: 'hunter2',
|
|
});
|
|
|
|
const result = await runInteractiveWorkflow(cubes, config);
|
|
|
|
expect(result.username).toBe('deploy');
|
|
expect(result.password).toBe('hunter2');
|
|
expect(result.session.auth.username).toBe('deploy');
|
|
});
|
|
|
|
it('skips the auth prompt entirely for vagrant hosts', async () => {
|
|
mockHostSelection.mockResolvedValue('@vagrant/default');
|
|
|
|
const result = await runInteractiveWorkflow(cubes, config);
|
|
|
|
expect(mockAuthSelection).not.toHaveBeenCalled();
|
|
expect(result.authMethod).toBe('ssh');
|
|
expect(result.username).toBeUndefined();
|
|
});
|
|
|
|
it('skips the auth prompt entirely for docker hosts', async () => {
|
|
mockHostSelection.mockResolvedValue('@docker/box');
|
|
|
|
const result = await runInteractiveWorkflow(cubes, config);
|
|
|
|
expect(mockAuthSelection).not.toHaveBeenCalled();
|
|
expect(result.authMethod).toBe('ssh');
|
|
});
|
|
|
|
it('proceeds when the user selects nothing', async () => {
|
|
mockCubeSelection.mockResolvedValue({ selectedCubes: [] });
|
|
|
|
const result = await runInteractiveWorkflow(cubes, config);
|
|
|
|
expect(result.selectedCubes).toEqual([]);
|
|
});
|
|
|
|
it('never stores a password on the session', async () => {
|
|
mockAuthSelection.mockResolvedValue({
|
|
authMethod: 'password',
|
|
username: 'deploy',
|
|
password: 'hunter2',
|
|
});
|
|
|
|
const result = await runInteractiveWorkflow(cubes, config);
|
|
|
|
expect(JSON.stringify(result.session)).not.toContain('hunter2');
|
|
});
|
|
});
|
|
|
|
describe('runReplayWorkflow', () => {
|
|
it('replays a session file without prompting', async () => {
|
|
mockLoadSession.mockResolvedValue(session());
|
|
|
|
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
|
|
|
|
expect(mockLoadSession).toHaveBeenCalledWith('/tmp/s.json');
|
|
expect(result.isReplay).toBe(true);
|
|
expect(result.selectedCubes).toEqual(['cube-a']);
|
|
expect(mockHostSelection).not.toHaveBeenCalled();
|
|
expect(mockPasswordSelection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('tolerates a session referencing an unknown cube', async () => {
|
|
mockLoadSession.mockResolvedValue(
|
|
session({ cubes: [{ key: 'ghost-cube', variables: {} }] } as Partial<NopySession>)
|
|
);
|
|
|
|
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
|
|
|
|
expect(result.selectedCubes).toEqual(['ghost-cube']);
|
|
});
|
|
|
|
it('prompts for a host when the session has none', async () => {
|
|
mockLoadSession.mockResolvedValue(session({ hosts: [] }));
|
|
|
|
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
|
|
|
|
expect(mockHostSelection).toHaveBeenCalledWith(config.hosts);
|
|
expect(result.session.hosts).toEqual(['web-1']);
|
|
});
|
|
|
|
it('prompts for a host when hosts is missing entirely', async () => {
|
|
mockLoadSession.mockResolvedValue(session({ hosts: undefined }));
|
|
|
|
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
|
|
|
|
expect(result.session.hosts).toEqual(['web-1']);
|
|
});
|
|
|
|
it('re-prompts only for the password when a username is stored', async () => {
|
|
mockLoadSession.mockResolvedValue(
|
|
session({ auth: { method: 'password', username: 'deploy' } })
|
|
);
|
|
|
|
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
|
|
|
|
expect(mockPasswordSelection).toHaveBeenCalledWith('deploy');
|
|
expect(mockAuthSelection).not.toHaveBeenCalled();
|
|
expect(result.password).toBe('s3cret');
|
|
expect(result.username).toBe('deploy');
|
|
});
|
|
|
|
it('falls back to the full auth prompt when the username is missing', async () => {
|
|
mockLoadSession.mockResolvedValue(session({ auth: { method: 'password' } }));
|
|
mockAuthSelection.mockResolvedValue({
|
|
authMethod: 'password',
|
|
username: 'recovered',
|
|
password: 'fresh',
|
|
});
|
|
|
|
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
|
|
|
|
expect(mockAuthSelection).toHaveBeenCalledWith(false);
|
|
expect(mockPasswordSelection).not.toHaveBeenCalled();
|
|
expect(result.username).toBe('recovered');
|
|
expect(result.password).toBe('fresh');
|
|
});
|
|
|
|
it('propagates load failures', async () => {
|
|
mockLoadSession.mockRejectedValue(new Error('missing file'));
|
|
|
|
await expect(runReplayWorkflow('/tmp/nope.json', cubes, config)).rejects.toThrow(
|
|
'missing file'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('runSessionReplayWorkflow', () => {
|
|
it('replays an in-memory session without prompting', async () => {
|
|
const result = await runSessionReplayWorkflow(session(), cubes, config);
|
|
|
|
expect(result.isReplay).toBe(true);
|
|
expect(result.selectedCubes).toEqual(['cube-a']);
|
|
expect(mockLoadSession).not.toHaveBeenCalled();
|
|
expect(mockHostSelection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('tolerates a session referencing an unknown cube', async () => {
|
|
const result = await runSessionReplayWorkflow(
|
|
session({ cubes: [{ key: 'ghost-cube', variables: {} }] } as Partial<NopySession>),
|
|
cubes,
|
|
config
|
|
);
|
|
|
|
expect(result.selectedCubes).toEqual(['ghost-cube']);
|
|
});
|
|
|
|
it('prompts for a host when the session has none', async () => {
|
|
const result = await runSessionReplayWorkflow(session({ hosts: [] }), cubes, config);
|
|
|
|
expect(mockHostSelection).toHaveBeenCalled();
|
|
expect(result.session.hosts).toEqual(['web-1']);
|
|
});
|
|
|
|
it('prompts for a host when hosts is missing entirely', async () => {
|
|
const result = await runSessionReplayWorkflow(session({ hosts: undefined }), cubes, config);
|
|
|
|
expect(result.session.hosts).toEqual(['web-1']);
|
|
});
|
|
|
|
it('re-prompts only for the password when a username is stored', async () => {
|
|
const result = await runSessionReplayWorkflow(
|
|
session({ auth: { method: 'password', username: 'deploy' } }),
|
|
cubes,
|
|
config
|
|
);
|
|
|
|
expect(mockPasswordSelection).toHaveBeenCalledWith('deploy');
|
|
expect(result.password).toBe('s3cret');
|
|
});
|
|
|
|
it('falls back to the full auth prompt when the username is missing', async () => {
|
|
mockAuthSelection.mockResolvedValue({
|
|
authMethod: 'password',
|
|
username: 'recovered',
|
|
password: 'fresh',
|
|
});
|
|
|
|
const result = await runSessionReplayWorkflow(
|
|
session({ auth: { method: 'password' } }),
|
|
cubes,
|
|
config
|
|
);
|
|
|
|
expect(mockAuthSelection).toHaveBeenCalledWith(false);
|
|
expect(result.username).toBe('recovered');
|
|
});
|
|
});
|
|
|
|
describe('runWorkflow dispatch', () => {
|
|
it('prefers an in-memory replay session over everything else', async () => {
|
|
const result = await runWorkflow('/tmp/s.json', cubes, config, {}, session());
|
|
|
|
expect(result.isReplay).toBe(true);
|
|
expect(mockLoadSession).not.toHaveBeenCalled();
|
|
expect(mockCubeSelection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses the session file when no in-memory session is given', async () => {
|
|
mockLoadSession.mockResolvedValue(session());
|
|
|
|
const result = await runWorkflow('/tmp/s.json', cubes, config);
|
|
|
|
expect(mockLoadSession).toHaveBeenCalledWith('/tmp/s.json');
|
|
expect(result.isReplay).toBe(true);
|
|
expect(mockCubeSelection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to the interactive workflow', async () => {
|
|
const result = await runWorkflow(undefined, cubes, config, { useAuthKey: true });
|
|
|
|
expect(result.isReplay).toBe(false);
|
|
expect(mockCubeSelection).toHaveBeenCalled();
|
|
expect(mockAuthSelection).toHaveBeenCalledWith(true);
|
|
});
|
|
});
|