Skip to content

Commit 9d8da21

Browse files
test: cover new features + enforce coverage threshold in jest and CI
- Add tests: validate command, snapshot delete/restore-backup, generate --dry-run, share import --validate-only, service healthcheck + sqlite warning, validator password false-positive, seed path, init wizard flow (prompts.inject) - jest.config: add global coverageThreshold (regression floor) - CI: run tests with --coverage and upload coverage artifact - 221 tests passing (was 201)
1 parent 48ee4b0 commit 9d8da21

10 files changed

Lines changed: 327 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,12 @@ jobs:
3131
run: npm run build
3232

3333
- name: Test
34-
run: npm test
34+
run: npm test -- --coverage
35+
36+
- name: Upload coverage report
37+
if: always()
38+
uses: actions/upload-artifact@v4
39+
with:
40+
name: coverage-node-${{ matrix.node-version }}
41+
path: coverage/
42+
if-no-files-found: ignore

jest.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ module.exports = {
1111
],
1212
coverageDirectory: 'coverage',
1313
coverageReporters: ['text', 'lcov', 'html'],
14+
coverageThreshold: {
15+
global: {
16+
statements: 80,
17+
branches: 55,
18+
functions: 72,
19+
lines: 80,
20+
},
21+
},
1422
verbose: true,
1523
clearMocks: true,
1624
restoreMocks: true,

tests/commands/generate.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,22 @@ describe('generate command', () => {
140140
expect(content).toContain('redis');
141141
});
142142

143+
it('dry-run prints compose to stdout without writing a file', async () => {
144+
await fs.writeFile(
145+
path.join(tempDir, '.dev-env.yml'),
146+
'name: gen-test\ndatabases:\n - type: redis\n port: 6379\ndocker:\n enabled: true'
147+
);
148+
process.chdir(tempDir);
149+
const writeSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
150+
151+
await runGenerate({ dryRun: true });
152+
153+
const written = writeSpy.mock.calls.map((c) => String(c[0])).join('');
154+
expect(written).toContain('redis');
155+
expect(written).toContain('services:');
156+
expect(await fs.pathExists(path.join(tempDir, 'docker-compose.yml'))).toBe(false);
157+
});
158+
143159
it('uses default output filename when output is empty string', async () => {
144160
await fs.writeFile(
145161
path.join(tempDir, '.dev-env.yml'),

tests/commands/init-wizard.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import * as path from 'path';
2+
import * as fs from 'fs-extra';
3+
import * as yaml from 'js-yaml';
4+
import prompts from 'prompts';
5+
import { runInit } from '../../src/commands/init';
6+
import { DevEnvConfigSchema } from '../../src/types/config';
7+
import { logger } from '../../src/utils/logger';
8+
9+
describe('init wizard (runInit)', () => {
10+
let tempDir: string;
11+
const originalCwd = process.cwd();
12+
const originalTTY = process.stdout.isTTY;
13+
14+
beforeEach(async () => {
15+
jest.spyOn(logger, 'info').mockImplementation();
16+
jest.spyOn(logger, 'success').mockImplementation();
17+
jest.spyOn(logger, 'error').mockImplementation();
18+
jest.spyOn(logger, 'debug').mockImplementation();
19+
// Force interactive so runInit drives the wizard.
20+
process.stdout.isTTY = true;
21+
tempDir = path.join(require('os').tmpdir(), `devkit-initw-${Date.now()}-${Math.floor(performance.now())}`);
22+
await fs.ensureDir(tempDir);
23+
});
24+
25+
afterEach(async () => {
26+
process.stdout.isTTY = originalTTY;
27+
process.chdir(originalCwd);
28+
await fs.remove(tempDir).catch(() => {});
29+
jest.restoreAllMocks();
30+
});
31+
32+
it('builds a valid .dev-env.yml from wizard answers (minimal flow)', async () => {
33+
// Order: name, version, addDeps?, addDb?, addService?, addEnv?, dockerEnabled?
34+
prompts.inject(['my-app', '2.1.0', false, false, false, true, true]);
35+
36+
await runInit(tempDir);
37+
38+
const configPath = path.join(tempDir, '.dev-env.yml');
39+
expect(await fs.pathExists(configPath)).toBe(true);
40+
const parsed = DevEnvConfigSchema.parse(yaml.load(await fs.readFile(configPath, 'utf-8')));
41+
expect(parsed.name).toBe('my-app');
42+
expect(parsed.version).toBe('2.1.0');
43+
expect(parsed.env).toMatchObject({ NODE_ENV: 'development' });
44+
expect(parsed.docker?.enabled).toBe(true);
45+
});
46+
47+
it('captures a database with a health check from wizard answers', async () => {
48+
// name, version, addDeps?, addDb?(yes), dbType, dbFields..., then addDb?(no) etc.
49+
// db prompt sequence after type select: name, port, user, password, database, migrations?, seed?
50+
// To stay robust we drive a redis DB which has the fewest follow-ups.
51+
prompts.inject([
52+
'db-app', '1.0.0',
53+
false, // addDeps?
54+
true, 'redis', // addDb? + type
55+
'cache', '7', 6379, 'localhost', '', '', // db: name, version, port, host, user, password
56+
false, // add migrations?
57+
false, // add seed?
58+
false, // addDb? (stop)
59+
false, // addService?
60+
false, // addEnv?
61+
false, // dockerEnabled?
62+
true, // add health check?
63+
]);
64+
65+
await runInit(tempDir);
66+
67+
const configPath = path.join(tempDir, '.dev-env.yml');
68+
const parsed = DevEnvConfigSchema.parse(yaml.load(await fs.readFile(configPath, 'utf-8')));
69+
expect(parsed.databases?.[0]?.type).toBe('redis');
70+
expect(parsed.health_checks?.some((h) => h.type === 'redis')).toBe(true);
71+
});
72+
});

tests/commands/setup.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,29 @@ describe('setup command', () => {
149149
const seedCall = calls.find((c) => c[0] === 'npm' && c[1].includes('seed'));
150150
expect(seedCall).toBeDefined();
151151
});
152+
153+
it('runs seed in the configured seed path', async () => {
154+
await fs.ensureDir(path.join(tempDir, 'scripts'));
155+
await fs.writeFile(
156+
path.join(tempDir, '.dev-env.yml'),
157+
[
158+
'name: setup-test',
159+
'dependencies: []',
160+
'databases:',
161+
' - type: postgresql',
162+
' port: 5432',
163+
' seed:',
164+
' command: npm run seed',
165+
' path: ./scripts',
166+
].join('\n')
167+
);
168+
process.chdir(tempDir);
169+
170+
await runSetup({ skipDeps: true, skipDb: false });
171+
172+
const calls = (execFn as jest.Mock).mock.calls as [string, string[], Record<string, unknown>][];
173+
const seedCall = calls.find((c) => c[0] === 'npm' && c[1].includes('seed'));
174+
expect(seedCall).toBeDefined();
175+
expect(seedCall![2].cwd).toBe(path.resolve(tempDir, './scripts'));
176+
});
152177
});

tests/commands/share.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,5 +247,26 @@ describe('share command', () => {
247247
const content = await fs.readFile(inCwd, 'utf-8');
248248
expect(content).toMatch(/imported-cwd/);
249249
});
250+
251+
it('validate-only does not write any file', async () => {
252+
const sharedPath = path.join(tempDir, 'shared.yml');
253+
await fs.writeFile(sharedPath, 'name: imported\nversion: "1.0.0"\ndatabases: []');
254+
process.chdir(tempDir);
255+
256+
await runShareImport(sharedPath, { validateOnly: true });
257+
258+
expect(await fs.pathExists(path.join(tempDir, '.dev-env.yml'))).toBe(false);
259+
expect(logger.success).toHaveBeenCalledWith(expect.stringContaining('valid'));
260+
});
261+
262+
it('validate-only still rejects an invalid config', async () => {
263+
const badPath = path.join(tempDir, 'bad.yml');
264+
await fs.writeFile(badPath, 'version: "1.0.0"');
265+
process.chdir(tempDir);
266+
267+
await expect(runShareImport(badPath, { validateOnly: true })).rejects.toThrow(
268+
/Invalid dev-env config|name/
269+
);
270+
});
250271
});
251272
});

tests/commands/snapshot.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as path from 'path';
22
import * as fs from 'fs-extra';
3-
import { runSnapshotCreate, runSnapshotList, runSnapshotRestore } from '../../src/commands/snapshot';
3+
import { runSnapshotCreate, runSnapshotList, runSnapshotRestore, runSnapshotDelete } from '../../src/commands/snapshot';
44
import { listSnapshots } from '../../src/core/snapshot/storage';
55
import { logger } from '../../src/utils/logger';
66

@@ -202,5 +202,52 @@ describe('snapshot commands', () => {
202202
const current = await fs.readFile(path.join(tempDir, '.dev-env.yml'), 'utf-8');
203203
expect(current).toBe(configYaml);
204204
});
205+
206+
it('backs up the current config to a pre-restore snapshot before overwriting', async () => {
207+
await fs.writeFile(path.join(tempDir, '.dev-env.yml'), 'name: original');
208+
process.chdir(tempDir);
209+
await runSnapshotCreate('backup');
210+
await fs.writeFile(
211+
path.join(tempDir, '.devkit', 'snapshots', 'backup', 'dev-env.yml'),
212+
'name: from-backup'
213+
);
214+
215+
await runSnapshotRestore('backup', { yes: true });
216+
217+
const snapshots = await listSnapshots(tempDir);
218+
const preRestore = snapshots.find((s) => s.name.startsWith('pre-restore-'));
219+
expect(preRestore).toBeDefined();
220+
const backupYaml = await fs.readFile(
221+
path.join(tempDir, '.devkit', 'snapshots', preRestore!.name, 'dev-env.yml'),
222+
'utf-8'
223+
);
224+
expect(backupYaml).toBe('name: original');
225+
});
226+
});
227+
228+
describe('runSnapshotDelete', () => {
229+
it('throws when name is empty', async () => {
230+
await fs.writeFile(path.join(tempDir, '.dev-env.yml'), 'name: x');
231+
process.chdir(tempDir);
232+
await expect(runSnapshotDelete('')).rejects.toThrow(/Snapshot name is required/);
233+
});
234+
235+
it('throws when snapshot does not exist', async () => {
236+
await fs.writeFile(path.join(tempDir, '.dev-env.yml'), 'name: x');
237+
process.chdir(tempDir);
238+
await expect(runSnapshotDelete('nope')).rejects.toThrow(/not found|envkit snapshot list/);
239+
});
240+
241+
it('removes an existing snapshot', async () => {
242+
await fs.writeFile(path.join(tempDir, '.dev-env.yml'), 'name: x');
243+
process.chdir(tempDir);
244+
await runSnapshotCreate('doomed');
245+
expect(await listSnapshots(tempDir)).toHaveLength(1);
246+
247+
await runSnapshotDelete('doomed');
248+
249+
expect(await listSnapshots(tempDir)).toHaveLength(0);
250+
expect(logger.success).toHaveBeenCalledWith(expect.stringContaining('doomed'));
251+
});
205252
});
206253
});

tests/commands/validate.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as path from 'path';
2+
import * as fs from 'fs-extra';
3+
import { runValidate } from '../../src/commands/validate';
4+
import { logger } from '../../src/utils/logger';
5+
6+
describe('validate command', () => {
7+
let tempDir: string;
8+
const originalCwd = process.cwd();
9+
10+
beforeEach(async () => {
11+
jest.spyOn(logger, 'warn').mockImplementation();
12+
jest.spyOn(logger, 'info').mockImplementation();
13+
jest.spyOn(logger, 'success').mockImplementation();
14+
jest.spyOn(logger, 'debug').mockImplementation();
15+
tempDir = path.join(require('os').tmpdir(), `devkit-validate-${Date.now()}-${Math.floor(performance.now())}`);
16+
await fs.ensureDir(tempDir);
17+
});
18+
19+
afterEach(async () => {
20+
process.chdir(originalCwd);
21+
await fs.remove(tempDir).catch(() => {});
22+
jest.restoreAllMocks();
23+
});
24+
25+
it('throws when .dev-env.yml is not found', async () => {
26+
process.chdir(tempDir);
27+
await expect(runValidate()).rejects.toThrow(/Configuration file not found|\.dev-env\.yml/);
28+
});
29+
30+
it('succeeds on a valid config', async () => {
31+
await fs.writeFile(path.join(tempDir, '.dev-env.yml'), 'name: ok\nversion: "1.0.0"\ndatabases: []');
32+
process.chdir(tempDir);
33+
34+
await expect(runValidate()).resolves.toBeUndefined();
35+
expect(logger.success).toHaveBeenCalledWith(expect.stringContaining('is valid'));
36+
});
37+
38+
it('throws on an invalid config (missing required name)', async () => {
39+
await fs.writeFile(path.join(tempDir, '.dev-env.yml'), 'version: "1.0.0"');
40+
process.chdir(tempDir);
41+
42+
await expect(runValidate()).rejects.toThrow(/Invalid configuration|name/);
43+
});
44+
45+
it('warns on unresolved ${VAR} references but succeeds without --strict', async () => {
46+
await fs.writeFile(
47+
path.join(tempDir, '.dev-env.yml'),
48+
'name: app\nenv:\n TOKEN: ${MISSING_TOKEN}'
49+
);
50+
process.chdir(tempDir);
51+
52+
await expect(runValidate()).resolves.toBeUndefined();
53+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('MISSING_TOKEN'));
54+
});
55+
56+
it('fails with --strict when there are unresolved variables', async () => {
57+
await fs.writeFile(
58+
path.join(tempDir, '.dev-env.yml'),
59+
'name: app\nenv:\n TOKEN: ${MISSING_TOKEN}'
60+
);
61+
process.chdir(tempDir);
62+
63+
await expect(runValidate({ strict: true })).rejects.toThrow(/--strict|unresolved/);
64+
});
65+
66+
it('resolves a variable defined in the config env block', async () => {
67+
await fs.writeFile(
68+
path.join(tempDir, '.dev-env.yml'),
69+
'name: app\nenv:\n BASE: hello\n DERIVED: ${BASE}-world'
70+
);
71+
process.chdir(tempDir);
72+
73+
await expect(runValidate({ strict: true })).resolves.toBeUndefined();
74+
});
75+
});

tests/core/config/validator.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,27 @@ describe('validator', () => {
9191
);
9292
});
9393

94+
it('does not warn for benign keys containing the word "password" (e.g. env URLs)', () => {
95+
const config = {
96+
name: 'app',
97+
databases: [{ type: 'redis' as const, port: 6379, host: 'localhost' }],
98+
env: { PASSWORD_RESET_URL: 'https://example.com/reset' },
99+
};
100+
checkConfigWarnings(config as unknown as DevEnvConfig);
101+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Passwords detected'));
102+
});
103+
104+
it('returns the list of warnings it emits', () => {
105+
const config = {
106+
name: 'app',
107+
databases: [
108+
{ type: 'postgresql' as const, port: 5432, host: 'localhost', user: 'u', password: 'plain', database: 'db' },
109+
],
110+
};
111+
const warnings = checkConfigWarnings(config as DevEnvConfig);
112+
expect(warnings.some((w) => w.includes('Passwords detected'))).toBe(true);
113+
});
114+
94115
it('warns when postgresql missing user or password', () => {
95116
const config = {
96117
name: 'app',

tests/core/docker/compose-generator.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as path from 'path';
22
import { generateComposeContent } from '../../../src/core/docker/compose-generator';
33
import type { DevEnvConfig } from '../../../src/types/config';
4+
import { logger } from '../../../src/utils/logger';
45

56
const templatesDir = path.resolve(__dirname, '../../../templates');
67

@@ -63,6 +64,37 @@ describe('compose-generator', () => {
6364
expect(content).toContain('services:');
6465
});
6566

67+
it('warns when a database has no Docker image (sqlite)', () => {
68+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation();
69+
try {
70+
const config = {
71+
name: 'app',
72+
databases: [{ type: 'sqlite' as const, name: 'localdb', host: 'localhost' }],
73+
docker: {},
74+
} as DevEnvConfig;
75+
generateComposeContent(config, templatesDir);
76+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('localdb'));
77+
} finally {
78+
warnSpy.mockRestore();
79+
}
80+
});
81+
82+
it('adds a healthcheck to a generic service when a matching health_check provides a test', () => {
83+
const config = {
84+
name: 'app',
85+
services: [{ type: 'rabbitmq', port: 5672, host: 'localhost' }],
86+
health_checks: [
87+
{ name: 'rabbitmq_1', type: 'rabbitmq', test: 'rabbitmq-diagnostics ping', interval: '30s', retries: 3 },
88+
],
89+
docker: {},
90+
} as DevEnvConfig;
91+
const content = generateComposeContent(config, templatesDir);
92+
expect(content).toContain('healthcheck:');
93+
expect(content).toContain('rabbitmq-diagnostics ping');
94+
expect(content).toContain('interval: 30s');
95+
expect(content).toContain('retries: 3');
96+
});
97+
6698
it('includes custom service with port and management_port', () => {
6799
const config = {
68100
name: 'app',

0 commit comments

Comments
 (0)