Skip to content

Commit 46c0a5d

Browse files
committed
fix(update): make all downloads non-fatal and add CLI integration tests (1.1.5)
1 parent 92c4aea commit 46c0a5d

6 files changed

Lines changed: 237 additions & 196 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## [1.1.5] - 2026-06-29
4+
5+
### Fixed
6+
- **`ck update` resilience (complete)** — ALL file downloads (commands, hooks, standards, types, scripts) are now non-fatal. Any single file failure logs a `⚠ Skipped` warning and the update continues. Previously only types and legacy scripts were graceful; a transient 400 on any command file still aborted the entire update and rolled back the backup.
7+
- **`ck update` skipped-file reporting** — spinner now shows `Files updated (N skipped — retry if issues persist)` when downloads were skipped, making partial updates visible.
8+
9+
### Tests
10+
- **`update` unit tests** — added tests 20–21 verifying that a 400 on a command file download is non-fatal and logs a warning instead of aborting.
11+
- **CLI integration tests** — expanded `cli.test.js` from 9 to 18 numbered tests; now covers `ck update`, `ck check`, `ck analyze`, `ck note`, `ck run` at the CLI level, and verifies `ck update` exits cleanly when ContextKit is not installed.
12+
- **`download-manifest` test** — updated URL regex to match refactored `${base}` variable alongside original `${this.repoUrl}`.
13+
314
## [1.1.4] - 2026-06-29
415

516
### Fixed

__tests__/commands/update.test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,62 @@ describe('UpdateCommand', () => {
326326
expect(logged).toContain('preserved');
327327
});
328328

329+
it('20. continues update when a command file download fails with 400', async () => {
330+
const DownloadManager = require('../../lib/utils/download');
331+
const realFs = require('fs-extra');
332+
333+
DownloadManager.mockImplementationOnce(() => ({
334+
downloadFile: jest.fn().mockImplementation(async (url, dest) => {
335+
if (url.includes('refactor.md')) {
336+
throw new Error('Request failed with status code 400');
337+
}
338+
await realFs.ensureDir(path.dirname(dest));
339+
await realFs.writeFile(dest, '# mocked download\n');
340+
}),
341+
}));
342+
343+
await fs.ensureDir('.contextkit');
344+
await fs.writeFile('.contextkit/config.yml', baseConfig);
345+
346+
const update = getUpdateModule();
347+
await expect(update({ force: true })).resolves.not.toThrow();
348+
349+
const logged = console.log.mock.calls.flat().join(' ');
350+
expect(logged).toContain('Skipped');
351+
expect(logged).toContain('refactor.md');
352+
});
353+
354+
it('21. reports skipped count in success message when files are skipped', async () => {
355+
const DownloadManager = require('../../lib/utils/download');
356+
const realFs = require('fs-extra');
357+
const ora = require('ora');
358+
359+
let succeedMessage = '';
360+
jest.spyOn(ora(), 'succeed').mockImplementation((msg) => {
361+
succeedMessage = msg || '';
362+
});
363+
364+
DownloadManager.mockImplementationOnce(() => ({
365+
downloadFile: jest.fn().mockImplementation(async (url, dest) => {
366+
if (url.includes('typescript-strict.json')) {
367+
throw new Error('Request failed with status code 400');
368+
}
369+
await realFs.ensureDir(path.dirname(dest));
370+
await realFs.writeFile(dest, '# mocked download\n');
371+
}),
372+
}));
373+
374+
await fs.ensureDir('.contextkit');
375+
await fs.writeFile('.contextkit/config.yml', baseConfig);
376+
377+
const update = getUpdateModule();
378+
await expect(update({ force: true })).resolves.not.toThrow();
379+
380+
const logged = console.log.mock.calls.flat().join(' ');
381+
expect(logged).toContain('Skipped');
382+
expect(logged).toContain('typescript-strict.json');
383+
});
384+
329385
it('11. version comparison works correctly', async () => {
330386
// Access the class to test isNewerVersion
331387
delete require.cache[require.resolve('../../lib/commands/update')];

__tests__/integration/cli.test.js

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,48 @@
11
const { execSync } = require('child_process');
22
const path = require('path');
3+
const fs = require('fs-extra');
4+
const os = require('os');
35
const packageJson = require('../../package.json');
46

57
describe('CLI Integration Tests', () => {
68
const cliPath = path.join(__dirname, '../../bin/contextkit.js');
9+
let tmpDir;
10+
11+
beforeEach(async () => {
12+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ck-cli-'));
13+
});
14+
15+
afterEach(async () => {
16+
await fs.remove(tmpDir);
17+
});
718

819
describe('version command', () => {
9-
test('should show version', () => {
20+
test('1. --version shows current package version', () => {
1021
const result = execSync(`node "${cliPath}" --version`, { encoding: 'utf8' });
1122
expect(result.trim()).toBe(packageJson.version);
1223
});
1324

14-
test('should show version with -v flag', () => {
25+
test('2. -v flag shows current package version', () => {
1526
const result = execSync(`node "${cliPath}" -v`, { encoding: 'utf8' });
1627
expect(result.trim()).toBe(packageJson.version);
1728
});
1829
});
1930

2031
describe('help command', () => {
21-
test('should show help', () => {
32+
test('3. --help shows all core commands', () => {
2233
const result = execSync(`node "${cliPath}" --help`, { encoding: 'utf8' });
2334
expect(result).toContain('Context Engineering for AI Development');
2435
expect(result).toContain('Commands:');
2536
expect(result).toContain('install');
2637
expect(result).toContain('status');
2738
expect(result).toContain('update');
39+
expect(result).toContain('analyze');
40+
expect(result).toContain('check');
41+
expect(result).toContain('note');
42+
expect(result).toContain('run');
2843
});
2944

30-
test('should show platform commands in help', () => {
45+
test('4. --help shows all platform commands', () => {
3146
const result = execSync(`node "${cliPath}" --help`, { encoding: 'utf8' });
3247
expect(result).toContain('windsurf');
3348
expect(result).toContain('codex');
@@ -38,26 +53,94 @@ describe('CLI Integration Tests', () => {
3853
expect(result).toContain('aider');
3954
});
4055

41-
test('should show help for install command', () => {
56+
test('5. install --help shows install options', () => {
4257
const result = execSync(`node "${cliPath}" install --help`, { encoding: 'utf8' });
4358
expect(result).toContain('Initialize ContextKit in the current project directory');
4459
expect(result).toContain('--no-hooks');
4560
expect(result).toContain('--non-interactive');
4661
});
62+
63+
test('6. update --help shows update description and --force option', () => {
64+
const result = execSync(`node "${cliPath}" update --help`, { encoding: 'utf8' });
65+
expect(result).toContain('Update to latest version');
66+
expect(result).toContain('--force');
67+
});
68+
69+
test('7. analyze --help shows analyze description and options', () => {
70+
const result = execSync(`node "${cliPath}" analyze --help`, { encoding: 'utf8' });
71+
expect(result).toContain('Analyze project');
72+
expect(result).toContain('--ai');
73+
expect(result).toContain('--scope');
74+
});
75+
76+
test('8. check --help shows check description and options', () => {
77+
const result = execSync(`node "${cliPath}" check --help`, { encoding: 'utf8' });
78+
expect(result).toContain('Validate ContextKit installation');
79+
expect(result).toContain('--strict');
80+
expect(result).toContain('--verbose');
81+
});
82+
83+
test('9. note --help shows note description and options', () => {
84+
const result = execSync(`node "${cliPath}" note --help`, { encoding: 'utf8' });
85+
expect(result).toContain('Add a note');
86+
expect(result).toContain('--priority');
87+
expect(result).toContain('--category');
88+
});
89+
90+
test('10. run --help shows run description and options', () => {
91+
const result = execSync(`node "${cliPath}" run --help`, { encoding: 'utf8' });
92+
expect(result).toContain('Run a workflow');
93+
expect(result).toContain('--interactive');
94+
});
4795
});
4896

4997
describe('status command', () => {
50-
test('should run status command', () => {
98+
test('11. status runs without crashing (installed or not)', () => {
5199
const result = execSync(`node "${cliPath}" status`, { encoding: 'utf8' });
52-
// CI has no .contextkit/config.yml; local dev has full install
53100
const notInstalled = result.includes('ContextKit is not installed');
54101
const installed = result.includes('ContextKit Status') && result.includes('Installation:');
55102
expect(notInstalled || installed).toBe(true);
56103
});
104+
105+
test('12. status reports not installed when run from empty directory', () => {
106+
const result = execSync(`node "${cliPath}" status`, {
107+
encoding: 'utf8',
108+
cwd: tmpDir,
109+
});
110+
expect(result).toContain('not installed');
111+
});
112+
});
113+
114+
describe('update command', () => {
115+
test('13. update exits cleanly with no .contextkit (not installed)', () => {
116+
const result = execSync(`node "${cliPath}" update`, {
117+
encoding: 'utf8',
118+
cwd: tmpDir,
119+
});
120+
expect(result).toContain('No ContextKit installation found');
121+
});
122+
123+
test('14. update suggests install command when not installed', () => {
124+
const result = execSync(`node "${cliPath}" update`, {
125+
encoding: 'utf8',
126+
cwd: tmpDir,
127+
});
128+
expect(result).toContain('contextkit install');
129+
});
130+
});
131+
132+
describe('check command', () => {
133+
test('15. check exits cleanly with no .contextkit (not installed)', () => {
134+
const result = execSync(`node "${cliPath}" check`, {
135+
encoding: 'utf8',
136+
cwd: tmpDir,
137+
});
138+
expect(result).toMatch(/not installed|No ContextKit/i);
139+
});
57140
});
58141

59142
describe('error handling', () => {
60-
test('should print error and exit non-zero for unknown commands', () => {
143+
test('16. unknown command prints error and exits non-zero', () => {
61144
let threw = false;
62145
try {
63146
execSync(`node "${cliPath}" unknown-command`, { encoding: 'utf8' });
@@ -73,16 +156,15 @@ describe('CLI Integration Tests', () => {
73156
});
74157

75158
describe('update-notifier', () => {
76-
test('CLI exits with code 0 and notifier does not break output', () => {
77-
// update-notifier is suppressed in CI (CI=true env var); verify the CLI still runs cleanly
159+
test('17. CLI exits with code 0 and notifier does not break output', () => {
78160
const result = execSync(`node "${cliPath}" --version`, {
79161
encoding: 'utf8',
80162
env: { ...process.env, CI: 'true' },
81163
});
82164
expect(result.trim()).toBe(packageJson.version);
83165
});
84166

85-
test('CLI --help output is unaffected by notifier', () => {
167+
test('18. --help output is unaffected by notifier', () => {
86168
const result = execSync(`node "${cliPath}" --help`, {
87169
encoding: 'utf8',
88170
env: { ...process.env, CI: 'true' },

__tests__/integration/download-manifest.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ const ROOT = path.resolve(__dirname, '..', '..');
1111
function extractDownloadPaths(sourceFile) {
1212
const content = fs.readFileSync(sourceFile, 'utf8');
1313
const paths = [];
14-
// Match: `${this.repoUrl}/some/path`
15-
const regex = /\$\{this\.repoUrl\}\/([\w./-]+)/g;
14+
// Match: `${this.repoUrl}/some/path` or `${base}/some/path`
15+
const regex = /\$\{(?:this\.repoUrl|base)\}\/([\w./-]+)/g;
1616
let match;
1717
while ((match = regex.exec(content)) !== null) {
1818
paths.push(match[1]);

0 commit comments

Comments
 (0)