ultraclaude-agent 0.0.17 → 0.0.19

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 (62) hide show
  1. package/__tests__/daemon-reconcile.test.ts +9 -0
  2. package/__tests__/daemon.test.ts +9 -0
  3. package/__tests__/pid-detection.test.ts +68 -0
  4. package/__tests__/socket-client.test.ts +210 -0
  5. package/__tests__/sync-reorder.test.ts +242 -0
  6. package/__tests__/usage-sync.test.ts +8 -0
  7. package/__tests__/version-watcher.test.ts +286 -0
  8. package/dist/cli.js +141 -14
  9. package/dist/cli.js.map +1 -1
  10. package/dist/config.d.ts +21 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +65 -1
  13. package/dist/config.js.map +1 -1
  14. package/dist/daemon.d.ts +4 -1
  15. package/dist/daemon.d.ts.map +1 -1
  16. package/dist/daemon.js +113 -5
  17. package/dist/daemon.js.map +1 -1
  18. package/dist/logger.d.ts +7 -0
  19. package/dist/logger.d.ts.map +1 -1
  20. package/dist/logger.js +35 -0
  21. package/dist/logger.js.map +1 -1
  22. package/dist/repl.d.ts.map +1 -1
  23. package/dist/repl.js +15 -9
  24. package/dist/repl.js.map +1 -1
  25. package/dist/scripts/postinstall.d.ts +2 -0
  26. package/dist/scripts/postinstall.d.ts.map +1 -0
  27. package/dist/scripts/postinstall.js +174 -0
  28. package/dist/scripts/postinstall.js.map +1 -0
  29. package/dist/service.d.ts +14 -0
  30. package/dist/service.d.ts.map +1 -1
  31. package/dist/service.js +54 -1
  32. package/dist/service.js.map +1 -1
  33. package/dist/setup.d.ts.map +1 -1
  34. package/dist/setup.js +11 -2
  35. package/dist/setup.js.map +1 -1
  36. package/dist/socket-client.d.ts +27 -0
  37. package/dist/socket-client.d.ts.map +1 -0
  38. package/dist/socket-client.js +84 -0
  39. package/dist/socket-client.js.map +1 -0
  40. package/dist/status.d.ts +1 -0
  41. package/dist/status.d.ts.map +1 -1
  42. package/dist/status.js.map +1 -1
  43. package/dist/sync.d.ts.map +1 -1
  44. package/dist/sync.js +15 -5
  45. package/dist/sync.js.map +1 -1
  46. package/dist/watcher.d.ts +2 -0
  47. package/dist/watcher.d.ts.map +1 -1
  48. package/dist/watcher.js +5 -1
  49. package/dist/watcher.js.map +1 -1
  50. package/package.json +3 -1
  51. package/src/cli.ts +147 -13
  52. package/src/config.ts +64 -1
  53. package/src/daemon.ts +130 -4
  54. package/src/logger.ts +39 -0
  55. package/src/repl.ts +15 -8
  56. package/src/scripts/postinstall.ts +179 -0
  57. package/src/service.ts +49 -1
  58. package/src/setup.ts +12 -2
  59. package/src/socket-client.ts +98 -0
  60. package/src/status.ts +1 -0
  61. package/src/sync.ts +18 -5
  62. package/src/watcher.ts +7 -1
@@ -86,6 +86,15 @@ vi.mock('../src/usage-sync.js', () => ({
86
86
  }),
87
87
  }));
88
88
 
89
+ // Mock socket-client
90
+ vi.mock('../src/socket-client.js', () => ({
91
+ init: vi.fn(),
92
+ addAccount: vi.fn(),
93
+ removeAccount: vi.fn(),
94
+ disconnectAll: vi.fn(),
95
+ getConnectedUserIds: vi.fn().mockReturnValue([]),
96
+ }));
97
+
89
98
  // Mock logger
90
99
  vi.mock('../src/logger.js', () => ({
91
100
  logger: {
@@ -84,6 +84,15 @@ vi.mock('../src/usage-sync.js', () => ({
84
84
  }),
85
85
  }));
86
86
 
87
+ // Mock socket-client
88
+ vi.mock('../src/socket-client.js', () => ({
89
+ init: vi.fn(),
90
+ addAccount: vi.fn(),
91
+ removeAccount: vi.fn(),
92
+ disconnectAll: vi.fn(),
93
+ getConnectedUserIds: vi.fn().mockReturnValue([]),
94
+ }));
95
+
87
96
  // Mock logger
88
97
  vi.mock('../src/logger.js', () => ({
89
98
  logger: {
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { spawn } from 'node:child_process';
3
+ import { isDaemonRunning, isPidAlive, verifyProcessIdentity, cleanStalePidFile } from '../src/config.js';
4
+
5
+ // Mock removePid (called internally by cleanStalePidFile)
6
+ vi.mock('node:fs/promises', async () => {
7
+ const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
8
+ return {
9
+ ...actual,
10
+ unlink: vi.fn().mockResolvedValue(undefined),
11
+ };
12
+ });
13
+
14
+ describe('Process identity verification', () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ it('isDaemonRunning returns false for a dead PID', () => {
20
+ // Use a PID that's almost certainly not running (very high number)
21
+ const result = isDaemonRunning(99999999);
22
+ expect(result).toBe(false);
23
+ });
24
+
25
+ it('isPidAlive returns true for the current process', () => {
26
+ const result = isPidAlive(process.pid);
27
+ expect(result).toBe(true);
28
+ });
29
+
30
+ it('isDaemonRunning returns false for a live non-agent process', async () => {
31
+ // Spawn a simple sleep process — definitely not ultraclaude-agent
32
+ const child = spawn('sleep', ['30'], { stdio: 'ignore' });
33
+
34
+ try {
35
+ expect(child.pid).toBeDefined();
36
+ const pid = child.pid!;
37
+
38
+ // PID should be alive
39
+ expect(isPidAlive(pid)).toBe(true);
40
+
41
+ // But isDaemonRunning should return false (not our daemon)
42
+ expect(isDaemonRunning(pid)).toBe(false);
43
+
44
+ // verifyProcessIdentity should also return false
45
+ expect(verifyProcessIdentity(pid)).toBe(false);
46
+ } finally {
47
+ child.kill('SIGTERM');
48
+ }
49
+ });
50
+
51
+ it('verifyProcessIdentity returns false for a dead PID', () => {
52
+ expect(verifyProcessIdentity(99999999)).toBe(false);
53
+ });
54
+
55
+ describe('cleanStalePidFile', () => {
56
+ it('logs a warning about stale PID', async () => {
57
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
58
+
59
+ await cleanStalePidFile('https://test.example.com', 12345);
60
+
61
+ expect(warnSpy).toHaveBeenCalledWith(
62
+ expect.stringContaining('PID 12345 is alive but is not ultraclaude-agent'),
63
+ );
64
+
65
+ warnSpy.mockRestore();
66
+ });
67
+ });
68
+ });
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock socket.io-client
4
+ const mockOn = vi.fn().mockReturnThis();
5
+ const mockDisconnect = vi.fn().mockReturnThis();
6
+ const mockSocket = {
7
+ on: mockOn,
8
+ disconnect: mockDisconnect,
9
+ id: 'mock-socket-id',
10
+ connected: true,
11
+ };
12
+ const mockIo = vi.fn().mockReturnValue(mockSocket);
13
+
14
+ vi.mock('socket.io-client', () => ({
15
+ io: (...args: unknown[]) => mockIo(...args),
16
+ }));
17
+
18
+ // Mock logger
19
+ vi.mock('../src/logger.js', () => ({
20
+ logger: {
21
+ child: () => ({
22
+ info: vi.fn(),
23
+ warn: vi.fn(),
24
+ error: vi.fn(),
25
+ debug: vi.fn(),
26
+ }),
27
+ info: vi.fn(),
28
+ warn: vi.fn(),
29
+ error: vi.fn(),
30
+ debug: vi.fn(),
31
+ },
32
+ }));
33
+
34
+ describe('socket-client', () => {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ // Each test gets a fresh socket mock
38
+ mockIo.mockReturnValue({
39
+ on: mockOn,
40
+ disconnect: mockDisconnect,
41
+ id: 'mock-socket-id',
42
+ connected: true,
43
+ });
44
+ });
45
+
46
+ // Re-import module fresh for each describe block to reset module state
47
+ async function loadModule() {
48
+ vi.resetModules();
49
+ return import('../src/socket-client.js');
50
+ }
51
+
52
+ describe('init', () => {
53
+ it('creates one socket per credential entry', async () => {
54
+ const mod = await loadModule();
55
+ const creds = new Map([
56
+ ['user-1', { apiKey: 'key-1' }],
57
+ ['user-2', { apiKey: 'key-2' }],
58
+ ]);
59
+
60
+ mod.init('http://localhost:3000', creds);
61
+
62
+ expect(mockIo).toHaveBeenCalledTimes(2);
63
+ expect(mockIo).toHaveBeenCalledWith('http://localhost:3000', {
64
+ transports: ['websocket'],
65
+ path: '/socket.io',
66
+ auth: { token: 'key-1' },
67
+ forceNew: true,
68
+ });
69
+ expect(mockIo).toHaveBeenCalledWith('http://localhost:3000', {
70
+ transports: ['websocket'],
71
+ path: '/socket.io',
72
+ auth: { token: 'key-2' },
73
+ forceNew: true,
74
+ });
75
+ });
76
+
77
+ it('is idempotent — disconnects existing sockets before creating new ones', async () => {
78
+ const mod = await loadModule();
79
+
80
+ // First init
81
+ mod.init('http://localhost:3000', new Map([['user-1', { apiKey: 'key-1' }]]));
82
+ expect(mockIo).toHaveBeenCalledTimes(1);
83
+
84
+ // Second init — should disconnect first socket, then create new one
85
+ mod.init('http://localhost:3000', new Map([['user-1', { apiKey: 'key-new' }]]));
86
+ expect(mockDisconnect).toHaveBeenCalled();
87
+ expect(mockIo).toHaveBeenCalledTimes(2);
88
+ });
89
+
90
+ it('registers connect, disconnect, and connect_error handlers', async () => {
91
+ const mod = await loadModule();
92
+ mod.init('http://localhost:3000', new Map([['user-1', { apiKey: 'key-1' }]]));
93
+
94
+ const registeredEvents = mockOn.mock.calls.map((call: unknown[]) => call[0]);
95
+ expect(registeredEvents).toContain('connect');
96
+ expect(registeredEvents).toContain('disconnect');
97
+ expect(registeredEvents).toContain('connect_error');
98
+ });
99
+ });
100
+
101
+ describe('addAccount', () => {
102
+ it('creates a new connection', async () => {
103
+ const mod = await loadModule();
104
+ mod.addAccount('http://localhost:3000', 'user-1', 'key-1');
105
+
106
+ expect(mockIo).toHaveBeenCalledTimes(1);
107
+ expect(mockIo).toHaveBeenCalledWith('http://localhost:3000', {
108
+ transports: ['websocket'],
109
+ path: '/socket.io',
110
+ auth: { token: 'key-1' },
111
+ forceNew: true,
112
+ });
113
+ expect(mod.getConnectedUserIds()).toEqual(['user-1']);
114
+ });
115
+
116
+ it('disconnects existing socket before replacing with new one', async () => {
117
+ const firstSocket = {
118
+ on: vi.fn().mockReturnThis(),
119
+ disconnect: vi.fn().mockReturnThis(),
120
+ id: 'first-socket',
121
+ connected: true,
122
+ };
123
+ const secondSocket = {
124
+ on: vi.fn().mockReturnThis(),
125
+ disconnect: vi.fn().mockReturnThis(),
126
+ id: 'second-socket',
127
+ connected: true,
128
+ };
129
+ mockIo.mockReturnValueOnce(firstSocket).mockReturnValueOnce(secondSocket);
130
+
131
+ const mod = await loadModule();
132
+ mod.addAccount('http://localhost:3000', 'user-1', 'key-1');
133
+ mod.addAccount('http://localhost:3000', 'user-1', 'key-new');
134
+
135
+ expect(firstSocket.disconnect).toHaveBeenCalled();
136
+ expect(mockIo).toHaveBeenCalledTimes(2);
137
+ expect(mod.getConnectedUserIds()).toEqual(['user-1']);
138
+ });
139
+ });
140
+
141
+ describe('removeAccount', () => {
142
+ it('disconnects and removes socket', async () => {
143
+ const socket = {
144
+ on: vi.fn().mockReturnThis(),
145
+ disconnect: vi.fn().mockReturnThis(),
146
+ id: 'socket-1',
147
+ connected: true,
148
+ };
149
+ mockIo.mockReturnValue(socket);
150
+
151
+ const mod = await loadModule();
152
+ mod.addAccount('http://localhost:3000', 'user-1', 'key-1');
153
+ expect(mod.getConnectedUserIds()).toEqual(['user-1']);
154
+
155
+ mod.removeAccount('user-1');
156
+ expect(socket.disconnect).toHaveBeenCalled();
157
+ expect(mod.getConnectedUserIds()).toEqual([]);
158
+ });
159
+
160
+ it('is a no-op for unknown userId', async () => {
161
+ const mod = await loadModule();
162
+ mod.removeAccount('nonexistent');
163
+ expect(mod.getConnectedUserIds()).toEqual([]);
164
+ });
165
+ });
166
+
167
+ describe('disconnectAll', () => {
168
+ it('disconnects all sockets and clears the map', async () => {
169
+ const socket1 = {
170
+ on: vi.fn().mockReturnThis(),
171
+ disconnect: vi.fn().mockReturnThis(),
172
+ id: 'socket-1',
173
+ };
174
+ const socket2 = {
175
+ on: vi.fn().mockReturnThis(),
176
+ disconnect: vi.fn().mockReturnThis(),
177
+ id: 'socket-2',
178
+ };
179
+ mockIo.mockReturnValueOnce(socket1).mockReturnValueOnce(socket2);
180
+
181
+ const mod = await loadModule();
182
+ mod.addAccount('http://localhost:3000', 'user-1', 'key-1');
183
+ mod.addAccount('http://localhost:3000', 'user-2', 'key-2');
184
+ expect(mod.getConnectedUserIds()).toHaveLength(2);
185
+
186
+ mod.disconnectAll();
187
+ expect(socket1.disconnect).toHaveBeenCalled();
188
+ expect(socket2.disconnect).toHaveBeenCalled();
189
+ expect(mod.getConnectedUserIds()).toEqual([]);
190
+ });
191
+ });
192
+
193
+ describe('getConnectedUserIds', () => {
194
+ it('returns empty array when no sockets exist', async () => {
195
+ const mod = await loadModule();
196
+ expect(mod.getConnectedUserIds()).toEqual([]);
197
+ });
198
+
199
+ it('returns all user IDs with active socket entries', async () => {
200
+ const mod = await loadModule();
201
+ mod.addAccount('http://localhost:3000', 'user-1', 'key-1');
202
+ mod.addAccount('http://localhost:3000', 'user-2', 'key-2');
203
+
204
+ const ids = mod.getConnectedUserIds();
205
+ expect(ids).toHaveLength(2);
206
+ expect(ids).toContain('user-1');
207
+ expect(ids).toContain('user-2');
208
+ });
209
+ });
210
+ });
@@ -0,0 +1,242 @@
1
+ /**
2
+ * B-001 regression test: Section reordering triggers sync push with updated positions.
3
+ *
4
+ * When sections are reordered in a markdown file (positions change but content stays
5
+ * the same), the diff logic must detect the position change and push the updated sections.
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
+ describe('B-001 regression — section reordering triggers sync push', () => {
53
+ let tempDir: string;
54
+
55
+ beforeEach(async () => {
56
+ vi.resetModules();
57
+ mockFetch.mockReset();
58
+ tempDir = await mkdtemp(join(tmpdir(), 'agent-reorder-test-'));
59
+ await mkdir(join(tempDir, 'documentation'), { recursive: true });
60
+ });
61
+
62
+ afterEach(async () => {
63
+ await rm(tempDir, { recursive: true, force: true });
64
+ });
65
+
66
+ it('pushes sections with updated positions when sections are reordered', async () => {
67
+ const { syncMarkdownFile } = await import('../src/sync.js');
68
+
69
+ const filePath = join(tempDir, 'documentation', 'test.md');
70
+
71
+ // Step 1: Initial push with sections A, B, C at positions 0, 1, 2
72
+ const initialContent = '# A\n\nContent A.\n\n# B\n\nContent B.\n\n# C\n\nContent C.';
73
+ await writeFile(filePath, initialContent);
74
+
75
+ // First call pushes all sections (cache is empty)
76
+ mockFetch.mockResolvedValueOnce({
77
+ ok: true,
78
+ json: async () => ({ data: { upserted: 3 } }),
79
+ });
80
+
81
+ const firstCount = await syncMarkdownFile('test-project-id', tempDir, filePath, testCreds);
82
+ expect(firstCount).toBe(3);
83
+
84
+ // Verify positions in first push
85
+ const firstPostCall = mockFetch.mock.calls.find(
86
+ (call: unknown[]) =>
87
+ (call[1] as { method: string }).method === 'POST' &&
88
+ (call[0] as string).includes('/api/sync/sections'),
89
+ );
90
+ expect(firstPostCall).toBeDefined();
91
+ const firstBody = JSON.parse((firstPostCall![1] as { body: string }).body);
92
+ expect(firstBody.sections).toHaveLength(3);
93
+ expect(firstBody.sections[0].slug).toBe('a');
94
+ expect(firstBody.sections[0].position).toBe(0);
95
+ expect(firstBody.sections[1].slug).toBe('b');
96
+ expect(firstBody.sections[1].position).toBe(1);
97
+ expect(firstBody.sections[2].slug).toBe('c');
98
+ expect(firstBody.sections[2].position).toBe(2);
99
+
100
+ mockFetch.mockReset();
101
+
102
+ // Step 2: Reorder sections to B, A, C (same content, different positions)
103
+ const reorderedContent = '# B\n\nContent B.\n\n# A\n\nContent A.\n\n# C\n\nContent C.';
104
+ await writeFile(filePath, reorderedContent);
105
+
106
+ mockFetch.mockResolvedValueOnce({
107
+ ok: true,
108
+ json: async () => ({ data: { upserted: 2 } }),
109
+ });
110
+
111
+ const secondCount = await syncMarkdownFile('test-project-id', tempDir, filePath, testCreds);
112
+
113
+ // Step 3: Assert the second call pushes sections with updated positions
114
+ // B moved from position 1 → 0, A moved from position 0 → 1
115
+ // C stayed at position 2 — should NOT be pushed
116
+ expect(secondCount).toBe(2);
117
+
118
+ const secondPostCall = mockFetch.mock.calls.find(
119
+ (call: unknown[]) =>
120
+ (call[1] as { method: string }).method === 'POST' &&
121
+ (call[0] as string).includes('/api/sync/sections'),
122
+ );
123
+ expect(secondPostCall).toBeDefined();
124
+ const secondBody = JSON.parse((secondPostCall![1] as { body: string }).body);
125
+
126
+ // Only A and B should be pushed (position changed), not C
127
+ expect(secondBody.sections).toHaveLength(2);
128
+
129
+ const pushedSlugs = secondBody.sections.map((s: { slug: string }) => s.slug).sort();
130
+ expect(pushedSlugs).toEqual(['a', 'b']);
131
+
132
+ // Verify the pushed positions are correct
133
+ const sectionA = secondBody.sections.find((s: { slug: string }) => s.slug === 'a');
134
+ const sectionB = secondBody.sections.find((s: { slug: string }) => s.slug === 'b');
135
+ expect(sectionB.position).toBe(0); // B is now first
136
+ expect(sectionA.position).toBe(1); // A is now second
137
+ });
138
+
139
+ it('does not push sections when neither content nor position changed', async () => {
140
+ const { syncMarkdownFile } = await import('../src/sync.js');
141
+
142
+ const filePath = join(tempDir, 'documentation', 'test.md');
143
+ const content = '# A\n\nContent A.\n\n# B\n\nContent B.';
144
+ await writeFile(filePath, content);
145
+
146
+ // First push
147
+ mockFetch.mockResolvedValueOnce({
148
+ ok: true,
149
+ json: async () => ({ data: { upserted: 2 } }),
150
+ });
151
+ await syncMarkdownFile('test-project-id', tempDir, filePath, testCreds);
152
+ mockFetch.mockReset();
153
+
154
+ // Second push — same content, same order
155
+ const secondCount = await syncMarkdownFile('test-project-id', tempDir, filePath, testCreds);
156
+
157
+ // No sections changed — no POST should be made
158
+ expect(secondCount).toBe(0);
159
+ expect(mockFetch).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it('manifest load → first sync (no push, positions populated) → reorder detected', async () => {
163
+ // This is the critical B-001 scenario:
164
+ // 1. Daemon starts, loads manifest (hashes only, no positions)
165
+ // 2. First sync: content unchanged → no push, but positions must be populated
166
+ // 3. User reorders sections → position change detected → push triggered
167
+
168
+ const filePath = join(tempDir, 'documentation', 'test.md');
169
+ const content = '# A\n\nContent A.\n\n# B\n\nContent B.';
170
+ await writeFile(filePath, content);
171
+
172
+ // Step 0: Compute real content hashes by doing a throwaway sync in an isolated module
173
+ vi.resetModules();
174
+ const probe = await import('../src/sync.js');
175
+ mockFetch.mockResolvedValueOnce({
176
+ ok: true,
177
+ json: async () => ({ data: { upserted: 2 } }),
178
+ });
179
+ await probe.syncMarkdownFile('test-project-id', tempDir, filePath, testCreds);
180
+ const probeCall = mockFetch.mock.calls.find(
181
+ (call: unknown[]) =>
182
+ (call[1] as { method: string }).method === 'POST' &&
183
+ (call[0] as string).includes('/api/sync/sections'),
184
+ );
185
+ const probeBody = JSON.parse((probeCall![1] as { body: string }).body);
186
+ const hashA = probeBody.sections.find((s: { slug: string }) => s.slug === 'a').content_hash;
187
+ const hashB = probeBody.sections.find((s: { slug: string }) => s.slug === 'b').content_hash;
188
+
189
+ // Step 1: Fresh daemon startup — load manifest with real hashes
190
+ mockFetch.mockReset();
191
+ vi.resetModules();
192
+ const fresh = await import('../src/sync.js');
193
+
194
+ mockFetch.mockResolvedValueOnce({
195
+ ok: true,
196
+ json: async () => ({
197
+ data: {
198
+ files: {
199
+ 'test.md': {
200
+ hash: null,
201
+ sections: { a: hashA, b: hashB },
202
+ },
203
+ },
204
+ },
205
+ }),
206
+ });
207
+ await fresh.loadManifestIntoCache('test-project-id', testCreds);
208
+ mockFetch.mockReset();
209
+
210
+ // Step 2: First sync — hashes match manifest, no push expected.
211
+ // But positions must be populated so future reorders are detectable.
212
+ const firstCount = await fresh.syncMarkdownFile('test-project-id', tempDir, filePath, testCreds);
213
+ expect(firstCount).toBe(0);
214
+ expect(mockFetch).not.toHaveBeenCalled();
215
+
216
+ // Step 3: Reorder sections (B, A) — same content, different positions
217
+ const reordered = '# B\n\nContent B.\n\n# A\n\nContent A.';
218
+ await writeFile(filePath, reordered);
219
+
220
+ mockFetch.mockResolvedValueOnce({
221
+ ok: true,
222
+ json: async () => ({ data: { upserted: 2 } }),
223
+ });
224
+
225
+ const reorderCount = await fresh.syncMarkdownFile('test-project-id', tempDir, filePath, testCreds);
226
+
227
+ // Both sections must be pushed — positions changed
228
+ expect(reorderCount).toBe(2);
229
+
230
+ const postCall = mockFetch.mock.calls.find(
231
+ (call: unknown[]) =>
232
+ (call[1] as { method: string }).method === 'POST' &&
233
+ (call[0] as string).includes('/api/sync/sections'),
234
+ );
235
+ expect(postCall).toBeDefined();
236
+ const body = JSON.parse((postCall![1] as { body: string }).body);
237
+ const sectionA = body.sections.find((s: { slug: string }) => s.slug === 'a');
238
+ const sectionB = body.sections.find((s: { slug: string }) => s.slug === 'b');
239
+ expect(sectionB.position).toBe(0); // B moved to first
240
+ expect(sectionA.position).toBe(1); // A moved to second
241
+ });
242
+ });
@@ -14,6 +14,14 @@ vi.mock('../src/config.js', () => ({
14
14
  userId: 'test-user',
15
15
  serverUrl: 'http://localhost:3000',
16
16
  }),
17
+ loadAllCredentials: vi.fn().mockResolvedValue(new Map([
18
+ ['test-user', {
19
+ apiKey: 'test-api-key',
20
+ userId: 'test-user',
21
+ email: 'test@example.com',
22
+ serverUrl: 'http://localhost:3000',
23
+ }],
24
+ ])),
17
25
  getServerUrl: vi.fn().mockReturnValue('http://localhost:3000'),
18
26
  paths: {
19
27
  claudeProjects: '/tmp/test-projects',