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.
- package/__tests__/daemon-reconcile.test.ts +9 -0
- package/__tests__/daemon.test.ts +9 -0
- package/__tests__/pid-detection.test.ts +68 -0
- package/__tests__/socket-client.test.ts +210 -0
- package/__tests__/sync-reorder.test.ts +242 -0
- package/__tests__/usage-sync.test.ts +8 -0
- package/__tests__/version-watcher.test.ts +286 -0
- package/dist/cli.js +141 -14
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +65 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +4 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +113 -5
- package/dist/daemon.js.map +1 -1
- package/dist/logger.d.ts +7 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +35 -0
- package/dist/logger.js.map +1 -1
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +15 -9
- package/dist/repl.js.map +1 -1
- package/dist/scripts/postinstall.d.ts +2 -0
- package/dist/scripts/postinstall.d.ts.map +1 -0
- package/dist/scripts/postinstall.js +174 -0
- package/dist/scripts/postinstall.js.map +1 -0
- package/dist/service.d.ts +14 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +54 -1
- package/dist/service.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +11 -2
- package/dist/setup.js.map +1 -1
- package/dist/socket-client.d.ts +27 -0
- package/dist/socket-client.d.ts.map +1 -0
- package/dist/socket-client.js +84 -0
- package/dist/socket-client.js.map +1 -0
- package/dist/status.d.ts +1 -0
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js.map +1 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +15 -5
- package/dist/sync.js.map +1 -1
- package/dist/watcher.d.ts +2 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +5 -1
- package/dist/watcher.js.map +1 -1
- package/package.json +3 -1
- package/src/cli.ts +147 -13
- package/src/config.ts +64 -1
- package/src/daemon.ts +130 -4
- package/src/logger.ts +39 -0
- package/src/repl.ts +15 -8
- package/src/scripts/postinstall.ts +179 -0
- package/src/service.ts +49 -1
- package/src/setup.ts +12 -2
- package/src/socket-client.ts +98 -0
- package/src/status.ts +1 -0
- package/src/sync.ts +18 -5
- 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: {
|
package/__tests__/daemon.test.ts
CHANGED
|
@@ -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',
|