ultraclaude-agent 0.0.20 → 0.0.22

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.
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Tests for high-level claude-profiles operations:
3
+ * switchProfile, loginProfile, deleteProfileByName, saveCurrentAsProfile
4
+ *
5
+ * These cover success criteria that are NOT tested in claude-profiles.test.ts:
6
+ * - claude switch <name>: atomic write, active.json update, auth status verify
7
+ * - claude switch with expired token: refreshes and saves updated credentials
8
+ * - claude login <name> --email: pre-populates from existing profile when present
9
+ * - claude delete <name>: warns and clears active.json when deleting active profile
10
+ * - claude save <name>: saves current credentials + auth status metadata
11
+ */
12
+
13
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
14
+ import { join } from 'node:path';
15
+
16
+ // Mock node:os for consistent homedir
17
+ vi.mock('node:os', async (importOriginal) => {
18
+ const actual = await importOriginal<typeof import('node:os')>();
19
+ return {
20
+ ...actual,
21
+ homedir: () => '/home/testuser',
22
+ };
23
+ });
24
+
25
+ // Mock fs/promises
26
+ const mockReadFile = vi.fn();
27
+ const mockWriteFile = vi.fn().mockResolvedValue(undefined);
28
+ const mockRename = vi.fn().mockResolvedValue(undefined);
29
+ const mockMkdir = vi.fn().mockResolvedValue(undefined);
30
+ const mockUnlink = vi.fn().mockResolvedValue(undefined);
31
+ const mockReaddir = vi.fn();
32
+ const mockStat = vi.fn();
33
+
34
+ vi.mock('node:fs/promises', () => ({
35
+ readFile: (...args: unknown[]) => mockReadFile(...args),
36
+ writeFile: (...args: unknown[]) => mockWriteFile(...args),
37
+ rename: (...args: unknown[]) => mockRename(...args),
38
+ mkdir: (...args: unknown[]) => mockMkdir(...args),
39
+ unlink: (...args: unknown[]) => mockUnlink(...args),
40
+ readdir: (...args: unknown[]) => mockReaddir(...args),
41
+ stat: (...args: unknown[]) => mockStat(...args),
42
+ }));
43
+
44
+ // Mock logger
45
+ vi.mock('../src/logger.js', () => ({
46
+ logger: {
47
+ child: () => ({
48
+ info: vi.fn(),
49
+ warn: vi.fn(),
50
+ error: vi.fn(),
51
+ debug: vi.fn(),
52
+ }),
53
+ info: vi.fn(),
54
+ warn: vi.fn(),
55
+ error: vi.fn(),
56
+ debug: vi.fn(),
57
+ },
58
+ }));
59
+
60
+ // Spawn mock infrastructure
61
+ const mockExecFileSync = vi.fn();
62
+ const mockSpawn = vi.fn();
63
+
64
+ vi.mock('node:child_process', () => ({
65
+ execFileSync: (...args: unknown[]) => mockExecFileSync(...args),
66
+ spawn: (...args: unknown[]) => mockSpawn(...args),
67
+ }));
68
+
69
+ type Handler = (...args: unknown[]) => void;
70
+
71
+ function createMockChild(output: string, exitCode = 0) {
72
+ const stdoutHandlers: Record<string, Handler[]> = {};
73
+ const childHandlers: Record<string, Handler[]> = {};
74
+
75
+ const mockStdout = {
76
+ on(event: string, handler: Handler) {
77
+ (stdoutHandlers[event] ??= []).push(handler);
78
+ return mockStdout;
79
+ },
80
+ };
81
+ const mockStderr = {
82
+ on(_event: string, _handler: Handler) { return mockStderr; },
83
+ };
84
+
85
+ const child = {
86
+ stdout: mockStdout,
87
+ stderr: mockStderr,
88
+ kill: vi.fn(),
89
+ on(event: string, handler: Handler) {
90
+ (childHandlers[event] ??= []).push(handler);
91
+ if (event === 'close') {
92
+ setTimeout(() => {
93
+ for (const h of stdoutHandlers['data'] ?? []) h(Buffer.from(output));
94
+ for (const h of childHandlers['close'] ?? []) h(exitCode);
95
+ }, 0);
96
+ }
97
+ return child;
98
+ },
99
+ };
100
+
101
+ return child;
102
+ }
103
+
104
+ const PROFILES_DIR = '/home/testuser/.claude/ultra/claude-profiles';
105
+ const CREDENTIALS_FILE = '/home/testuser/.claude/.credentials.json';
106
+ const ACTIVE_FILE = join(PROFILES_DIR, 'active.json');
107
+
108
+ const sampleCredentials = {
109
+ claudeAiOauth: {
110
+ accessToken: 'sk-ant-oat01-test',
111
+ refreshToken: 'sk-ant-ort01-test',
112
+ expiresAt: Date.now() + 3600_000,
113
+ scopes: ['user:profile', 'user:inference'],
114
+ subscriptionType: 'team',
115
+ rateLimitTier: 'default_claude_max_5x',
116
+ },
117
+ };
118
+
119
+ const sampleProfile = {
120
+ name: 'work',
121
+ email: 'user@work.com',
122
+ orgName: 'WorkOrg',
123
+ subscriptionType: 'team',
124
+ savedAt: '2026-04-11T18:05:36Z',
125
+ credentials: sampleCredentials,
126
+ };
127
+
128
+ const authStatusOutput = JSON.stringify({
129
+ email: 'user@work.com',
130
+ orgName: 'WorkOrg',
131
+ subscriptionType: 'team',
132
+ loggedIn: true,
133
+ });
134
+
135
+ describe('switchProfile', () => {
136
+ beforeEach(() => {
137
+ vi.resetAllMocks();
138
+ mockExecFileSync.mockReturnValue('/usr/bin/claude');
139
+ // Reset self-trigger flag
140
+ });
141
+
142
+ it('returns error when profile does not exist', async () => {
143
+ // Profile not found
144
+ mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
145
+
146
+ const { switchProfile, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
147
+ clearSelfTriggerFlag();
148
+
149
+ const result = await switchProfile('nonexistent');
150
+ expect(result.success).toBe(false);
151
+ expect(result.message).toContain('"nonexistent" not found');
152
+ });
153
+
154
+ it('atomically writes credentials, updates active.json, and verifies via auth status', async () => {
155
+ // Profile load → work.json
156
+ // Then readCredentialsFile (freshCredentials) → sampleCredentials
157
+ // Then active file write
158
+ let readCallIdx = 0;
159
+ mockReadFile.mockImplementation(() => {
160
+ readCallIdx++;
161
+ if (readCallIdx === 1) {
162
+ // loadProfile('work')
163
+ return Promise.resolve(JSON.stringify(sampleProfile));
164
+ }
165
+ // readCredentialsFile() after auth status
166
+ return Promise.resolve(JSON.stringify(sampleCredentials));
167
+ });
168
+
169
+ mockSpawn.mockReturnValue(createMockChild(authStatusOutput, 0));
170
+
171
+ const { switchProfile, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
172
+ clearSelfTriggerFlag();
173
+
174
+ const result = await switchProfile('work');
175
+ expect(result.success).toBe(true);
176
+ expect(result.message).toContain('Switched to "work"');
177
+ expect(result.message).toContain('user@work.com');
178
+
179
+ // Should have written credentials file atomically (temp + rename)
180
+ const writeCallsToCredentials = mockWriteFile.mock.calls.filter(
181
+ (c) => (c[0] as string).startsWith(CREDENTIALS_FILE),
182
+ );
183
+ expect(writeCallsToCredentials.length).toBeGreaterThan(0);
184
+
185
+ // Atomic rename of credentials
186
+ const renameToCredentials = mockRename.mock.calls.filter(
187
+ (c) => (c[1] as string) === CREDENTIALS_FILE,
188
+ );
189
+ expect(renameToCredentials.length).toBeGreaterThan(0);
190
+
191
+ // Should have updated active.json
192
+ const writeCallsToActive = mockWriteFile.mock.calls.filter(
193
+ (c) => (c[0] as string).startsWith(ACTIVE_FILE),
194
+ );
195
+ expect(writeCallsToActive.length).toBeGreaterThan(0);
196
+ });
197
+
198
+ it('sets self-trigger flag before writing credentials', async () => {
199
+ mockReadFile.mockResolvedValue(JSON.stringify(sampleProfile));
200
+ mockSpawn.mockReturnValue(createMockChild(authStatusOutput, 0));
201
+
202
+ const { switchProfile, isSelfTriggered, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
203
+ clearSelfTriggerFlag();
204
+
205
+ // Spy on writeFile to check flag state at write time
206
+ let flagStateAtWrite = false;
207
+ mockWriteFile.mockImplementationOnce((..._args: unknown[]) => {
208
+ flagStateAtWrite = isSelfTriggered();
209
+ return Promise.resolve();
210
+ });
211
+
212
+ await switchProfile('work');
213
+ expect(flagStateAtWrite).toBe(true);
214
+ });
215
+
216
+ it('updates saved profile with refreshed credentials from auth status', async () => {
217
+ const refreshedCredentials = {
218
+ claudeAiOauth: {
219
+ ...sampleCredentials.claudeAiOauth,
220
+ accessToken: 'sk-ant-refreshed-token',
221
+ expiresAt: Date.now() + 7200_000, // new expiry
222
+ },
223
+ };
224
+
225
+ let readCallIdx = 0;
226
+ mockReadFile.mockImplementation(() => {
227
+ readCallIdx++;
228
+ if (readCallIdx === 1) return Promise.resolve(JSON.stringify(sampleProfile));
229
+ // After auth status runs, credentials file has refreshed tokens
230
+ return Promise.resolve(JSON.stringify(refreshedCredentials));
231
+ });
232
+
233
+ mockSpawn.mockReturnValue(createMockChild(authStatusOutput, 0));
234
+
235
+ const { switchProfile, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
236
+ clearSelfTriggerFlag();
237
+
238
+ const result = await switchProfile('work');
239
+ expect(result.success).toBe(true);
240
+
241
+ // The profile should be saved with the refreshed credentials
242
+ const savedProfileCall = mockWriteFile.mock.calls.find((c) => {
243
+ try {
244
+ const content = JSON.parse(c[1] as string);
245
+ return content.credentials?.claudeAiOauth?.accessToken === 'sk-ant-refreshed-token';
246
+ } catch {
247
+ return false;
248
+ }
249
+ });
250
+ expect(savedProfileCall).toBeDefined();
251
+ });
252
+ });
253
+
254
+ describe('deleteProfileByName', () => {
255
+ beforeEach(() => {
256
+ vi.resetAllMocks();
257
+ mockExecFileSync.mockReturnValue('/usr/bin/claude');
258
+ });
259
+
260
+ it('returns error when profile does not exist', async () => {
261
+ mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
262
+
263
+ const { deleteProfileByName } = await import('../src/claude-profiles.js');
264
+ const result = await deleteProfileByName('nonexistent');
265
+ expect(result.success).toBe(false);
266
+ expect(result.wasActive).toBe(false);
267
+ });
268
+
269
+ it('deletes profile file and returns wasActive=false when not active', async () => {
270
+ let readCallIdx = 0;
271
+ mockReadFile.mockImplementation(() => {
272
+ readCallIdx++;
273
+ if (readCallIdx === 1) return Promise.resolve(JSON.stringify(sampleProfile)); // loadProfile
274
+ // loadActiveProfile → different profile active
275
+ return Promise.resolve(JSON.stringify({ profile: 'personal', switchedAt: '2026-04-11T18:00:00Z' }));
276
+ });
277
+
278
+ const { deleteProfileByName } = await import('../src/claude-profiles.js');
279
+ const result = await deleteProfileByName('work');
280
+
281
+ expect(result.success).toBe(true);
282
+ expect(result.wasActive).toBe(false);
283
+ expect(result.message).toBe('Deleted profile "work"');
284
+
285
+ // Should delete the profile file
286
+ expect(mockUnlink).toHaveBeenCalledWith(join(PROFILES_DIR, 'work.json'));
287
+ // Should NOT delete active.json
288
+ expect(mockUnlink).not.toHaveBeenCalledWith(ACTIVE_FILE);
289
+ });
290
+
291
+ it('clears active.json and warns when deleting active profile', async () => {
292
+ let readCallIdx = 0;
293
+ mockReadFile.mockImplementation(() => {
294
+ readCallIdx++;
295
+ if (readCallIdx === 1) return Promise.resolve(JSON.stringify(sampleProfile)); // loadProfile
296
+ // loadActiveProfile → work is active
297
+ return Promise.resolve(JSON.stringify({ profile: 'work', switchedAt: '2026-04-11T18:00:00Z' }));
298
+ });
299
+
300
+ const { deleteProfileByName } = await import('../src/claude-profiles.js');
301
+ const result = await deleteProfileByName('work');
302
+
303
+ expect(result.success).toBe(true);
304
+ expect(result.wasActive).toBe(true);
305
+ expect(result.message).toContain('was active');
306
+
307
+ // Should delete profile file AND active.json
308
+ expect(mockUnlink).toHaveBeenCalledWith(join(PROFILES_DIR, 'work.json'));
309
+ expect(mockUnlink).toHaveBeenCalledWith(ACTIVE_FILE);
310
+ });
311
+ });
312
+
313
+ describe('loginProfile', () => {
314
+ beforeEach(() => {
315
+ vi.resetAllMocks();
316
+ mockExecFileSync.mockReturnValue('/usr/bin/claude');
317
+ });
318
+
319
+ it('pre-populates --email from existing profile when profile already exists', async () => {
320
+ const existingProfile = { ...sampleProfile, email: 'user@work.com' };
321
+
322
+ // loginProfile read order:
323
+ // 1. readCredentialsFile() for backup → sampleCredentials
324
+ // 2. loadProfile('work') → existingProfile (has stored email)
325
+ // 3. readCredentialsFile() after login → sampleCredentials
326
+ let readCallIdx = 0;
327
+ mockReadFile.mockImplementation(() => {
328
+ readCallIdx++;
329
+ if (readCallIdx === 1) {
330
+ // readCredentialsFile for backup
331
+ return Promise.resolve(JSON.stringify(sampleCredentials));
332
+ }
333
+ if (readCallIdx === 2) {
334
+ // loadProfile('work') — profile exists with stored email
335
+ return Promise.resolve(JSON.stringify(existingProfile));
336
+ }
337
+ // readCredentialsFile after login
338
+ return Promise.resolve(JSON.stringify(sampleCredentials));
339
+ });
340
+
341
+ // Mock the interactive login spawn (exits with code 0)
342
+ const loginChild = {
343
+ on: vi.fn().mockImplementation((event: string, handler: Handler) => {
344
+ if (event === 'close') setTimeout(() => handler(0), 0);
345
+ return loginChild;
346
+ }),
347
+ kill: vi.fn(),
348
+ stdout: null,
349
+ stderr: null,
350
+ };
351
+
352
+ // Mock auth status spawn
353
+ mockSpawn.mockImplementationOnce(() => loginChild); // claude auth login
354
+ mockSpawn.mockImplementationOnce(() => createMockChild(authStatusOutput, 0)); // claude auth status
355
+
356
+ const { loginProfile } = await import('../src/claude-profiles.js');
357
+ const result = await loginProfile('work'); // no explicit email — should use profile's email
358
+
359
+ expect(result.success).toBe(true);
360
+
361
+ // claude auth login should have been called with --email from existing profile
362
+ const loginSpawnCall = mockSpawn.mock.calls[0]!;
363
+ expect(loginSpawnCall[0]).toBe('claude');
364
+ const loginArgs = loginSpawnCall[1] as string[];
365
+ expect(loginArgs).toContain('--email');
366
+ expect(loginArgs).toContain('user@work.com');
367
+ });
368
+
369
+ it('uses provided --email even when profile does not exist', async () => {
370
+ // loginProfile read order:
371
+ // 1. readCredentialsFile() for backup → ENOENT (no credentials yet)
372
+ // 2. loadProfile('newaccount') → ENOENT (no profile)
373
+ // 3. readCredentialsFile() after login → sampleCredentials
374
+ let readCallIdx = 0;
375
+ mockReadFile.mockImplementation(() => {
376
+ readCallIdx++;
377
+ if (readCallIdx === 1 || readCallIdx === 2) {
378
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
379
+ }
380
+ return Promise.resolve(JSON.stringify(sampleCredentials));
381
+ });
382
+
383
+ const loginChild = {
384
+ on: vi.fn().mockImplementation((event: string, handler: Handler) => {
385
+ if (event === 'close') setTimeout(() => handler(0), 0);
386
+ return loginChild;
387
+ }),
388
+ kill: vi.fn(),
389
+ stdout: null,
390
+ stderr: null,
391
+ };
392
+
393
+ mockSpawn.mockImplementationOnce(() => loginChild);
394
+ mockSpawn.mockImplementationOnce(() => createMockChild(authStatusOutput, 0));
395
+
396
+ const { loginProfile } = await import('../src/claude-profiles.js');
397
+ await loginProfile('newaccount', 'explicit@email.com');
398
+
399
+ const loginSpawnCall = mockSpawn.mock.calls[0]!;
400
+ const loginArgs = loginSpawnCall[1] as string[];
401
+ expect(loginArgs).toContain('--email');
402
+ expect(loginArgs).toContain('explicit@email.com');
403
+ });
404
+ });
405
+
406
+ describe('saveCurrentAsProfile', () => {
407
+ beforeEach(() => {
408
+ vi.resetAllMocks();
409
+ mockExecFileSync.mockReturnValue('/usr/bin/claude');
410
+ });
411
+
412
+ it('saves current credentials with auth status metadata', async () => {
413
+ mockReadFile.mockResolvedValue(JSON.stringify(sampleCredentials));
414
+ mockSpawn.mockReturnValue(createMockChild(authStatusOutput, 0));
415
+
416
+ const { saveCurrentAsProfile } = await import('../src/claude-profiles.js');
417
+ const result = await saveCurrentAsProfile('work');
418
+
419
+ expect(result.success).toBe(true);
420
+ expect(result.message).toContain('work');
421
+ expect(result.message).toContain('user@work.com');
422
+
423
+ // Should write the profile with metadata
424
+ const profileWrite = mockWriteFile.mock.calls.find((c) => {
425
+ try {
426
+ const parsed = JSON.parse(c[1] as string);
427
+ return parsed.name === 'work' && parsed.email === 'user@work.com';
428
+ } catch { return false; }
429
+ });
430
+ expect(profileWrite).toBeDefined();
431
+ });
432
+
433
+ it('returns error when no credentials file exists', async () => {
434
+ mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
435
+
436
+ const { saveCurrentAsProfile } = await import('../src/claude-profiles.js');
437
+ const result = await saveCurrentAsProfile('work');
438
+ expect(result.success).toBe(false);
439
+ expect(result.message).toContain('No credentials file');
440
+ });
441
+ });