hardening and bugfixing prior to stable release

This commit is contained in:
Benjamin Diedrichsen
2026-07-31 18:21:43 +02:00
parent ac7ea07e3c
commit 0aa0be5542
44 changed files with 3016 additions and 436 deletions
+118 -1
View File
@@ -5,12 +5,14 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createSession,
describeSession,
listSessions,
loadSession,
type NopySession,
SESSION_VERSION,
saveSession,
} from '../src/nopy.session.js';
@@ -48,6 +50,62 @@ describe('createSession', () => {
expect(session.env).toEqual({ KEY: 'value' });
});
it('stamps the format version and a creation time', () => {
const session = createSession({ cubes: [], hosts: ['localhost'], auth: { method: 'ssh' } });
expect(session.version).toBe(SESSION_VERSION);
expect(new Date(session.timestamp!).toISOString()).toBe(session.timestamp);
});
it('lets the caller supply the timestamp', () => {
const session = createSession({
cubes: [],
hosts: ['localhost'],
auth: { method: 'ssh' },
timestamp: '2026-01-01T00:00:00.000Z',
});
expect(session.timestamp).toBe('2026-01-01T00:00:00.000Z');
});
});
describe('describeSession', () => {
const at = '2026-01-01T12:30:00.000Z';
it('names the cubes and the hosts', () => {
const name = describeSession(
{
cubes: [{ key: 'apt:essentials', variables: {} }],
hosts: ['web-1'],
auth: { method: 'ssh' },
},
at
);
expect(name).toContain('apt:essentials');
expect(name).toContain('web-1');
});
it('says so when there is no host', () => {
const name = describeSession({ cubes: [], auth: { method: 'ssh' } }, at);
expect(name).toContain('no host');
});
it('truncates a long cube list and a long host list', () => {
const name = describeSession(
{
cubes: Array.from({ length: 10 }, (_, i) => ({ key: `cube-${i}`, variables: {} })),
hosts: Array.from({ length: 10 }, (_, i) => `host-${i}`),
auth: { method: 'ssh' },
},
at
);
expect(name).toContain('...');
expect(name.split('→')[1]).toContain('...');
});
});
describe('saveSession and loadSession', () => {
@@ -116,6 +174,51 @@ describe('saveSession and loadSession', () => {
await expect(loadSession(sessionPath)).rejects.toThrow('auth');
});
// Half of SESSION_FORMAT.md is about the MJS form, and nothing exercised it.
// Each test needs its own filename: `import()` caches by URL, so a second
// module written to the same path would never be read.
it('loads a session from an MJS default export', async () => {
const mjsPath = path.join(tempDir, 'ok.session.mjs');
fs.writeFileSync(
mjsPath,
'export default { cubes: [{ key: "apt:essentials", variables: {} }], auth: { method: "ssh" } };'
);
await expect(loadSession(mjsPath)).resolves.toMatchObject({
cubes: [{ key: 'apt:essentials', variables: {} }],
});
});
it('rejects an MJS session with no default export', async () => {
const mjsPath = path.join(tempDir, 'no-default.session.mjs');
fs.writeFileSync(mjsPath, 'export const session = {};');
await expect(loadSession(mjsPath)).rejects.toThrow('must export a default object');
});
it('loads a session with no version at all', async () => {
fs.writeFileSync(sessionPath, JSON.stringify({ cubes: [], auth: { method: 'ssh' } }));
const warn = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(loadSession(sessionPath)).resolves.toMatchObject({ cubes: [] });
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
it('warns about an unknown version but still loads it', async () => {
fs.writeFileSync(
sessionPath,
JSON.stringify({ version: '9.9.9', cubes: [], auth: { method: 'ssh' } })
);
const warn = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(loadSession(sessionPath)).resolves.toMatchObject({ version: '9.9.9' });
expect(warn.mock.calls[0][0]).toContain('9.9.9');
warn.mockRestore();
});
});
describe('listSessions', () => {
@@ -154,4 +257,18 @@ describe('listSessions', () => {
expect(result).toHaveLength(1);
expect(result[0].endsWith('test.session.mjs')).toBe(true);
});
it('finds the documented .nopysession.* files', () => {
// The name every example in the README uses, and the one this missed:
// `wild.nopysession.json` does not end in `.session.json`.
fs.writeFileSync(path.join(tempDir, 'wild.nopysession.json'), '{}');
fs.writeFileSync(path.join(tempDir, 'wild.nopysession.mjs'), 'export default {}');
fs.writeFileSync(path.join(tempDir, 'nopysession.json'), '{}');
const result = listSessions(tempDir);
expect(result).toHaveLength(2);
expect(result.some((p) => p.endsWith('wild.nopysession.json'))).toBe(true);
expect(result.some((p) => p.endsWith('wild.nopysession.mjs'))).toBe(true);
});
});