ultraclaude-agent 0.0.23 → 0.0.25

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,199 @@
1
+ // Integration test — real chokidar against tmp project dir. Regression
2
+ // guard for the project watcher's parent-directory watch pattern on
3
+ // .git/HEAD and .claude/ultra/version.json.
4
+ //
5
+ // Per the Lead amendment (shared/lead.md § Amendments 2026-04-12):
6
+ // these tests are regression guards, not failing-first reproductions.
7
+ // chokidar 5.x on Linux auto-re-registers inodes in `_handleFile`, so a
8
+ // simple multi-rename scenario passes against both unfixed and fixed code.
9
+ // The tests exist to ensure the parent-dir + basename-filter pattern
10
+ // correctly fires on atomic tmp+mv across multiple renames — verifying
11
+ // the fix doesn't introduce a new breakage.
12
+
13
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
14
+ import { mkdtemp, writeFile, rename, mkdir, rm } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ // --- Mock ./sync.js — observable surface for the handlers ---
19
+
20
+ const mockInitialSync = vi.fn();
21
+ const mockPushVersionMetadata = vi.fn();
22
+ const mockSyncFile = vi.fn();
23
+ const mockDeleteFiles = vi.fn();
24
+ const mockCreateSnapshot = vi.fn();
25
+ const mockHideBranches = vi.fn();
26
+
27
+ vi.mock('../src/sync.js', () => ({
28
+ syncFile: (...args: unknown[]) => mockSyncFile(...args),
29
+ deleteFiles: (...args: unknown[]) => mockDeleteFiles(...args),
30
+ initialSync: (...args: unknown[]) => mockInitialSync(...args),
31
+ createSnapshot: (...args: unknown[]) => mockCreateSnapshot(...args),
32
+ pushVersionMetadata: (...args: unknown[]) => mockPushVersionMetadata(...args),
33
+ hideBranches: (...args: unknown[]) => mockHideBranches(...args),
34
+ }));
35
+
36
+ // --- Mock logger ---
37
+
38
+ vi.mock('../src/logger.js', () => ({
39
+ logger: {
40
+ child: () => ({
41
+ info: vi.fn(),
42
+ warn: vi.fn(),
43
+ error: vi.fn(),
44
+ debug: vi.fn(),
45
+ }),
46
+ info: vi.fn(),
47
+ warn: vi.fn(),
48
+ error: vi.fn(),
49
+ debug: vi.fn(),
50
+ },
51
+ }));
52
+
53
+ describe('project watcher — atomic tmp+mv (real chokidar)', () => {
54
+ let tempDir: string;
55
+ let watcher: { close: () => Promise<void> } | null = null;
56
+
57
+ const testCredentials = {
58
+ apiKey: 'test-key',
59
+ userId: 'test-user',
60
+ serverUrl: 'http://localhost:3000',
61
+ };
62
+
63
+ beforeEach(async () => {
64
+ mockInitialSync.mockReset().mockResolvedValue(undefined);
65
+ mockPushVersionMetadata.mockReset().mockResolvedValue(undefined);
66
+ mockSyncFile.mockReset().mockResolvedValue(undefined);
67
+ mockDeleteFiles.mockReset().mockResolvedValue(undefined);
68
+ mockCreateSnapshot.mockReset().mockResolvedValue(true);
69
+ mockHideBranches.mockReset().mockResolvedValue(true);
70
+
71
+ tempDir = await mkdtemp(join(tmpdir(), 'project-watcher-test-'));
72
+
73
+ // Seed a minimal git repo layout
74
+ await mkdir(join(tempDir, '.git', 'refs', 'heads'), { recursive: true });
75
+ await writeFile(join(tempDir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
76
+ await writeFile(
77
+ join(tempDir, '.git', 'refs', 'heads', 'main'),
78
+ '0000000000000000000000000000000000000000\n',
79
+ );
80
+
81
+ // Seed Ultra Claude layout
82
+ await mkdir(join(tempDir, '.claude', 'ultra'), { recursive: true });
83
+ await writeFile(
84
+ join(tempDir, '.claude', 'ultra', 'version.json'),
85
+ JSON.stringify({ version: '1.0.0' }),
86
+ );
87
+
88
+ // Seed documentation/ dir so docWatcher has something to watch
89
+ await mkdir(join(tempDir, 'documentation'), { recursive: true });
90
+
91
+ // Fresh module load per test
92
+ vi.resetModules();
93
+ const { startProjectWatcher } = await import('../src/watcher.js');
94
+ watcher = startProjectWatcher({
95
+ projectId: 'test-project',
96
+ projectPath: tempDir,
97
+ credentials: testCredentials,
98
+ });
99
+
100
+ // Give chokidar time to reach ready state
101
+ await new Promise((r) => setTimeout(r, 300));
102
+ });
103
+
104
+ afterEach(async () => {
105
+ if (watcher) await watcher.close();
106
+ watcher = null;
107
+ await rm(tempDir, { recursive: true, force: true });
108
+ });
109
+
110
+ it('fires initialSync on repeated atomic tmp+mv of .git/HEAD (branch switch simulation)', async () => {
111
+ // Reproduction: repeated atomic tmp+mv of .git/HEAD (as `git checkout` does).
112
+ // On the unfixed single-file watch, the inode swap orphans chokidar's
113
+ // internal watch after the first rename; subsequent checkouts are silent.
114
+ const headPath = join(tempDir, '.git', 'HEAD');
115
+ await mkdir(join(tempDir, '.git', 'refs', 'heads'), { recursive: true });
116
+ await writeFile(
117
+ join(tempDir, '.git', 'refs', 'heads', 'feature'),
118
+ '1111111111111111111111111111111111111111\n',
119
+ );
120
+ await writeFile(
121
+ join(tempDir, '.git', 'refs', 'heads', 'develop'),
122
+ '2222222222222222222222222222222222222222\n',
123
+ );
124
+
125
+ const checkoutAtomic = async (branch: string, label: string) => {
126
+ const tmpPath = `${headPath}.${label}.tmp`;
127
+ await writeFile(tmpPath, `ref: refs/heads/${branch}\n`);
128
+ await rename(tmpPath, headPath);
129
+ };
130
+
131
+ await checkoutAtomic('feature', 'a');
132
+ await vi.waitFor(
133
+ () => {
134
+ expect(mockInitialSync).toHaveBeenCalledTimes(1);
135
+ },
136
+ { timeout: 4000, interval: 100 },
137
+ );
138
+
139
+ await new Promise((r) => setTimeout(r, 500));
140
+
141
+ await checkoutAtomic('develop', 'b');
142
+ await vi.waitFor(
143
+ () => {
144
+ expect(mockInitialSync).toHaveBeenCalledTimes(2);
145
+ },
146
+ { timeout: 4000, interval: 100 },
147
+ );
148
+
149
+ await new Promise((r) => setTimeout(r, 500));
150
+
151
+ await checkoutAtomic('main', 'c');
152
+ await vi.waitFor(
153
+ () => {
154
+ expect(mockInitialSync).toHaveBeenCalledTimes(3);
155
+ },
156
+ { timeout: 4000, interval: 100 },
157
+ );
158
+ });
159
+
160
+ it('fires pushVersionMetadata on repeated atomic tmp+mv of .claude/ultra/version.json', async () => {
161
+ // Reproduction: repeated atomic tmp+mv. The awaitWriteFinish setting
162
+ // means we need to space them past the stability threshold.
163
+ const versionPath = join(tempDir, '.claude', 'ultra', 'version.json');
164
+
165
+ const bumpAtomic = async (version: string, label: string) => {
166
+ const tmpPath = `${versionPath}.${label}.tmp`;
167
+ await writeFile(tmpPath, JSON.stringify({ version }));
168
+ await rename(tmpPath, versionPath);
169
+ };
170
+
171
+ await bumpAtomic('2.0.0', 'a');
172
+ await vi.waitFor(
173
+ () => {
174
+ expect(mockPushVersionMetadata).toHaveBeenCalledTimes(1);
175
+ },
176
+ { timeout: 4000, interval: 100 },
177
+ );
178
+
179
+ await new Promise((r) => setTimeout(r, 500));
180
+
181
+ await bumpAtomic('2.1.0', 'b');
182
+ await vi.waitFor(
183
+ () => {
184
+ expect(mockPushVersionMetadata).toHaveBeenCalledTimes(2);
185
+ },
186
+ { timeout: 4000, interval: 100 },
187
+ );
188
+
189
+ await new Promise((r) => setTimeout(r, 500));
190
+
191
+ await bumpAtomic('2.2.0', 'c');
192
+ await vi.waitFor(
193
+ () => {
194
+ expect(mockPushVersionMetadata).toHaveBeenCalledTimes(3);
195
+ },
196
+ { timeout: 4000, interval: 100 },
197
+ );
198
+ });
199
+ });
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Tests for sync resilience fixes:
3
+ * Fix A — apiRequest() times out after 15s via AbortController
4
+ * Fix C — flushQueue() retains unprocessed items after network error break
5
+ * Fix D — initialSync() runs file syncs concurrently at limit 10
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
+ import { join } from 'node:path';
10
+ import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
11
+ import { tmpdir } from 'node:os';
12
+
13
+ // Mock fetch globally
14
+ const mockFetch = vi.fn();
15
+ vi.stubGlobal('fetch', mockFetch);
16
+
17
+ // Mock config module
18
+ vi.mock('../src/config.js', () => ({
19
+ loadCredentials: vi.fn().mockResolvedValue({
20
+ apiKey: 'test-api-key',
21
+ userId: 'test-user',
22
+ serverUrl: 'http://localhost:3000',
23
+ }),
24
+ getServerUrl: vi.fn().mockReturnValue('http://localhost:3000'),
25
+ getProjectId: vi.fn().mockResolvedValue('test-project-id'),
26
+ writeProjectId: vi.fn().mockResolvedValue(undefined),
27
+ paths: {
28
+ claudeProjects: '/tmp/test-claude-projects',
29
+ projectIdFile: '.claude/ultra/project-id',
30
+ oldConfigDir: '/tmp/test-old-config',
31
+ },
32
+ }));
33
+
34
+ // Mock logger
35
+ vi.mock('../src/logger.js', () => ({
36
+ logger: {
37
+ child: () => ({
38
+ info: vi.fn(),
39
+ warn: vi.fn(),
40
+ error: vi.fn(),
41
+ debug: vi.fn(),
42
+ }),
43
+ info: vi.fn(),
44
+ warn: vi.fn(),
45
+ error: vi.fn(),
46
+ debug: vi.fn(),
47
+ },
48
+ }));
49
+
50
+ const testCreds = { apiKey: 'test-api-key', userId: 'test-user', serverUrl: 'http://localhost:3000' };
51
+
52
+ // --- Fix A: apiRequest timeout ---
53
+
54
+ describe('sync — apiRequest timeout (Fix A)', () => {
55
+ beforeEach(() => {
56
+ mockFetch.mockReset();
57
+ vi.useFakeTimers();
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.useRealTimers();
62
+ });
63
+
64
+ it('returns NETWORK_ERROR when fetch hangs past 15 seconds', async () => {
65
+ vi.resetModules();
66
+ const { apiRequest } = await import('../src/sync.js');
67
+
68
+ // fetch that hangs until aborted — simulates a stalled connection
69
+ mockFetch.mockImplementation((_url: string, options: RequestInit) => {
70
+ return new Promise((_resolve, reject) => {
71
+ if (options?.signal) {
72
+ options.signal.addEventListener('abort', () => {
73
+ reject(new DOMException('The operation was aborted', 'AbortError'));
74
+ });
75
+ }
76
+ });
77
+ });
78
+
79
+ const resultPromise = apiRequest('GET', '/api/test', undefined, testCreds);
80
+
81
+ // Advance past the 15s timeout
82
+ await vi.advanceTimersByTimeAsync(15_001);
83
+
84
+ const result = await resultPromise;
85
+ expect(result.success).toBe(false);
86
+ if (!result.success) {
87
+ expect(result.error).toBe('NETWORK_ERROR');
88
+ }
89
+ });
90
+
91
+ it('succeeds when fetch resolves before timeout', async () => {
92
+ vi.resetModules();
93
+ const { apiRequest } = await import('../src/sync.js');
94
+
95
+ mockFetch.mockResolvedValue({
96
+ ok: true,
97
+ status: 200,
98
+ json: async () => ({ data: {} }),
99
+ });
100
+
101
+ const resultPromise = apiRequest('GET', '/api/test', undefined, testCreds);
102
+
103
+ // Advance a small amount (fetch resolves immediately with mock)
104
+ await vi.advanceTimersByTimeAsync(10);
105
+
106
+ const result = await resultPromise;
107
+ expect(result.success).toBe(true);
108
+ });
109
+
110
+ it('passes AbortController signal to fetch', async () => {
111
+ vi.resetModules();
112
+ const { apiRequest } = await import('../src/sync.js');
113
+
114
+ mockFetch.mockResolvedValue({ ok: true, status: 200 });
115
+
116
+ await apiRequest('POST', '/api/test', { data: 1 }, testCreds);
117
+
118
+ expect(mockFetch).toHaveBeenCalledOnce();
119
+ const fetchOptions = mockFetch.mock.calls[0]![1] as RequestInit;
120
+ expect(fetchOptions.signal).toBeInstanceOf(AbortSignal);
121
+ });
122
+ });
123
+
124
+ // --- Fix C: flushQueue retains unprocessed items ---
125
+
126
+ describe('sync — flushQueue retains unprocessed items (Fix C)', () => {
127
+ let tempDir: string;
128
+
129
+ beforeEach(async () => {
130
+ mockFetch.mockReset();
131
+ vi.useFakeTimers();
132
+ tempDir = await mkdtemp(join(tmpdir(), 'agent-flush-test-'));
133
+ await mkdir(join(tempDir, 'documentation'), { recursive: true });
134
+ });
135
+
136
+ afterEach(async () => {
137
+ vi.useRealTimers();
138
+ await rm(tempDir, { recursive: true, force: true });
139
+ });
140
+
141
+ it('retains all unprocessed items after first network failure', async () => {
142
+ vi.resetModules();
143
+ const { syncMarkdownFile, getQueueSize, stopSync } = await import('../src/sync.js');
144
+
145
+ // Create 5 unique files and enqueue them all (all fail initially)
146
+ mockFetch.mockRejectedValue(new Error('Server down'));
147
+
148
+ const files: string[] = [];
149
+ for (let i = 0; i < 5; i++) {
150
+ const fp = join(tempDir, 'documentation', `file${i}.md`);
151
+ await writeFile(fp, `# Heading ${i}\n\nContent ${i}.`);
152
+ files.push(fp);
153
+ await syncMarkdownFile('test-project', tempDir, fp, testCreds, 'main');
154
+ }
155
+
156
+ expect(getQueueSize()).toBe(5);
157
+
158
+ // Server comes back but is flaky — first item succeeds, second fails with network error
159
+ let callCount = 0;
160
+ mockFetch.mockImplementation(() => {
161
+ callCount++;
162
+ if (callCount === 1) {
163
+ // First item succeeds
164
+ return Promise.resolve({
165
+ ok: true,
166
+ status: 200,
167
+ json: async () => ({ data: { upserted: 1 } }),
168
+ });
169
+ }
170
+ // Second item: network error — should break and retain remaining
171
+ return Promise.reject(new Error('Network error'));
172
+ });
173
+
174
+ // Trigger flush via retry timer
175
+ await vi.advanceTimersByTimeAsync(5_001);
176
+
177
+ // Item 1 succeeded, item 2 failed (retained), items 3-5 never attempted (retained)
178
+ // Total remaining = 4 (failed item + 3 unprocessed)
179
+ expect(getQueueSize()).toBe(4);
180
+
181
+ stopSync();
182
+ });
183
+
184
+ it('retains unprocessed items when first item fails', async () => {
185
+ vi.resetModules();
186
+ const { syncMarkdownFile, getQueueSize, stopSync } = await import('../src/sync.js');
187
+
188
+ // Enqueue 3 items (all fail initially)
189
+ mockFetch.mockRejectedValue(new Error('Server down'));
190
+
191
+ for (let i = 0; i < 3; i++) {
192
+ const fp = join(tempDir, 'documentation', `item${i}.md`);
193
+ await writeFile(fp, `# Item ${i}\n\nContent.`);
194
+ await syncMarkdownFile('test-project', tempDir, fp, testCreds, 'main');
195
+ }
196
+
197
+ expect(getQueueSize()).toBe(3);
198
+
199
+ // First flush attempt — first item fails immediately
200
+ mockFetch.mockRejectedValue(new Error('Still down'));
201
+
202
+ await vi.advanceTimersByTimeAsync(5_001);
203
+
204
+ // All 3 items should be retained (1 failed + 2 unprocessed)
205
+ expect(getQueueSize()).toBe(3);
206
+
207
+ stopSync();
208
+ });
209
+ });
210
+
211
+ // --- Fix D: initialSync concurrency ---
212
+
213
+ describe('sync — initialSync concurrency (Fix D)', () => {
214
+ let tempDir: string;
215
+
216
+ beforeEach(async () => {
217
+ mockFetch.mockReset();
218
+ tempDir = await mkdtemp(join(tmpdir(), 'agent-initial-sync-'));
219
+ await mkdir(join(tempDir, 'documentation'), { recursive: true });
220
+ });
221
+
222
+ afterEach(async () => {
223
+ await rm(tempDir, { recursive: true, force: true });
224
+ });
225
+
226
+ it('syncs files concurrently with p-limit at concurrency 10', async () => {
227
+ vi.resetModules();
228
+
229
+ // Create 15 markdown files to sync
230
+ for (let i = 0; i < 15; i++) {
231
+ const subdir = join(tempDir, 'documentation', `dir${i}`);
232
+ await mkdir(subdir, { recursive: true });
233
+ await writeFile(join(subdir, `file${i}.md`), `# File ${i}\n\nContent ${i}.`);
234
+ }
235
+
236
+ // Track concurrency: how many sync requests are in-flight at once
237
+ let activeCalls = 0;
238
+ let maxConcurrent = 0;
239
+
240
+ // Mock manifest fetch (first call)
241
+ mockFetch.mockImplementation(async (url: string) => {
242
+ if ((url as string).includes('/api/sync/manifest')) {
243
+ return { ok: true, json: async () => ({ data: { files: {} } }) };
244
+ }
245
+ if ((url as string).includes('/api/sync/reconcile')) {
246
+ return { ok: true, json: async () => ({ data: { prunedFiles: 0, prunedPlans: 0, prunedBacklogItems: 0 } }) };
247
+ }
248
+
249
+ // Sync requests — track concurrency
250
+ activeCalls++;
251
+ maxConcurrent = Math.max(maxConcurrent, activeCalls);
252
+
253
+ // Simulate some async work
254
+ await new Promise((resolve) => setTimeout(resolve, 10));
255
+
256
+ activeCalls--;
257
+ return { ok: true, json: async () => ({ data: { upserted: 1 } }) };
258
+ });
259
+
260
+ const { initialSync } = await import('../src/sync.js');
261
+ await initialSync('test-project', tempDir, testCreds, 'main');
262
+
263
+ // Verify concurrency was bounded at 10
264
+ expect(maxConcurrent).toBeLessThanOrEqual(10);
265
+ // Verify multiple files were synced (some concurrently)
266
+ expect(maxConcurrent).toBeGreaterThan(1);
267
+
268
+ // Verify all files were synced — count sync POST calls (sections + reconcile)
269
+ const syncCalls = mockFetch.mock.calls.filter(
270
+ (call: unknown[]) => (call[0] as string).includes('/api/sync/sections'),
271
+ );
272
+ expect(syncCalls.length).toBe(15);
273
+ });
274
+
275
+ it('uses Promise.allSettled so one file failure does not abort others', async () => {
276
+ vi.resetModules();
277
+
278
+ // Create 3 files
279
+ for (let i = 0; i < 3; i++) {
280
+ await writeFile(join(tempDir, 'documentation', `f${i}.md`), `# F${i}\n\nContent.`);
281
+ }
282
+
283
+ let syncCallCount = 0;
284
+ mockFetch.mockImplementation(async (url: string) => {
285
+ if ((url as string).includes('/api/sync/manifest')) {
286
+ return { ok: true, json: async () => ({ data: { files: {} } }) };
287
+ }
288
+ if ((url as string).includes('/api/sync/reconcile')) {
289
+ return { ok: true, json: async () => ({ data: { prunedFiles: 0, prunedPlans: 0, prunedBacklogItems: 0 } }) };
290
+ }
291
+ syncCallCount++;
292
+ // Second sync call fails
293
+ if (syncCallCount === 2) {
294
+ return { ok: false, status: 500 };
295
+ }
296
+ return { ok: true, json: async () => ({ data: { upserted: 1 } }) };
297
+ });
298
+
299
+ const { initialSync } = await import('../src/sync.js');
300
+
301
+ // Should NOT throw — allSettled absorbs individual rejections
302
+ await expect(initialSync('test-project', tempDir, testCreds, 'main')).resolves.toBeUndefined();
303
+
304
+ // All 3 files should have been attempted despite the failure of the second
305
+ expect(syncCallCount).toBe(3);
306
+ });
307
+ });