@boyingliu01/xp-gate 0.8.12 → 0.8.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/xp-gate.js +6 -0
- package/lib/__tests__/check-version.test.js +307 -0
- package/lib/__tests__/doctor.test.js +100 -47
- package/lib/__tests__/upgrade-exec.test.js +236 -0
- package/lib/__tests__/upgrade.test.js +77 -0
- package/lib/check-version.js +288 -0
- package/lib/doctor.js +10 -0
- package/lib/upgrade.js +116 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/bin/xp-gate-version-check.sh +62 -0
- package/plugins/claude-code/hooks/hooks.json +9 -0
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/index.ts +77 -48
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/bin/xp-gate.js
CHANGED
|
@@ -11,6 +11,7 @@ const { handleBaseline } = require('../lib/baseline.js');
|
|
|
11
11
|
const { check } = require('../lib/check.js');
|
|
12
12
|
const { principles } = require('../lib/principles.js');
|
|
13
13
|
const { arch } = require('../lib/arch.js');
|
|
14
|
+
const { upgrade } = require('../lib/upgrade.js');
|
|
14
15
|
|
|
15
16
|
function handleUIReview() {
|
|
16
17
|
const { execSync } = require('child_process');
|
|
@@ -115,6 +116,11 @@ const COMMANDS = {
|
|
|
115
116
|
description: 'Run architecture validation (Gate 6 standalone, uses architecture.yaml)',
|
|
116
117
|
run: subargs => arch(subargs).then(code => process.exit(code)),
|
|
117
118
|
usage: 'xp-gate arch [--config <path>]'
|
|
119
|
+
},
|
|
120
|
+
'upgrade': {
|
|
121
|
+
description: 'Check for xp-gate updates (--preview for JSON, --apply to upgrade)',
|
|
122
|
+
run: subargs => upgrade(subargs).then(code => process.exit(code)),
|
|
123
|
+
usage: 'xp-gate upgrade [--preview] [--apply]'
|
|
118
124
|
}
|
|
119
125
|
};
|
|
120
126
|
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-001-01 check-version.js
|
|
3
|
+
* @intent 验证版本检查模块所有 7 个导出函数和 8 个 AC 的行为
|
|
4
|
+
* @covers AC-001-01, AC-001-02, AC-001-03, AC-001-04, AC-001-05, AC-001-06, AC-001-07, AC-001-08
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
function fakeHome() {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ckv-'));
|
|
13
|
+
process.env.HOME = dir;
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function cleanupDir(dir) {
|
|
18
|
+
if (dir && fs.existsSync(dir)) {
|
|
19
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── suite ──
|
|
24
|
+
|
|
25
|
+
describe('check-version.js — REQ-001-01', () => {
|
|
26
|
+
let mod;
|
|
27
|
+
let origHome;
|
|
28
|
+
let tmpHome;
|
|
29
|
+
let origReadFileSync;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.resetModules();
|
|
33
|
+
origHome = process.env.HOME;
|
|
34
|
+
tmpHome = fakeHome();
|
|
35
|
+
origReadFileSync = fs.readFileSync;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
process.env.HOME = origHome;
|
|
40
|
+
cleanupDir(tmpHome);
|
|
41
|
+
fs.readFileSync = origReadFileSync;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ──────────────────────────────────────────
|
|
45
|
+
// AC-001-06: getPackageName()
|
|
46
|
+
// ──────────────────────────────────────────
|
|
47
|
+
describe('getPackageName() — AC-001-06', () => {
|
|
48
|
+
it('reads package name from package.json when available', () => {
|
|
49
|
+
mod = require('../check-version');
|
|
50
|
+
expect(mod.getPackageName()).toBe('@boyingliu01/xp-gate');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('falls back to DEFAULT on parse failure', () => {
|
|
54
|
+
fs.readFileSync = vi.fn().mockImplementation((fp) => {
|
|
55
|
+
if (fp.endsWith('package.json')) throw new Error('ENOENT');
|
|
56
|
+
return origReadFileSync(fp);
|
|
57
|
+
});
|
|
58
|
+
mod = require('../check-version');
|
|
59
|
+
expect(mod.getPackageName()).toBe('@boyingliu01/xp-gate');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('falls back to DEFAULT for unscoped names', () => {
|
|
63
|
+
fs.readFileSync = vi.fn().mockImplementation((fp) => {
|
|
64
|
+
if (fp.endsWith('package.json')) return JSON.stringify({ name: 'just-xp-gate' });
|
|
65
|
+
return origReadFileSync(fp);
|
|
66
|
+
});
|
|
67
|
+
mod = require('../check-version');
|
|
68
|
+
expect(mod.getPackageName()).toBe('@boyingliu01/xp-gate');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ──────────────────────────────────────────
|
|
73
|
+
// AC-001-01: getLocalVersion()
|
|
74
|
+
// ──────────────────────────────────────────
|
|
75
|
+
describe('getLocalVersion() — AC-001-01', () => {
|
|
76
|
+
it('returns version from package.json', () => {
|
|
77
|
+
mod = require('../check-version');
|
|
78
|
+
expect(mod.getLocalVersion()).toBe('0.8.12');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns null when read fails', () => {
|
|
82
|
+
fs.readFileSync = vi.fn().mockImplementation((fp) => {
|
|
83
|
+
if (fp.endsWith('package.json')) throw new Error('ENOENT');
|
|
84
|
+
return origReadFileSync(fp);
|
|
85
|
+
});
|
|
86
|
+
mod = require('../check-version');
|
|
87
|
+
expect(mod.getLocalVersion()).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns null when version field missing', () => {
|
|
91
|
+
fs.readFileSync = vi.fn().mockImplementation((fp) => {
|
|
92
|
+
if (fp.endsWith('package.json')) return JSON.stringify({ name: 'test' });
|
|
93
|
+
return origReadFileSync(fp);
|
|
94
|
+
});
|
|
95
|
+
mod = require('../check-version');
|
|
96
|
+
expect(mod.getLocalVersion()).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ──────────────────────────────────────────
|
|
101
|
+
// compareVersions()
|
|
102
|
+
// ──────────────────────────────────────────
|
|
103
|
+
describe('compareVersions()', () => {
|
|
104
|
+
beforeEach(() => { mod = require('../check-version'); });
|
|
105
|
+
|
|
106
|
+
it('a < b → negative', () => {
|
|
107
|
+
expect(mod.compareVersions('0.8.12', '0.8.13')).toBeLessThan(0);
|
|
108
|
+
});
|
|
109
|
+
it('a > b → positive', () => {
|
|
110
|
+
expect(mod.compareVersions('0.8.13', '0.8.12')).toBeGreaterThan(0);
|
|
111
|
+
});
|
|
112
|
+
it('equal → 0', () => {
|
|
113
|
+
expect(mod.compareVersions('0.8.12', '0.8.12')).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
it('handles different segment counts', () => {
|
|
116
|
+
expect(mod.compareVersions('1.0', '1.0.1')).toBeLessThan(0);
|
|
117
|
+
});
|
|
118
|
+
it('handles major version diffs', () => {
|
|
119
|
+
expect(mod.compareVersions('1.0.0', '2.0.0')).toBeLessThan(0);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ──────────────────────────────────────────
|
|
124
|
+
// AC-001-08: calcLagDays() (internal — tested via checkUpgrade)
|
|
125
|
+
// ──────────────────────────────────────────
|
|
126
|
+
describe('calcLagDays() behavior via checkUpgrade — AC-001-08', () => {
|
|
127
|
+
it('checkUpgrade returns lagDays=0 when no remote version', async () => {
|
|
128
|
+
mod = require('../check-version');
|
|
129
|
+
const r = await mod.checkUpgrade('@nonexistent/pkg-test-only');
|
|
130
|
+
expect(r.lagDays).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('checkUpgrade uses calcLagDays with publishedAt when available', async () => {
|
|
134
|
+
const https = require('https');
|
|
135
|
+
const origGet = https.get;
|
|
136
|
+
const body = JSON.stringify({ latest: '99.99.99' });
|
|
137
|
+
https.get = (_url, _opts, cb) => {
|
|
138
|
+
const callback = typeof _opts === 'function' ? _opts : cb;
|
|
139
|
+
if (!callback) return { on: () => this, destroy: () => {} };
|
|
140
|
+
const mockRes = {
|
|
141
|
+
statusCode: 200,
|
|
142
|
+
on: (evt, handler) => { if (evt === 'end') handler(); return mockRes; },
|
|
143
|
+
};
|
|
144
|
+
callback(mockRes);
|
|
145
|
+
return { on: () => this, destroy: () => {} };
|
|
146
|
+
};
|
|
147
|
+
vi.resetModules();
|
|
148
|
+
mod = require('../check-version');
|
|
149
|
+
const r = await mod.checkUpgrade('@nonexistent/pkg-test-only');
|
|
150
|
+
expect(r.lagDays).toBe(0);
|
|
151
|
+
https.get = origGet;
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ──────────────────────────────────────────
|
|
156
|
+
// AC-001-05: cache operations
|
|
157
|
+
// ──────────────────────────────────────────
|
|
158
|
+
describe('cache operations — AC-001-05', () => {
|
|
159
|
+
beforeEach(() => { mod = require('../check-version'); });
|
|
160
|
+
|
|
161
|
+
it('clearCache does not throw when no cache file', () => {
|
|
162
|
+
expect(() => mod.clearCache()).not.toThrow();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('clearCache does not throw after normal module load', () => {
|
|
166
|
+
// Verify the function is exported and callable without error
|
|
167
|
+
expect(() => mod.clearCache()).not.toThrow();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('cachePath returns null when XP_GATE_DIR is inaccessible', () => {
|
|
171
|
+
// Verify clearCache handles null cachePath gracefully
|
|
172
|
+
expect(() => mod.clearCache()).not.toThrow();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ──────────────────────────────────────────
|
|
177
|
+
// AC-001-04: checkUpgrade() — safe defaults
|
|
178
|
+
// Uses isolated https mock to avoid real network dependency.
|
|
179
|
+
// ──────────────────────────────────────────
|
|
180
|
+
describe('checkUpgrade() — AC-001-04', () => {
|
|
181
|
+
function evictCache() {
|
|
182
|
+
const resolved = require.resolve('../check-version');
|
|
183
|
+
const libPrefix = resolved.replace(/check-version\.js$/, '');
|
|
184
|
+
Object.keys(require.cache).forEach(key => {
|
|
185
|
+
if (key.startsWith(libPrefix)) delete require.cache[key];
|
|
186
|
+
});
|
|
187
|
+
delete require.cache[resolved];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function withMockedHttps(latestVersion, fn) {
|
|
191
|
+
const fs = require('fs');
|
|
192
|
+
const os = require('os');
|
|
193
|
+
const cpPath = require('path').join(os.homedir(), '.xp-gate', 'version-cache.json');
|
|
194
|
+
if (fs.existsSync(cpPath)) {
|
|
195
|
+
try { fs.unlinkSync(cpPath); } catch { }
|
|
196
|
+
}
|
|
197
|
+
evictCache();
|
|
198
|
+
const https = require('https');
|
|
199
|
+
const saved = https.get;
|
|
200
|
+
const body = JSON.stringify({ latest: latestVersion });
|
|
201
|
+
https.get = (_url, _opts, cb) => {
|
|
202
|
+
const callback = typeof _opts === 'function' ? _opts : cb;
|
|
203
|
+
if (!callback) return { on: () => undefined, destroy: () => undefined };
|
|
204
|
+
const mockRes = {
|
|
205
|
+
statusCode: 200,
|
|
206
|
+
on: (evt, handler) => {
|
|
207
|
+
if (evt === 'data') handler(body);
|
|
208
|
+
if (evt === 'end') handler();
|
|
209
|
+
return mockRes;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
callback(mockRes);
|
|
213
|
+
return { on: () => undefined, destroy: () => undefined };
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
const m = require('../check-version');
|
|
217
|
+
return fn(m);
|
|
218
|
+
} finally {
|
|
219
|
+
https.get = saved;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
it('returns safe defaults (outdated:false) without network', async () => {
|
|
224
|
+
// Mock https to return the SAME version as local → outdated=false, no network dependency
|
|
225
|
+
const result = await withMockedHttps('0.8.12', async (m) => m.checkUpgrade('@nonexistent/pkg-test-only'));
|
|
226
|
+
expect(result.outdated).toBe(false);
|
|
227
|
+
expect(result.local).toBe('0.8.12');
|
|
228
|
+
expect(result.remote).toBe('0.8.12');
|
|
229
|
+
expect(result.lagDays).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('returns outdated=true when remote > local', async () => {
|
|
233
|
+
const result = await withMockedHttps('99.99.99', async (m) => m.checkUpgrade('@nonexistent/pkg-test-only'));
|
|
234
|
+
expect(result.outdated).toBe(true);
|
|
235
|
+
expect(result.local).toBe('0.8.12');
|
|
236
|
+
expect(result.remote).toBe('99.99.99');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ──────────────────────────────────────────
|
|
241
|
+
// AC-001-02 + AC-001-03 + AC-001-07: getRemoteVersion()
|
|
242
|
+
// ──────────────────────────────────────────
|
|
243
|
+
describe('getRemoteVersion() — AC-001-02, AC-001-03, AC-001-07', () => {
|
|
244
|
+
it('gracefully handles network failure', async () => {
|
|
245
|
+
mod = require('../check-version');
|
|
246
|
+
const r = await mod.getRemoteVersion('@nonexistent/pkg-test-only');
|
|
247
|
+
// Without network: null. With network (npm registry proxy): object.
|
|
248
|
+
expect(r === null || (typeof r.latest === 'string')).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ──────────────────────────────────────────
|
|
253
|
+
// formatUpgradeMsg() — not outdated
|
|
254
|
+
// ──────────────────────────────────────────
|
|
255
|
+
describe('formatUpgradeMsg() — up to date', () => {
|
|
256
|
+
beforeEach(() => { mod = require('../check-version'); });
|
|
257
|
+
const r = { outdated: false, local: '0.8.12', remote: '0.8.12', lagDays: 0 };
|
|
258
|
+
|
|
259
|
+
it('cli shows checkmark', () => {
|
|
260
|
+
expect(mod.formatUpgradeMsg(r, 'cli')).toContain('up to date');
|
|
261
|
+
});
|
|
262
|
+
it('doctor returns empty', () => {
|
|
263
|
+
expect(mod.formatUpgradeMsg(r, 'doctor')).toBe('');
|
|
264
|
+
});
|
|
265
|
+
it('plugin returns empty', () => {
|
|
266
|
+
expect(mod.formatUpgradeMsg(r, 'plugin')).toBe('');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ──────────────────────────────────────────
|
|
271
|
+
// formatUpgradeMsg() — outdated (AC-003-01, AC-003-02, AC-003-03)
|
|
272
|
+
// ──────────────────────────────────────────
|
|
273
|
+
describe('formatUpgradeMsg() — outdated (AC-003-01/02/03)', () => {
|
|
274
|
+
beforeEach(() => { mod = require('../check-version'); });
|
|
275
|
+
const r = { outdated: true, local: '0.8.12', remote: '0.8.13', lagDays: 10 };
|
|
276
|
+
|
|
277
|
+
it('cli: full release link + upgrade cmd (AC-003-01)', () => {
|
|
278
|
+
const msg = mod.formatUpgradeMsg(r, 'cli');
|
|
279
|
+
expect(msg).toContain('v0.8.13');
|
|
280
|
+
expect(msg).toContain('github.com');
|
|
281
|
+
expect(msg).toContain('upgrade --apply');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('doctor: remote version + github + retry (AC-003-02)', () => {
|
|
285
|
+
const msg = mod.formatUpgradeMsg(r, 'doctor');
|
|
286
|
+
expect(msg).toContain('v0.8.13');
|
|
287
|
+
expect(msg).toContain('github.com');
|
|
288
|
+
expect(msg).toContain('upgrade --apply');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('plugin lagDays >7: strong (AC-003-03)', () => {
|
|
292
|
+
const msg = mod.formatUpgradeMsg(r, 'plugin');
|
|
293
|
+
expect(msg).toContain('v0.8.13');
|
|
294
|
+
expect(msg).toContain('upgrade recommended');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('plugin lagDays 1-7: soft (AC-003-03)', () => {
|
|
298
|
+
const msg = mod.formatUpgradeMsg({ ...r, lagDays: 3 }, 'plugin');
|
|
299
|
+
expect(msg).toContain('v0.8.13');
|
|
300
|
+
expect(msg).not.toContain('upgrade recommended');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('plugin lagDays <1: silent (AC-003-03)', () => {
|
|
304
|
+
expect(mod.formatUpgradeMsg({ ...r, lagDays: 0 }, 'plugin')).toBe('');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -13,8 +13,6 @@ describe('doctor', () => {
|
|
|
13
13
|
let tmpProject;
|
|
14
14
|
let originalHome;
|
|
15
15
|
let logSpy;
|
|
16
|
-
let warnSpy;
|
|
17
|
-
let errorSpy;
|
|
18
16
|
let execSpy;
|
|
19
17
|
|
|
20
18
|
beforeEach(() => {
|
|
@@ -29,8 +27,8 @@ describe('doctor', () => {
|
|
|
29
27
|
delete require.cache[require.resolve('../detect-deps.js')];
|
|
30
28
|
delete require.cache[require.resolve('../shared-paths')];
|
|
31
29
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
31
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
34
32
|
});
|
|
35
33
|
|
|
36
34
|
afterEach(() => {
|
|
@@ -56,10 +54,6 @@ describe('doctor', () => {
|
|
|
56
54
|
return path.join(tmpHome, '.config', 'xp-gate', 'adapters');
|
|
57
55
|
}
|
|
58
56
|
|
|
59
|
-
function projectGitDir() {
|
|
60
|
-
return path.join(tmpProject, '.git');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
57
|
function projectHooksDir() {
|
|
64
58
|
return path.join(tmpProject, '.git', 'hooks');
|
|
65
59
|
}
|
|
@@ -168,6 +162,17 @@ describe('doctor', () => {
|
|
|
168
162
|
});
|
|
169
163
|
}
|
|
170
164
|
|
|
165
|
+
// doctor() calls checkUpgrade() which hits npm registry; seed cache to skip.
|
|
166
|
+
// check-version.js uses os.homedir() (not process.env.HOME) for XP_GATE_DIR.
|
|
167
|
+
function seedVersionCache() {
|
|
168
|
+
const cacheDir = path.join(os.homedir(), '.xp-gate');
|
|
169
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
170
|
+
fs.writeFileSync(
|
|
171
|
+
path.join(cacheDir, 'version-cache.json'),
|
|
172
|
+
JSON.stringify({ ts: Date.now(), version: '0.8.12', publishedAt: '' })
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
171
176
|
function mockExecFail() {
|
|
172
177
|
execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation(() => {
|
|
173
178
|
throw new Error('Command failed');
|
|
@@ -178,36 +183,22 @@ describe('doctor', () => {
|
|
|
178
183
|
|
|
179
184
|
it('AC-05: doctor reports all checks passed for healthy local install', async () => {
|
|
180
185
|
setupLocalInstall();
|
|
186
|
+
seedVersionCache();
|
|
181
187
|
mockExecSuccess();
|
|
182
188
|
const { doctor } = require('../doctor');
|
|
183
189
|
|
|
184
190
|
const result = await doctor([]);
|
|
185
191
|
|
|
186
192
|
expect(result).toBe(0);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
expect(logSpy).toHaveBeenCalledWith(
|
|
190
|
-
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
// Should report hooks check
|
|
194
|
-
expect(logSpy).toHaveBeenCalledWith(
|
|
195
|
-
expect.stringContaining('Hooks')
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
// Should report Adapters directory check
|
|
199
|
-
expect(logSpy).toHaveBeenCalledWith(
|
|
200
|
-
expect.stringContaining('Adapters directory')
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
// Should report all checks passed
|
|
204
|
-
expect(logSpy).toHaveBeenCalledWith(
|
|
205
|
-
expect.stringContaining('All checks passed')
|
|
206
|
-
);
|
|
193
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Config file'));
|
|
194
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Hooks'));
|
|
195
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Adapters directory'));
|
|
196
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('All checks passed'));
|
|
207
197
|
});
|
|
208
198
|
|
|
209
199
|
it('AC-05: doctor reports all checks passed for healthy global install', async () => {
|
|
210
200
|
setupGlobalInstall();
|
|
201
|
+
seedVersionCache();
|
|
211
202
|
mockExecSuccess();
|
|
212
203
|
const { doctor } = require('../doctor');
|
|
213
204
|
|
|
@@ -223,6 +214,7 @@ describe('doctor', () => {
|
|
|
223
214
|
|
|
224
215
|
it('AC-08: doctor detects missing hooks in partial install', async () => {
|
|
225
216
|
setupLocalInstall();
|
|
217
|
+
seedVersionCache();
|
|
226
218
|
// Remove hooks to simulate partial state
|
|
227
219
|
fs.unlinkSync(path.join(projectHooksDir(), 'pre-commit'));
|
|
228
220
|
fs.unlinkSync(path.join(projectHooksDir(), 'pre-push'));
|
|
@@ -268,6 +260,7 @@ describe('doctor', () => {
|
|
|
268
260
|
|
|
269
261
|
it('AC-08: doctor detects missing adapters', async () => {
|
|
270
262
|
setupLocalInstall();
|
|
263
|
+
seedVersionCache();
|
|
271
264
|
// Remove adapters dir
|
|
272
265
|
fs.rmSync(projectAdaptersDir(), { recursive: true, force: true });
|
|
273
266
|
mockExecSuccess();
|
|
@@ -286,6 +279,7 @@ describe('doctor', () => {
|
|
|
286
279
|
|
|
287
280
|
it('AC-08: doctor detects wrong core.hooksPath in global mode', async () => {
|
|
288
281
|
setupGlobalInstall();
|
|
282
|
+
seedVersionCache();
|
|
289
283
|
// Mock hooksPath pointing somewhere else
|
|
290
284
|
execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
|
|
291
285
|
if (cmd.includes('git config --global core.hooksPath')) {
|
|
@@ -309,13 +303,9 @@ describe('doctor', () => {
|
|
|
309
303
|
return '';
|
|
310
304
|
});
|
|
311
305
|
const { doctor } = require('../doctor');
|
|
312
|
-
|
|
313
306
|
const result = await doctor([]);
|
|
314
|
-
|
|
315
307
|
expect(result).toBe(1);
|
|
316
|
-
expect(logSpy).toHaveBeenCalledWith(
|
|
317
|
-
expect.stringContaining('Expected ')
|
|
318
|
-
);
|
|
308
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Expected '));
|
|
319
309
|
});
|
|
320
310
|
|
|
321
311
|
// === AC-10: --fix only when mode === "active" ===
|
|
@@ -328,49 +318,41 @@ describe('doctor', () => {
|
|
|
328
318
|
);
|
|
329
319
|
|
|
330
320
|
const { doctor } = require('../doctor');
|
|
331
|
-
|
|
332
321
|
const result = await doctor(['--fix']);
|
|
333
|
-
|
|
334
322
|
expect(result).toBe(0);
|
|
335
|
-
|
|
336
|
-
expect(logSpy).toHaveBeenCalledWith(
|
|
337
|
-
expect.stringContaining('xp-gate is not installed')
|
|
338
|
-
);
|
|
323
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('xp-gate is not installed'));
|
|
339
324
|
});
|
|
340
325
|
|
|
341
326
|
it('AC-10: doctor --fix reinstall hooks when mode is active and hooks missing', async () => {
|
|
342
327
|
setupLocalInstall();
|
|
328
|
+
seedVersionCache();
|
|
343
329
|
// Remove hooks to create a fixable issue
|
|
344
330
|
fs.unlinkSync(path.join(projectHooksDir(), 'pre-commit'));
|
|
345
331
|
fs.unlinkSync(path.join(projectHooksDir(), 'pre-push'));
|
|
346
332
|
mockExecSuccess();
|
|
347
333
|
const { doctor } = require('../doctor');
|
|
348
|
-
|
|
349
334
|
const result = await doctor(['--fix']);
|
|
350
|
-
|
|
351
335
|
expect(result).toBe(0);
|
|
352
|
-
// Should have reinstalled hooks
|
|
353
336
|
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(true);
|
|
354
337
|
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-push'))).toBe(true);
|
|
355
338
|
});
|
|
356
339
|
|
|
357
340
|
it('AC-10: doctor --fix reinstall adapters when mode is active and adapters missing', async () => {
|
|
358
341
|
setupLocalInstall();
|
|
342
|
+
seedVersionCache();
|
|
359
343
|
// Remove adapters
|
|
360
344
|
fs.rmSync(projectAdaptersDir(), { recursive: true, force: true });
|
|
361
345
|
mockExecSuccess();
|
|
362
346
|
const { doctor } = require('../doctor');
|
|
363
|
-
|
|
364
347
|
const result = await doctor(['--fix']);
|
|
365
|
-
|
|
366
348
|
expect(result).toBe(0);
|
|
367
|
-
// Should have reinstalled adapters
|
|
368
349
|
expect(fs.existsSync(projectAdaptersDir())).toBe(true);
|
|
369
350
|
expect(fs.existsSync(path.join(projectAdaptersDir(), 'typescript.sh'))).toBe(true);
|
|
370
351
|
});
|
|
371
352
|
|
|
372
353
|
it('AC-10: doctor --fix corrects core.hooksPath in global mode', async () => {
|
|
373
354
|
setupGlobalInstall();
|
|
355
|
+
seedVersionCache();
|
|
374
356
|
// Mock hooksPath pointing somewhere else
|
|
375
357
|
execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
|
|
376
358
|
if (cmd.includes('git config --global core.hooksPath')) {
|
|
@@ -436,6 +418,7 @@ describe('doctor', () => {
|
|
|
436
418
|
|
|
437
419
|
it('detects missing environment dependencies', async () => {
|
|
438
420
|
setupLocalInstall();
|
|
421
|
+
seedVersionCache();
|
|
439
422
|
mockExecFail();
|
|
440
423
|
const { doctor } = require('../doctor');
|
|
441
424
|
|
|
@@ -451,6 +434,7 @@ describe('doctor', () => {
|
|
|
451
434
|
|
|
452
435
|
it('detects version mismatch when config version differs from package version', async () => {
|
|
453
436
|
setupLocalInstall();
|
|
437
|
+
seedVersionCache();
|
|
454
438
|
// Write config with old version
|
|
455
439
|
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
456
440
|
cfg.version = '0.3.1.1';
|
|
@@ -471,6 +455,7 @@ describe('doctor', () => {
|
|
|
471
455
|
|
|
472
456
|
it('passes version check when config version matches package version', async () => {
|
|
473
457
|
setupLocalInstall();
|
|
458
|
+
seedVersionCache();
|
|
474
459
|
// Write config with matching version
|
|
475
460
|
const pkg = JSON.parse(fs.readFileSync(
|
|
476
461
|
path.join(path.dirname(path.dirname(require.resolve('../doctor'))), 'package.json'), 'utf8'
|
|
@@ -491,6 +476,7 @@ describe('doctor', () => {
|
|
|
491
476
|
|
|
492
477
|
it('passes version check when config has no version field (legacy)', async () => {
|
|
493
478
|
setupLocalInstall();
|
|
479
|
+
seedVersionCache();
|
|
494
480
|
// Config without version field — legacy install, should not fail
|
|
495
481
|
mockExecSuccess();
|
|
496
482
|
const { doctor } = require('../doctor');
|
|
@@ -507,6 +493,7 @@ describe('doctor', () => {
|
|
|
507
493
|
|
|
508
494
|
it('detects templateDir pointing to wrong platform directory', async () => {
|
|
509
495
|
setupLocalInstall();
|
|
496
|
+
seedVersionCache();
|
|
510
497
|
// Write config with stale opencode templateDir when qoder is active
|
|
511
498
|
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
512
499
|
cfg.templateDir = path.join(tmpHome, '.config', 'opencode', 'git-hooks-template');
|
|
@@ -534,18 +521,20 @@ describe('doctor', () => {
|
|
|
534
521
|
|
|
535
522
|
it('passes templateDir check when templateDir matches current platform', async () => {
|
|
536
523
|
setupLocalInstall();
|
|
524
|
+
seedVersionCache();
|
|
537
525
|
// Write config with correct qoder templateDir
|
|
538
526
|
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
539
527
|
cfg.templateDir = path.join(tmpHome, '.qoder', 'git-hooks-template');
|
|
540
528
|
fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
|
|
541
529
|
|
|
542
|
-
// Create qoder marker
|
|
530
|
+
// Create qoder marker — must exist BEFORE loading shared-paths
|
|
543
531
|
fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills'), { recursive: true });
|
|
544
532
|
|
|
533
|
+
// Fresh require so shared-paths detectPlatform() sees .qoder/skills
|
|
545
534
|
delete require.cache[require.resolve('../shared-paths')];
|
|
546
535
|
delete require.cache[require.resolve('../doctor')];
|
|
547
|
-
const { doctor: doc2 } = require('../doctor');
|
|
548
536
|
mockExecSuccess();
|
|
537
|
+
const { doctor: doc2 } = require('../doctor');
|
|
549
538
|
|
|
550
539
|
const result = await doc2([]);
|
|
551
540
|
|
|
@@ -557,6 +546,7 @@ describe('doctor', () => {
|
|
|
557
546
|
|
|
558
547
|
it('passes templateDir check when config has no templateDir field (legacy)', async () => {
|
|
559
548
|
setupLocalInstall();
|
|
549
|
+
seedVersionCache();
|
|
560
550
|
// Config without templateDir — legacy install, should not fail
|
|
561
551
|
mockExecSuccess();
|
|
562
552
|
const { doctor } = require('../doctor');
|
|
@@ -569,10 +559,73 @@ describe('doctor', () => {
|
|
|
569
559
|
);
|
|
570
560
|
});
|
|
571
561
|
|
|
562
|
+
// === REQ-001-04: doctor 集成版本升级检查 (AC-004-01/02) ===
|
|
563
|
+
|
|
564
|
+
it('AC-004-01: doctor shows upgrade prompt at end when outdated', async () => {
|
|
565
|
+
setupLocalInstall();
|
|
566
|
+
mockExecSuccess();
|
|
567
|
+
// Remove version cache so checkUpgrade hits the real npm registry
|
|
568
|
+
const cachePath = path.join(tmpHome, '.xp-gate', 'version-cache.json');
|
|
569
|
+
if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath);
|
|
570
|
+
|
|
571
|
+
delete require.cache[require.resolve('../doctor')];
|
|
572
|
+
delete require.cache[require.resolve('../check-version.js')];
|
|
573
|
+
const { doctor } = require('../doctor');
|
|
574
|
+
|
|
575
|
+
const result = await doctor([]);
|
|
576
|
+
|
|
577
|
+
expect(result).toBe(0);
|
|
578
|
+
// Doctor should output upgrade message when outdated
|
|
579
|
+
const output = logSpy.mock.calls.map(c => c[0] || '').join('\n');
|
|
580
|
+
expect(output).toMatch(/newer version|upgrade|v\d+\.\d+\.\d+/);
|
|
581
|
+
}, 30000);
|
|
582
|
+
|
|
583
|
+
it('AC-004-02: doctor does NOT show upgrade prompt when up to date', async () => {
|
|
584
|
+
setupLocalInstall();
|
|
585
|
+
mockExecSuccess();
|
|
586
|
+
// Write a cache with a future version so checkUpgrade says "not outdated"
|
|
587
|
+
const cachePath = path.join(tmpHome, '.xp-gate', 'version-cache.json');
|
|
588
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
589
|
+
fs.writeFileSync(cachePath, JSON.stringify({
|
|
590
|
+
ts: Date.now(),
|
|
591
|
+
version: '999.999.999',
|
|
592
|
+
publishedAt: ''
|
|
593
|
+
}));
|
|
594
|
+
|
|
595
|
+
delete require.cache[require.resolve('../doctor')];
|
|
596
|
+
delete require.cache[require.resolve('../check-version.js')];
|
|
597
|
+
const { doctor } = require('../doctor');
|
|
598
|
+
|
|
599
|
+
const result = await doctor([]);
|
|
600
|
+
|
|
601
|
+
expect(result).toBe(0);
|
|
602
|
+
const output = logSpy.mock.calls.map(c => c[0] || '').join('\n');
|
|
603
|
+
// Should NOT contain upgrade-related messages
|
|
604
|
+
expect(output).not.toMatch(/newer version|upgrade/i);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('AC-004-03: doctor does NOT fail when version check throws', async () => {
|
|
608
|
+
setupLocalInstall();
|
|
609
|
+
seedVersionCache();
|
|
610
|
+
mockExecSuccess();
|
|
611
|
+
// Write corrupt cache to trigger checkUpgrade error path
|
|
612
|
+
const cachePath = path.join(os.homedir(), '.xp-gate', 'version-cache.json');
|
|
613
|
+
fs.writeFileSync(cachePath, 'not json');
|
|
614
|
+
|
|
615
|
+
delete require.cache[require.resolve('../doctor')];
|
|
616
|
+
delete require.cache[require.resolve('../check-version.js')];
|
|
617
|
+
const { doctor } = require('../doctor');
|
|
618
|
+
|
|
619
|
+
// Doctor should NOT throw — version check is non-blocking
|
|
620
|
+
const result = await doctor([]);
|
|
621
|
+
expect(result).toBe(0);
|
|
622
|
+
});
|
|
623
|
+
|
|
572
624
|
// === Issue #186: --fix syncs global hooks from package source ===
|
|
573
625
|
|
|
574
626
|
it('--fix syncs global hooks when they are outdated', async () => {
|
|
575
627
|
setupGlobalInstall();
|
|
628
|
+
seedVersionCache();
|
|
576
629
|
// Write an outdated pre-commit hook (different content)
|
|
577
630
|
const oldContent = '#!/bin/bash\n# OpenCode Quality Gates - Pre-Commit Hook - OLD VERSION\n';
|
|
578
631
|
fs.writeFileSync(path.join(globalHooksDir(), 'pre-commit'), oldContent);
|