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.
- package/__tests__/daemon-reconcile.test.ts +1 -0
- package/__tests__/daemon.test.ts +1 -0
- package/__tests__/hide-branches.test.ts +129 -0
- package/__tests__/logger-multistream.test.ts +151 -0
- package/__tests__/repl-reset.test.ts +3 -0
- package/__tests__/repl-status-account.test.ts +3 -0
- package/__tests__/repl.test.ts +7 -2
- package/__tests__/snapshot-sync.test.ts +6 -6
- package/__tests__/status-command.test.ts +479 -0
- package/__tests__/status-service-type.test.ts +177 -0
- package/__tests__/sync-bugs.test.ts +8 -7
- package/__tests__/sync-queue-credentials.test.ts +4 -4
- package/__tests__/sync-reorder.test.ts +8 -8
- package/__tests__/sync.test.ts +4 -3
- package/__tests__/version-check.test.ts +1 -1
- package/__tests__/version-watcher.test.ts +8 -2
- package/__tests__/watcher-branch.test.ts +68 -0
- package/dist/cli.js +6 -96
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +3 -70
- package/dist/daemon.js.map +1 -1
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +6 -143
- package/dist/repl.js.map +1 -1
- package/dist/sync.d.ts +13 -8
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +45 -21
- package/dist/sync.js.map +1 -1
- package/dist/watcher.d.ts +6 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +92 -7
- package/dist/watcher.js.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.d.ts +11 -0
- package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.d.ts.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.js +8 -0
- package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.js.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.d.ts +11 -0
- package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.d.ts.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.js +11 -0
- package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.js.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/index.d.ts +3 -3
- package/node_modules/@ultra-claude/shared/dist/index.d.ts.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/index.js +2 -2
- package/node_modules/@ultra-claude/shared/dist/index.js.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/types.d.ts +0 -32
- package/node_modules/@ultra-claude/shared/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +6 -120
- package/src/daemon.ts +3 -82
- package/src/repl.ts +6 -166
- package/src/sync.ts +56 -14
- package/src/watcher.ts +101 -7
- package/__tests__/claude-profiles-ops.test.ts +0 -441
- package/__tests__/claude-profiles.test.ts +0 -407
- package/__tests__/credential-watcher.test.ts +0 -229
- package/dist/claude-profiles.d.ts +0 -83
- package/dist/claude-profiles.d.ts.map +0 -1
- package/dist/claude-profiles.js +0 -499
- package/dist/claude-profiles.js.map +0 -1
- 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
|
+
});
|