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.
- package/__tests__/project-watcher.test.ts +199 -0
- package/__tests__/sync-resilience.test.ts +307 -0
- package/__tests__/usage-sync-watcher.test.ts +259 -0
- package/__tests__/usage-sync.test.ts +121 -0
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +18 -2
- package/dist/sync.js.map +1 -1
- package/dist/usage-sync.d.ts.map +1 -1
- package/dist/usage-sync.js +24 -5
- package/dist/usage-sync.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +30 -9
- package/dist/watcher.js.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.d.ts +5 -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 +5 -0
- package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.js.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/api/schemas/usage.d.ts +1 -0
- package/node_modules/@ultra-claude/shared/dist/api/schemas/usage.d.ts.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/index.d.ts +1 -1
- package/node_modules/@ultra-claude/shared/dist/index.d.ts.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/index.js +1 -1
- package/node_modules/@ultra-claude/shared/dist/index.js.map +1 -1
- package/package.json +6 -2
- package/src/sync.ts +23 -2
- package/src/usage-sync.ts +26 -5
- package/src/watcher.ts +28 -9
|
@@ -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
|
+
});
|