ultraclaude-agent 0.0.21 → 0.0.23

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.
Files changed (61) hide show
  1. package/__tests__/daemon-reconcile.test.ts +1 -0
  2. package/__tests__/daemon.test.ts +1 -0
  3. package/__tests__/hide-branches.test.ts +129 -0
  4. package/__tests__/logger-multistream.test.ts +151 -0
  5. package/__tests__/repl-reset.test.ts +3 -0
  6. package/__tests__/repl-status-account.test.ts +3 -0
  7. package/__tests__/repl.test.ts +7 -2
  8. package/__tests__/snapshot-sync.test.ts +6 -6
  9. package/__tests__/status-command.test.ts +479 -0
  10. package/__tests__/status-service-type.test.ts +177 -0
  11. package/__tests__/sync-bugs.test.ts +8 -7
  12. package/__tests__/sync-queue-credentials.test.ts +4 -4
  13. package/__tests__/sync-reorder.test.ts +8 -8
  14. package/__tests__/sync.test.ts +4 -3
  15. package/__tests__/version-check.test.ts +1 -1
  16. package/__tests__/version-watcher.test.ts +8 -2
  17. package/__tests__/watcher-branch.test.ts +68 -0
  18. package/dist/cli.js +6 -96
  19. package/dist/cli.js.map +1 -1
  20. package/dist/daemon.d.ts.map +1 -1
  21. package/dist/daemon.js +3 -70
  22. package/dist/daemon.js.map +1 -1
  23. package/dist/repl.d.ts.map +1 -1
  24. package/dist/repl.js +6 -143
  25. package/dist/repl.js.map +1 -1
  26. package/dist/sync.d.ts +13 -8
  27. package/dist/sync.d.ts.map +1 -1
  28. package/dist/sync.js +45 -21
  29. package/dist/sync.js.map +1 -1
  30. package/dist/watcher.d.ts +6 -0
  31. package/dist/watcher.d.ts.map +1 -1
  32. package/dist/watcher.js +92 -7
  33. package/dist/watcher.js.map +1 -1
  34. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.d.ts +11 -0
  35. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.d.ts.map +1 -1
  36. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.js +8 -0
  37. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.js.map +1 -1
  38. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.d.ts +11 -0
  39. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.d.ts.map +1 -1
  40. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.js +11 -0
  41. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.js.map +1 -1
  42. package/node_modules/@ultra-claude/shared/dist/index.d.ts +3 -3
  43. package/node_modules/@ultra-claude/shared/dist/index.d.ts.map +1 -1
  44. package/node_modules/@ultra-claude/shared/dist/index.js +2 -2
  45. package/node_modules/@ultra-claude/shared/dist/index.js.map +1 -1
  46. package/node_modules/@ultra-claude/shared/dist/types.d.ts +0 -32
  47. package/node_modules/@ultra-claude/shared/dist/types.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/cli.ts +6 -120
  50. package/src/daemon.ts +3 -82
  51. package/src/repl.ts +6 -166
  52. package/src/sync.ts +56 -14
  53. package/src/watcher.ts +101 -7
  54. package/__tests__/claude-profiles-ops.test.ts +0 -441
  55. package/__tests__/claude-profiles.test.ts +0 -407
  56. package/__tests__/credential-watcher.test.ts +0 -229
  57. package/dist/claude-profiles.d.ts +0 -83
  58. package/dist/claude-profiles.d.ts.map +0 -1
  59. package/dist/claude-profiles.js +0 -499
  60. package/dist/claude-profiles.js.map +0 -1
  61. package/src/claude-profiles.ts +0 -597
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Tests for Task 2: Status Command Improvements and lastSyncAt Fix
3
+ *
4
+ * Success criteria verified:
5
+ * 1. status shows version with staleness warning when running old code
6
+ * → getDaemonStatus() returns version field; CLI compares against installed version
7
+ * 2. Uptime displayed as human-readable duration
8
+ * → startedAt ISO string present in DaemonStatus for CLI to compute formatDuration()
9
+ * 3. Service type shown (systemd/launchd/manual)
10
+ * → getServiceType() — see status-service-type.test.ts
11
+ * 4. lastSyncAt in status.json updates within 10s of a file push
12
+ * → onPushSuccess callback → updateProjectSyncState → getDaemonStatus reflects update
13
+ * 5. Relative timestamps used for sync times ("5m ago")
14
+ * → lastSyncAt surfaces as ISO string in DaemonStatus.projects; CLI formats via formatRelativeTime()
15
+ */
16
+
17
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
18
+
19
+ // ─── Top-level mocks (hoisted — must not use vi.doMock inside tests) ──────────
20
+
21
+ // chokidar
22
+ const mockWatcher = {
23
+ on: vi.fn().mockReturnThis(),
24
+ close: vi.fn().mockResolvedValue(undefined),
25
+ };
26
+ vi.mock('chokidar', () => ({
27
+ default: { watch: vi.fn().mockReturnValue(mockWatcher) },
28
+ }));
29
+
30
+ // config
31
+ const mockLoadCredentials = vi.fn();
32
+ const mockLoadAllCredentials = vi.fn();
33
+ const mockLoadRegistry = vi.fn();
34
+ const mockWritePid = vi.fn().mockResolvedValue(undefined);
35
+ const mockRemovePid = vi.fn().mockResolvedValue(undefined);
36
+ const mockMigrateOldConfigDir = vi.fn().mockResolvedValue(false);
37
+ const mockGetProjectAccount = vi.fn().mockResolvedValue(null);
38
+ const mockGetDefaultAccount = vi.fn().mockResolvedValue('user-1');
39
+ const mockLoadServerConfig = vi.fn().mockResolvedValue({
40
+ defaultAccount: '',
41
+ autoAssignNewProjects: true,
42
+ projectAccounts: {},
43
+ });
44
+ const mockResolveServerPaths = vi.fn().mockReturnValue({
45
+ accountsDir: '/tmp/accounts',
46
+ configFile: '/tmp/config.json',
47
+ statusFile: '/tmp/status.json',
48
+ logDir: '/tmp/logs',
49
+ });
50
+
51
+ vi.mock('../src/config.js', () => ({
52
+ loadCredentials: (...args: unknown[]) => mockLoadCredentials(...args),
53
+ loadAllCredentials: (...args: unknown[]) => mockLoadAllCredentials(...args),
54
+ loadServerConfig: (...args: unknown[]) => mockLoadServerConfig(...args),
55
+ getServerUrl: (creds: { serverUrl: string } | null) =>
56
+ creds?.serverUrl ?? 'http://localhost:3000',
57
+ loadRegistry: (...args: unknown[]) => mockLoadRegistry(...args),
58
+ getProjectId: vi.fn().mockResolvedValue('proj-1'),
59
+ writeProjectId: vi.fn().mockResolvedValue(undefined),
60
+ writePid: (...args: unknown[]) => mockWritePid(...args),
61
+ removePid: (...args: unknown[]) => mockRemovePid(...args),
62
+ migrateOldConfigDir: (...args: unknown[]) => mockMigrateOldConfigDir(...args),
63
+ getProjectAccount: (...args: unknown[]) => mockGetProjectAccount(...args),
64
+ setProjectAccount: vi.fn().mockResolvedValue(undefined),
65
+ getDefaultAccount: (...args: unknown[]) => mockGetDefaultAccount(...args),
66
+ resolveServerPaths: (...args: unknown[]) => mockResolveServerPaths(...args),
67
+ isDaemonRunning: vi.fn().mockReturnValue(false),
68
+ paths: {
69
+ claudeProjects: '/tmp/test-claude-projects',
70
+ projectIdFile: '.claude/ultra/project-id',
71
+ oldConfigDir: '/tmp/test-old-config',
72
+ },
73
+ }));
74
+
75
+ // sync
76
+ vi.mock('../src/sync.js', () => ({
77
+ createProjectOnServer: vi.fn().mockResolvedValue({ id: 'proj-1' }),
78
+ initialSync: vi.fn().mockResolvedValue(undefined),
79
+ fetchManifest: vi.fn().mockResolvedValue({ success: true, data: [] }),
80
+ stopSync: vi.fn(),
81
+ pushVersionMetadata: vi.fn().mockResolvedValue(undefined),
82
+ syncFile: vi.fn().mockResolvedValue(undefined),
83
+ deleteFiles: vi.fn().mockResolvedValue(undefined),
84
+ }));
85
+
86
+ // watcher — captures onPushSuccess/onPushError for later invocation
87
+ let capturedOnPushSuccess: ((path: string) => void) | undefined;
88
+ let capturedOnPushError: ((path: string) => void) | undefined;
89
+ const mockStartProjectWatcher = vi.fn().mockImplementation(
90
+ (opts: {
91
+ onPushSuccess?: (path: string) => void;
92
+ onPushError?: (path: string) => void;
93
+ projectId: string;
94
+ projectPath: string;
95
+ credentials: unknown;
96
+ }) => {
97
+ capturedOnPushSuccess = opts.onPushSuccess;
98
+ capturedOnPushError = opts.onPushError;
99
+ return {
100
+ projectId: opts.projectId,
101
+ projectPath: opts.projectPath,
102
+ credentials: opts.credentials,
103
+ close: vi.fn().mockResolvedValue(undefined),
104
+ };
105
+ },
106
+ );
107
+ vi.mock('../src/watcher.js', () => ({
108
+ startProjectWatcher: (...args: unknown[]) => mockStartProjectWatcher(...args),
109
+ getCurrentBranch: vi.fn().mockReturnValue('main'),
110
+ }));
111
+
112
+ // usage-sync
113
+ vi.mock('../src/usage-sync.js', () => ({
114
+ startUsageWatcher: vi.fn().mockReturnValue({
115
+ close: vi.fn().mockResolvedValue(undefined),
116
+ }),
117
+ }));
118
+
119
+ // socket-client
120
+ vi.mock('../src/socket-client.js', () => ({
121
+ init: vi.fn(),
122
+ addAccount: vi.fn(),
123
+ removeAccount: vi.fn(),
124
+ disconnectAll: vi.fn(),
125
+ getConnectedUserIds: vi.fn().mockReturnValue([]),
126
+ }));
127
+
128
+ // status
129
+ const mockStartStatusLoop = vi.fn().mockReturnValue({ stop: vi.fn() });
130
+ const mockReadStatusFile = vi.fn().mockResolvedValue(null);
131
+ vi.mock('../src/status.js', () => ({
132
+ startStatusLoop: (...args: unknown[]) => mockStartStatusLoop(...args),
133
+ readStatusFile: (...args: unknown[]) => mockReadStatusFile(...args),
134
+ removeStatusFile: vi.fn().mockResolvedValue(undefined),
135
+ writeStatusFile: vi.fn().mockResolvedValue(undefined),
136
+ }));
137
+
138
+ // service
139
+ vi.mock('../src/service.js', () => ({
140
+ isServiceActive: vi.fn().mockResolvedValue(null),
141
+ isServiceInstalled: vi.fn().mockResolvedValue(false),
142
+ installService: vi.fn().mockResolvedValue(undefined),
143
+ uninstallService: vi.fn().mockResolvedValue(undefined),
144
+ SERVICE_NAME: 'ultraclaude-dashboard-agent',
145
+ LAUNCHD_LABEL: 'com.ultraclaude.dashboard-agent',
146
+ getServiceType: vi.fn().mockResolvedValue('manual'),
147
+ }));
148
+
149
+ // logger
150
+ vi.mock('../src/logger.js', () => ({
151
+ logger: {
152
+ child: () => ({
153
+ info: vi.fn(),
154
+ warn: vi.fn(),
155
+ error: vi.fn(),
156
+ debug: vi.fn(),
157
+ }),
158
+ info: vi.fn(),
159
+ warn: vi.fn(),
160
+ error: vi.fn(),
161
+ debug: vi.fn(),
162
+ fatal: vi.fn(),
163
+ },
164
+ }));
165
+
166
+ const testCreds = {
167
+ apiKey: 'test-key',
168
+ userId: 'user-1',
169
+ serverUrl: 'http://localhost:3000',
170
+ email: 'alice@example.com',
171
+ };
172
+
173
+ function setupStandardMocks() {
174
+ mockLoadCredentials.mockResolvedValue(testCreds);
175
+ mockLoadAllCredentials.mockResolvedValue(new Map([['user-1', testCreds]]));
176
+ mockLoadRegistry.mockResolvedValue({ projects: [] });
177
+ }
178
+
179
+ // ─── Criterion 1 & 3: getDaemonStatus() version field ─────────────────────────
180
+
181
+ describe('getDaemonStatus() — version field (criterion: staleness warning)', () => {
182
+ beforeEach(() => {
183
+ capturedOnPushSuccess = undefined;
184
+ capturedOnPushError = undefined;
185
+ vi.clearAllMocks();
186
+ setupStandardMocks();
187
+ });
188
+
189
+ it('includes version when running in-process', async () => {
190
+ vi.resetModules();
191
+ const { startDaemon, getDaemonStatus, stopDaemon } = await import('../src/daemon.js');
192
+
193
+ const result = await startDaemon();
194
+ expect(result.success).toBe(true);
195
+
196
+ const status = await getDaemonStatus('http://localhost:3000');
197
+
198
+ // Must include version — CLI compares against package.json to show staleness warning
199
+ expect(status.version).toBeDefined();
200
+ expect(typeof status.version).toBe('string');
201
+ expect(status.version!.length).toBeGreaterThan(0);
202
+
203
+ await stopDaemon();
204
+ });
205
+
206
+ it('version matches LOADED_VERSION constant', async () => {
207
+ vi.resetModules();
208
+ const { startDaemon, getDaemonStatus, stopDaemon, LOADED_VERSION } =
209
+ await import('../src/daemon.js');
210
+
211
+ await startDaemon();
212
+ const status = await getDaemonStatus('http://localhost:3000');
213
+ expect(status.version).toBe(LOADED_VERSION);
214
+
215
+ await stopDaemon();
216
+ });
217
+
218
+ it('reads version from status.json for external daemon (staleness detection)', async () => {
219
+ // Simulate external daemon that was started with an OLD version
220
+ mockReadStatusFile.mockResolvedValue({
221
+ pid: 99999,
222
+ startedAt: new Date(Date.now() - 3600_000).toISOString(),
223
+ version: '0.0.1', // old daemon version
224
+ projectCount: 0,
225
+ projects: [],
226
+ });
227
+
228
+ vi.resetModules();
229
+ const { getDaemonStatus } = await import('../src/daemon.js');
230
+
231
+ // Not running in-process → reads from status.json
232
+ const status = await getDaemonStatus('http://localhost:3000');
233
+ expect(status.running).toBe(true);
234
+
235
+ // Criterion: CLI compares status.version against installed version for staleness warning
236
+ // getDaemonStatus must surface the daemon's own version from status.json
237
+ expect(status.version).toBe('0.0.1');
238
+ });
239
+
240
+ it('buildStatusData() passed to startStatusLoop includes version field', async () => {
241
+ vi.resetModules();
242
+ const { startDaemon, stopDaemon, LOADED_VERSION } = await import('../src/daemon.js');
243
+
244
+ await startDaemon();
245
+
246
+ // The callback was registered with the status loop
247
+ expect(mockStartStatusLoop).toHaveBeenCalled();
248
+ const [, getStatusFn] = mockStartStatusLoop.mock.calls[0] as [
249
+ string,
250
+ () => { version?: string; pid: number; startedAt: string },
251
+ ];
252
+
253
+ const data = getStatusFn();
254
+
255
+ // version must be written to status.json via the status loop
256
+ expect(data.version).toBe(LOADED_VERSION);
257
+
258
+ await stopDaemon();
259
+ });
260
+ });
261
+
262
+ // ─── Criterion 2: startedAt for uptime calculation ────────────────────────────
263
+
264
+ describe('getDaemonStatus() — startedAt field (criterion: human-readable uptime)', () => {
265
+ beforeEach(() => {
266
+ capturedOnPushSuccess = undefined;
267
+ vi.clearAllMocks();
268
+ setupStandardMocks();
269
+ });
270
+
271
+ it('includes startedAt ISO string for uptime calculation', async () => {
272
+ const beforeStart = Date.now();
273
+
274
+ vi.resetModules();
275
+ const { startDaemon, getDaemonStatus, stopDaemon } = await import('../src/daemon.js');
276
+ await startDaemon();
277
+
278
+ const status = await getDaemonStatus('http://localhost:3000');
279
+
280
+ // startedAt must be present — CLI uses `Date.now() - new Date(status.startedAt).getTime()`
281
+ // to compute uptime and then calls formatDuration() for human-readable output
282
+ expect(status.startedAt).toBeDefined();
283
+ expect(status.startedAt).not.toBeNull();
284
+
285
+ const startedAtMs = new Date(status.startedAt!).getTime();
286
+ expect(startedAtMs).toBeGreaterThanOrEqual(beforeStart);
287
+ expect(startedAtMs).toBeLessThanOrEqual(Date.now() + 1000);
288
+
289
+ await stopDaemon();
290
+ });
291
+
292
+ it('startedAt is written to status.json in buildStatusData()', async () => {
293
+ vi.resetModules();
294
+ const { startDaemon, stopDaemon } = await import('../src/daemon.js');
295
+ await startDaemon();
296
+
297
+ const [, getStatusFn] = mockStartStatusLoop.mock.calls[0] as [
298
+ string,
299
+ () => { startedAt: string },
300
+ ];
301
+ const data = getStatusFn();
302
+
303
+ // startedAt must be a valid ISO string
304
+ expect(data.startedAt).toBeDefined();
305
+ expect(() => new Date(data.startedAt)).not.toThrow();
306
+ expect(new Date(data.startedAt).getTime()).toBeGreaterThan(0);
307
+
308
+ await stopDaemon();
309
+ });
310
+ });
311
+
312
+ // ─── Criterion 4 & 5: lastSyncAt updates on file push ─────────────────────────
313
+
314
+ describe('lastSyncAt — updates on every successful file push', () => {
315
+ beforeEach(() => {
316
+ capturedOnPushSuccess = undefined;
317
+ capturedOnPushError = undefined;
318
+ vi.clearAllMocks();
319
+ mockLoadCredentials.mockResolvedValue(testCreds);
320
+ mockLoadAllCredentials.mockResolvedValue(new Map([['user-1', testCreds]]));
321
+ mockLoadRegistry.mockResolvedValue({
322
+ projects: [{ path: '/test/my-project', name: 'my-project' }],
323
+ });
324
+ });
325
+
326
+ it('onPushSuccess callback updates lastSyncAt in daemon state', async () => {
327
+ vi.resetModules();
328
+ const { startDaemon, getDaemonStatus, stopDaemon } = await import('../src/daemon.js');
329
+ await startDaemon();
330
+
331
+ // Callback must have been captured during startDaemon → ensureProject
332
+ expect(capturedOnPushSuccess).toBeDefined();
333
+
334
+ const beforePush = Date.now();
335
+ capturedOnPushSuccess!('/test/my-project');
336
+
337
+ // After callback fires, getDaemonStatus must reflect updated lastSyncAt
338
+ const status = await getDaemonStatus('http://localhost:3000');
339
+ const project = status.projects.find((p) => p.path === '/test/my-project');
340
+
341
+ expect(project).toBeDefined();
342
+ expect(project!.lastSyncAt).not.toBeNull();
343
+
344
+ const syncTime = new Date(project!.lastSyncAt!).getTime();
345
+ expect(syncTime).toBeGreaterThanOrEqual(beforePush);
346
+ expect(syncTime).toBeLessThanOrEqual(Date.now() + 100);
347
+
348
+ await stopDaemon();
349
+ });
350
+
351
+ it('lastSyncAt advances on second push (not just initial sync)', async () => {
352
+ vi.resetModules();
353
+ const { startDaemon, getDaemonStatus, stopDaemon, updateProjectSyncState } =
354
+ await import('../src/daemon.js');
355
+ await startDaemon();
356
+
357
+ // Set a known "first sync" time in the past (30 seconds ago)
358
+ const firstSyncTime = new Date(Date.now() - 30_000);
359
+ updateProjectSyncState('/test/my-project', { lastSyncAt: firstSyncTime });
360
+
361
+ const firstStatus = await getDaemonStatus('http://localhost:3000');
362
+ const firstProject = firstStatus.projects.find((p) => p.path === '/test/my-project');
363
+ expect(firstProject?.lastSyncAt).toBe(firstSyncTime.toISOString());
364
+
365
+ // Simulate a SECOND file push completing successfully
366
+ const beforeSecondPush = Date.now();
367
+ capturedOnPushSuccess!('/test/my-project');
368
+
369
+ const secondStatus = await getDaemonStatus('http://localhost:3000');
370
+ const secondProject = secondStatus.projects.find((p) => p.path === '/test/my-project');
371
+
372
+ // lastSyncAt must have advanced beyond the 30-second-old timestamp
373
+ expect(secondProject?.lastSyncAt).not.toBe(firstSyncTime.toISOString());
374
+ const newTime = new Date(secondProject!.lastSyncAt!).getTime();
375
+ expect(newTime).toBeGreaterThanOrEqual(beforeSecondPush);
376
+
377
+ await stopDaemon();
378
+ });
379
+
380
+ it('onPushError does NOT update lastSyncAt', async () => {
381
+ vi.resetModules();
382
+ const { startDaemon, getDaemonStatus, stopDaemon, updateProjectSyncState } =
383
+ await import('../src/daemon.js');
384
+ await startDaemon();
385
+
386
+ // Set initial sync time
387
+ const initialSyncTime = new Date(Date.now() - 60_000);
388
+ updateProjectSyncState('/test/my-project', { lastSyncAt: initialSyncTime });
389
+
390
+ // Fire an error (file push failed)
391
+ capturedOnPushError!('/test/my-project');
392
+
393
+ const status = await getDaemonStatus('http://localhost:3000');
394
+ const project = status.projects.find((p) => p.path === '/test/my-project');
395
+
396
+ // lastSyncAt should NOT change on error — only errorCount increases
397
+ expect(project?.lastSyncAt).toBe(initialSyncTime.toISOString());
398
+ expect(project?.errorCount).toBeGreaterThan(0);
399
+
400
+ await stopDaemon();
401
+ });
402
+
403
+ it('lastSyncAt surfaces in buildStatusData for status.json write', async () => {
404
+ vi.resetModules();
405
+ const { startDaemon, stopDaemon, updateProjectSyncState } =
406
+ await import('../src/daemon.js');
407
+ await startDaemon();
408
+
409
+ // Set a known sync time
410
+ const syncTime = new Date(Date.now() - 5 * 60 * 1000); // 5 minutes ago
411
+ updateProjectSyncState('/test/my-project', { lastSyncAt: syncTime });
412
+
413
+ // The status loop callback returns current state
414
+ const [, getStatusFn] = mockStartStatusLoop.mock.calls[0] as [
415
+ string,
416
+ () => { projects: Array<{ path: string; lastSyncAt: string | null }> },
417
+ ];
418
+ const data = getStatusFn();
419
+
420
+ const project = data.projects.find((p) => p.path === '/test/my-project');
421
+ // lastSyncAt written to status.json (which status command reads for "5m ago" display)
422
+ expect(project?.lastSyncAt).toBe(syncTime.toISOString());
423
+
424
+ await stopDaemon();
425
+ });
426
+
427
+ it('watcher onPushSuccess is wired up in ensureProject', async () => {
428
+ vi.resetModules();
429
+ const { startDaemon, stopDaemon } = await import('../src/daemon.js');
430
+ await startDaemon();
431
+
432
+ // startProjectWatcher must have been called with onPushSuccess and onPushError callbacks
433
+ expect(mockStartProjectWatcher).toHaveBeenCalled();
434
+ const callOpts = mockStartProjectWatcher.mock.calls[0]![0] as {
435
+ onPushSuccess?: unknown;
436
+ onPushError?: unknown;
437
+ };
438
+ expect(typeof callOpts.onPushSuccess).toBe('function');
439
+ expect(typeof callOpts.onPushError).toBe('function');
440
+
441
+ await stopDaemon();
442
+ });
443
+ });
444
+
445
+ // ─── StatusFileData type validation ────────────────────────────────────────────
446
+
447
+ describe('StatusFileData — version field in schema', () => {
448
+ it('StatusFileData type allows optional version field', async () => {
449
+ // Import the type and verify the version field is present in the interface
450
+ // This is a compile-time check made runtime via code inspection
451
+ const statusModule = await import('../src/status.js');
452
+
453
+ // The module exports must exist (this validates the module loads correctly)
454
+ expect(typeof statusModule.startStatusLoop).toBe('function');
455
+ expect(typeof statusModule.writeStatusFile).toBe('function');
456
+ expect(typeof statusModule.readStatusFile).toBe('function');
457
+
458
+ // Write a StatusFileData object WITH version — must not throw type error
459
+ // (TypeScript type checking validates this at compile time; test confirms shape)
460
+ const dataWithVersion = {
461
+ pid: 123,
462
+ startedAt: new Date().toISOString(),
463
+ version: '1.2.3',
464
+ projectCount: 0,
465
+ projects: [],
466
+ };
467
+
468
+ const dataWithoutVersion = {
469
+ pid: 123,
470
+ startedAt: new Date().toISOString(),
471
+ projectCount: 0,
472
+ projects: [],
473
+ };
474
+
475
+ // Both shapes are valid (version is optional for backwards compat)
476
+ expect(dataWithVersion.version).toBe('1.2.3');
477
+ expect('version' in dataWithoutVersion).toBe(false);
478
+ });
479
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Tests for getServiceType() — Task 2 criterion: "Service type shown (systemd/launchd/manual)"
3
+ *
4
+ * getServiceType() is in service.ts and exported. It calls isServiceActive() and
5
+ * isServiceInstalled() which shell out to systemctl / launchctl / schtasks.
6
+ * We mock the underlying os/child_process/config to control what each returns.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
10
+
11
+ // Mock child_process execFile — controls what systemctl/launchctl return
12
+ const mockExecFile = vi.fn();
13
+ vi.mock('node:child_process', async (importOriginal) => {
14
+ const actual = await importOriginal<typeof import('node:child_process')>();
15
+ return { ...actual, execFile: (...args: unknown[]) => mockExecFile(...args) };
16
+ });
17
+
18
+ // Mock node:util promisify — returns mockExecFile directly
19
+ vi.mock('node:util', async (importOriginal) => {
20
+ const actual = await importOriginal<typeof import('node:util')>();
21
+ return { ...actual, promisify: () => mockExecFile };
22
+ });
23
+
24
+ // Mock node:fs/promises — for fileExists (isServiceInstalled)
25
+ const mockAccess = vi.fn();
26
+ vi.mock('node:fs/promises', async (importOriginal) => {
27
+ const actual = await importOriginal<typeof import('node:fs/promises')>();
28
+ return {
29
+ ...actual,
30
+ writeFile: vi.fn().mockResolvedValue(undefined),
31
+ mkdir: vi.fn().mockResolvedValue(undefined),
32
+ unlink: vi.fn().mockResolvedValue(undefined),
33
+ access: (...args: unknown[]) => mockAccess(...args),
34
+ };
35
+ });
36
+
37
+ // Mock node:os platform — controlled per-test
38
+ const mockPlatform = vi.fn().mockReturnValue('linux');
39
+ vi.mock('node:os', async (importOriginal) => {
40
+ const actual = await importOriginal<typeof import('node:os')>();
41
+ return {
42
+ ...actual,
43
+ platform: () => mockPlatform(),
44
+ homedir: () => '/home/testuser',
45
+ };
46
+ });
47
+
48
+ // Mock config — provides fileExists used by isServiceInstalled
49
+ const mockFileExists = vi.fn();
50
+ vi.mock('../src/config.js', () => ({
51
+ resolveServerPaths: () => ({
52
+ accountsDir: '/tmp/accounts',
53
+ configFile: '/tmp/config.json',
54
+ statusFile: '/tmp/status.json',
55
+ logDir: '/tmp/logs',
56
+ }),
57
+ fileExists: (...args: unknown[]) => mockFileExists(...args),
58
+ paths: {
59
+ claudeProjects: '/tmp/test-claude-projects',
60
+ projectIdFile: '.claude/ultra/project-id',
61
+ oldConfigDir: '/tmp/test-old-config',
62
+ },
63
+ }));
64
+
65
+ // Mock logger
66
+ vi.mock('../src/logger.js', () => ({
67
+ logger: {
68
+ child: () => ({
69
+ info: vi.fn(),
70
+ warn: vi.fn(),
71
+ error: vi.fn(),
72
+ debug: vi.fn(),
73
+ }),
74
+ info: vi.fn(),
75
+ warn: vi.fn(),
76
+ error: vi.fn(),
77
+ debug: vi.fn(),
78
+ fatal: vi.fn(),
79
+ },
80
+ }));
81
+
82
+ describe('getServiceType() — service management label for status display', () => {
83
+ beforeEach(() => {
84
+ vi.clearAllMocks();
85
+ mockPlatform.mockReturnValue('linux');
86
+ // Default: no service active, not installed
87
+ mockExecFile.mockRejectedValue(new Error('not active'));
88
+ mockFileExists.mockResolvedValue(false);
89
+ });
90
+
91
+ it('returns "systemd (active)" when systemd is active on Linux', async () => {
92
+ mockPlatform.mockReturnValue('linux');
93
+ // systemctl --user is-active → "active"
94
+ mockExecFile.mockResolvedValue({ stdout: 'active\n', stderr: '' });
95
+
96
+ vi.resetModules();
97
+ const { getServiceType } = await import('../src/service.js');
98
+ const result = await getServiceType();
99
+ expect(result).toBe('systemd (active)');
100
+ });
101
+
102
+ it('returns "systemd (installed)" when installed but not active on Linux', async () => {
103
+ mockPlatform.mockReturnValue('linux');
104
+ // systemctl → not active
105
+ mockExecFile.mockRejectedValue(new Error('inactive'));
106
+ // but service file exists
107
+ mockFileExists.mockResolvedValue(true);
108
+
109
+ vi.resetModules();
110
+ const { getServiceType } = await import('../src/service.js');
111
+ const result = await getServiceType();
112
+ expect(result).toBe('systemd (installed)');
113
+ });
114
+
115
+ it('returns "manual" when no service detected on Linux', async () => {
116
+ mockPlatform.mockReturnValue('linux');
117
+ // systemctl → not active
118
+ mockExecFile.mockRejectedValue(new Error('not found'));
119
+ // service file does not exist
120
+ mockFileExists.mockResolvedValue(false);
121
+
122
+ vi.resetModules();
123
+ const { getServiceType } = await import('../src/service.js');
124
+ const result = await getServiceType();
125
+ expect(result).toBe('manual');
126
+ });
127
+
128
+ it('returns "launchd (active)" when launchd lists the service on macOS', async () => {
129
+ mockPlatform.mockReturnValue('darwin');
130
+ // launchctl list → includes the service label
131
+ mockExecFile.mockResolvedValue({
132
+ stdout: 'PID\tStatus\tLabel\n12345\t0\tcom.ultraclaude.dashboard-agent\n',
133
+ stderr: '',
134
+ });
135
+
136
+ vi.resetModules();
137
+ const { getServiceType } = await import('../src/service.js');
138
+ const result = await getServiceType();
139
+ expect(result).toBe('launchd (active)');
140
+ });
141
+
142
+ it('returns "launchd (installed)" when plist exists but not in launchctl list on macOS', async () => {
143
+ mockPlatform.mockReturnValue('darwin');
144
+ // launchctl list → does NOT include our service
145
+ mockExecFile.mockResolvedValue({ stdout: 'some other services\n', stderr: '' });
146
+ // plist file exists
147
+ mockFileExists.mockResolvedValue(true);
148
+
149
+ vi.resetModules();
150
+ const { getServiceType } = await import('../src/service.js');
151
+ const result = await getServiceType();
152
+ expect(result).toBe('launchd (installed)');
153
+ });
154
+
155
+ it('returns "manual" when no service detected on macOS', async () => {
156
+ mockPlatform.mockReturnValue('darwin');
157
+ // launchctl fails
158
+ mockExecFile.mockRejectedValue(new Error('launchctl failed'));
159
+ // plist file does not exist
160
+ mockFileExists.mockResolvedValue(false);
161
+
162
+ vi.resetModules();
163
+ const { getServiceType } = await import('../src/service.js');
164
+ const result = await getServiceType();
165
+ expect(result).toBe('manual');
166
+ });
167
+
168
+ it('returns "manual" on unsupported platform', async () => {
169
+ mockPlatform.mockReturnValue('freebsd');
170
+ mockFileExists.mockResolvedValue(false);
171
+
172
+ vi.resetModules();
173
+ const { getServiceType } = await import('../src/service.js');
174
+ const result = await getServiceType();
175
+ expect(result).toBe('manual');
176
+ });
177
+ });