claude-session-share 1.0.0

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,286 @@
1
+ /**
2
+ * Tests for session writer with local JSONL storage
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import { writeSessionToLocal, SessionWriteError } from '../session/writer.js';
6
+ import { homedir } from 'os';
7
+ import { join } from 'path';
8
+ // Mock fs/promises
9
+ vi.mock('fs/promises', () => ({
10
+ mkdir: vi.fn(),
11
+ writeFile: vi.fn(),
12
+ }));
13
+ // Mock crypto for predictable UUID generation
14
+ vi.mock('crypto', () => ({
15
+ randomUUID: vi.fn(() => 'test-session-uuid-123'),
16
+ }));
17
+ describe('writeSessionToLocal', () => {
18
+ let mockMkdir;
19
+ let mockWriteFile;
20
+ beforeEach(async () => {
21
+ // Get mocked functions
22
+ const fs = await import('fs/promises');
23
+ mockMkdir = fs.mkdir;
24
+ mockWriteFile = fs.writeFile;
25
+ // Reset mocks
26
+ vi.clearAllMocks();
27
+ // Default successful behavior
28
+ mockMkdir.mockResolvedValue(undefined);
29
+ mockWriteFile.mockResolvedValue(undefined);
30
+ });
31
+ afterEach(() => {
32
+ vi.clearAllMocks();
33
+ });
34
+ it('should write session messages to correct path', async () => {
35
+ const messages = [
36
+ {
37
+ type: 'user',
38
+ uuid: 'uuid-1',
39
+ sessionId: 'session-123',
40
+ timestamp: '2024-01-01T00:00:00Z',
41
+ parentUuid: null,
42
+ message: { role: 'user', content: 'Test message' },
43
+ cwd: '/Users/name/project',
44
+ version: '1.0.0',
45
+ },
46
+ ];
47
+ const projectPath = '/Users/name/project';
48
+ const result = await writeSessionToLocal(messages, projectPath);
49
+ // Verify directory creation
50
+ const expectedDir = join(homedir(), '.claude', 'projects', 'Users-name-project');
51
+ expect(mockMkdir).toHaveBeenCalledWith(expectedDir, { recursive: true });
52
+ // Verify file writing
53
+ const expectedPath = join(expectedDir, 'test-session-uuid-123.jsonl');
54
+ expect(mockWriteFile).toHaveBeenCalledWith(expectedPath, expect.any(String), { encoding: 'utf-8' });
55
+ // Verify result
56
+ expect(result.filePath).toBe(expectedPath);
57
+ expect(result.sessionId).toBe('test-session-uuid-123');
58
+ });
59
+ it('should format messages as JSONL with trailing newline', async () => {
60
+ const messages = [
61
+ {
62
+ type: 'user',
63
+ uuid: 'uuid-1',
64
+ sessionId: 'session-123',
65
+ timestamp: '2024-01-01T00:00:00Z',
66
+ parentUuid: null,
67
+ message: { role: 'user', content: 'First' },
68
+ cwd: '/Users/name/project',
69
+ version: '1.0.0',
70
+ },
71
+ {
72
+ type: 'user',
73
+ uuid: 'uuid-2',
74
+ sessionId: 'session-123',
75
+ timestamp: '2024-01-01T00:01:00Z',
76
+ parentUuid: 'uuid-1',
77
+ message: { role: 'user', content: 'Second' },
78
+ cwd: '/Users/name/project',
79
+ version: '1.0.0',
80
+ },
81
+ ];
82
+ await writeSessionToLocal(messages, '/Users/name/project');
83
+ const writtenContent = mockWriteFile.mock.calls[0][1];
84
+ const lines = writtenContent.split('\n');
85
+ // Should have 3 lines: 2 messages + trailing newline (which creates empty string)
86
+ expect(lines.length).toBe(3);
87
+ expect(lines[2]).toBe(''); // Trailing newline creates empty last element
88
+ // Each line should be valid JSON
89
+ expect(() => JSON.parse(lines[0])).not.toThrow();
90
+ expect(() => JSON.parse(lines[1])).not.toThrow();
91
+ // Verify content
92
+ const msg1 = JSON.parse(lines[0]);
93
+ const msg2 = JSON.parse(lines[1]);
94
+ expect(msg1.uuid).toBe('uuid-1');
95
+ expect(msg2.uuid).toBe('uuid-2');
96
+ });
97
+ it('should handle multiple message types', async () => {
98
+ const messages = [
99
+ {
100
+ type: 'user',
101
+ uuid: 'uuid-1',
102
+ sessionId: 'session-123',
103
+ timestamp: '2024-01-01T00:00:00Z',
104
+ parentUuid: null,
105
+ message: { role: 'user', content: 'Question' },
106
+ cwd: '/Users/name/project',
107
+ version: '1.0.0',
108
+ },
109
+ {
110
+ type: 'assistant',
111
+ uuid: 'uuid-2',
112
+ sessionId: 'session-123',
113
+ timestamp: '2024-01-01T00:01:00Z',
114
+ parentUuid: 'uuid-1',
115
+ messageId: 'msg-1',
116
+ snapshot: {
117
+ thinking: 'Let me help',
118
+ messages: [{ role: 'assistant', content: 'Answer' }],
119
+ },
120
+ },
121
+ ];
122
+ await writeSessionToLocal(messages, '/Users/name/project');
123
+ const writtenContent = mockWriteFile.mock.calls[0][1];
124
+ const lines = writtenContent.split('\n');
125
+ const msg1 = JSON.parse(lines[0]);
126
+ const msg2 = JSON.parse(lines[1]);
127
+ expect(msg1.type).toBe('user');
128
+ expect(msg2.type).toBe('assistant');
129
+ expect(msg2.snapshot.thinking).toBe('Let me help');
130
+ });
131
+ it('should encode project path correctly', async () => {
132
+ const messages = [
133
+ {
134
+ type: 'user',
135
+ uuid: 'uuid-1',
136
+ sessionId: 'session-123',
137
+ timestamp: '2024-01-01T00:00:00Z',
138
+ parentUuid: null,
139
+ message: { role: 'user', content: 'Test' },
140
+ cwd: '/opt/code/my-app',
141
+ version: '1.0.0',
142
+ },
143
+ ];
144
+ await writeSessionToLocal(messages, '/opt/code/my-app');
145
+ const expectedDir = join(homedir(), '.claude', 'projects', 'opt-code-my-app');
146
+ expect(mockMkdir).toHaveBeenCalledWith(expectedDir, { recursive: true });
147
+ });
148
+ it('should create directory with recursive flag', async () => {
149
+ const messages = [
150
+ {
151
+ type: 'user',
152
+ uuid: 'uuid-1',
153
+ sessionId: 'session-123',
154
+ timestamp: '2024-01-01T00:00:00Z',
155
+ parentUuid: null,
156
+ message: { role: 'user', content: 'Test' },
157
+ cwd: '/Users/name/project',
158
+ version: '1.0.0',
159
+ },
160
+ ];
161
+ await writeSessionToLocal(messages, '/Users/name/project');
162
+ expect(mockMkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
163
+ });
164
+ it('should throw SessionWriteError on EACCES (permissions)', async () => {
165
+ const messages = [
166
+ {
167
+ type: 'user',
168
+ uuid: 'uuid-1',
169
+ sessionId: 'session-123',
170
+ timestamp: '2024-01-01T00:00:00Z',
171
+ parentUuid: null,
172
+ message: { role: 'user', content: 'Test' },
173
+ cwd: '/Users/name/project',
174
+ version: '1.0.0',
175
+ },
176
+ ];
177
+ mockMkdir.mockRejectedValue({
178
+ code: 'EACCES',
179
+ message: 'Permission denied',
180
+ });
181
+ await expect(writeSessionToLocal(messages, '/Users/name/project')).rejects.toThrow(SessionWriteError);
182
+ await expect(writeSessionToLocal(messages, '/Users/name/project')).rejects.toThrow(/Permission denied/);
183
+ });
184
+ it('should throw SessionWriteError on ENOSPC (disk full)', async () => {
185
+ const messages = [
186
+ {
187
+ type: 'user',
188
+ uuid: 'uuid-1',
189
+ sessionId: 'session-123',
190
+ timestamp: '2024-01-01T00:00:00Z',
191
+ parentUuid: null,
192
+ message: { role: 'user', content: 'Test' },
193
+ cwd: '/Users/name/project',
194
+ version: '1.0.0',
195
+ },
196
+ ];
197
+ mockWriteFile.mockRejectedValue({
198
+ code: 'ENOSPC',
199
+ message: 'No space left on device',
200
+ });
201
+ await expect(writeSessionToLocal(messages, '/Users/name/project')).rejects.toThrow(SessionWriteError);
202
+ await expect(writeSessionToLocal(messages, '/Users/name/project')).rejects.toThrow(/Disk full/);
203
+ });
204
+ it('should throw SessionWriteError on other filesystem errors', async () => {
205
+ const messages = [
206
+ {
207
+ type: 'user',
208
+ uuid: 'uuid-1',
209
+ sessionId: 'session-123',
210
+ timestamp: '2024-01-01T00:00:00Z',
211
+ parentUuid: null,
212
+ message: { role: 'user', content: 'Test' },
213
+ cwd: '/Users/name/project',
214
+ version: '1.0.0',
215
+ },
216
+ ];
217
+ mockWriteFile.mockRejectedValue({
218
+ code: 'UNKNOWN',
219
+ message: 'Something went wrong',
220
+ });
221
+ await expect(writeSessionToLocal(messages, '/Users/name/project')).rejects.toThrow(SessionWriteError);
222
+ await expect(writeSessionToLocal(messages, '/Users/name/project')).rejects.toThrow(/Failed to write session/);
223
+ });
224
+ it('should include error code in SessionWriteError', async () => {
225
+ const messages = [
226
+ {
227
+ type: 'user',
228
+ uuid: 'uuid-1',
229
+ sessionId: 'session-123',
230
+ timestamp: '2024-01-01T00:00:00Z',
231
+ parentUuid: null,
232
+ message: { role: 'user', content: 'Test' },
233
+ cwd: '/Users/name/project',
234
+ version: '1.0.0',
235
+ },
236
+ ];
237
+ mockMkdir.mockRejectedValue({
238
+ code: 'EACCES',
239
+ message: 'Permission denied',
240
+ });
241
+ try {
242
+ await writeSessionToLocal(messages, '/Users/name/project');
243
+ expect.fail('Should have thrown');
244
+ }
245
+ catch (error) {
246
+ expect(error).toBeInstanceOf(SessionWriteError);
247
+ expect(error.code).toBe('EACCES');
248
+ }
249
+ });
250
+ it('should write empty messages array', async () => {
251
+ const messages = [];
252
+ await writeSessionToLocal(messages, '/Users/name/project');
253
+ const writtenContent = mockWriteFile.mock.calls[0][1];
254
+ expect(writtenContent).toBe('\n'); // Just trailing newline
255
+ });
256
+ it('should preserve all message fields in written JSON', async () => {
257
+ const messages = [
258
+ {
259
+ type: 'user',
260
+ uuid: 'uuid-1',
261
+ sessionId: 'session-123',
262
+ timestamp: '2024-01-01T00:00:00Z',
263
+ parentUuid: null,
264
+ message: { role: 'user', content: 'Test message' },
265
+ cwd: '/Users/name/project',
266
+ version: '1.0.0',
267
+ gitBranch: 'main',
268
+ isSidechain: true,
269
+ isMeta: false,
270
+ },
271
+ ];
272
+ await writeSessionToLocal(messages, '/Users/name/project');
273
+ const writtenContent = mockWriteFile.mock.calls[0][1];
274
+ const parsed = JSON.parse(writtenContent.split('\n')[0]);
275
+ expect(parsed.type).toBe('user');
276
+ expect(parsed.uuid).toBe('uuid-1');
277
+ expect(parsed.sessionId).toBe('session-123');
278
+ expect(parsed.timestamp).toBe('2024-01-01T00:00:00Z');
279
+ expect(parsed.parentUuid).toBeNull();
280
+ expect(parsed.cwd).toBe('/Users/name/project');
281
+ expect(parsed.version).toBe('1.0.0');
282
+ expect(parsed.gitBranch).toBe('main');
283
+ expect(parsed.isSidechain).toBe(true);
284
+ expect(parsed.isMeta).toBe(false);
285
+ });
286
+ });
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Tests for UUID remapper with collision avoidance
3
+ */
4
+ import { describe, it, expect, beforeEach } from 'vitest';
5
+ import { UUIDMapper } from '../utils/uuid-mapper.js';
6
+ describe('UUIDMapper', () => {
7
+ let mapper;
8
+ beforeEach(() => {
9
+ mapper = new UUIDMapper();
10
+ });
11
+ describe('remap', () => {
12
+ it('should generate a new UUID for a given input', () => {
13
+ const original = 'abc-123-def';
14
+ const remapped = mapper.remap(original);
15
+ expect(remapped).toBeTruthy();
16
+ expect(remapped).not.toBe(original);
17
+ expect(typeof remapped).toBe('string');
18
+ });
19
+ it('should return the same UUID for the same input (consistency)', () => {
20
+ const original = 'abc-123-def';
21
+ const remapped1 = mapper.remap(original);
22
+ const remapped2 = mapper.remap(original);
23
+ const remapped3 = mapper.remap(original);
24
+ expect(remapped1).toBe(remapped2);
25
+ expect(remapped2).toBe(remapped3);
26
+ });
27
+ it('should generate different UUIDs for different inputs', () => {
28
+ const uuid1 = mapper.remap('original-1');
29
+ const uuid2 = mapper.remap('original-2');
30
+ const uuid3 = mapper.remap('original-3');
31
+ expect(uuid1).not.toBe(uuid2);
32
+ expect(uuid2).not.toBe(uuid3);
33
+ expect(uuid1).not.toBe(uuid3);
34
+ });
35
+ it('should handle null input by returning null', () => {
36
+ const result = mapper.remap(null);
37
+ expect(result).toBeNull();
38
+ });
39
+ it('should always return null for null input (multiple calls)', () => {
40
+ const result1 = mapper.remap(null);
41
+ const result2 = mapper.remap(null);
42
+ const result3 = mapper.remap(null);
43
+ expect(result1).toBeNull();
44
+ expect(result2).toBeNull();
45
+ expect(result3).toBeNull();
46
+ });
47
+ it('should generate valid UUID v4 format', () => {
48
+ const remapped = mapper.remap('test-uuid');
49
+ // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
50
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
51
+ expect(remapped).toMatch(uuidRegex);
52
+ });
53
+ });
54
+ describe('remapMessage', () => {
55
+ it('should remap UUIDs in a user message', () => {
56
+ const original = {
57
+ type: 'user',
58
+ uuid: 'user-uuid-1',
59
+ sessionId: 'session-123',
60
+ timestamp: '2024-01-01T00:00:00Z',
61
+ parentUuid: 'parent-uuid-1',
62
+ message: {
63
+ role: 'user',
64
+ content: 'Hello, world!',
65
+ },
66
+ cwd: '/home/user/project',
67
+ version: '1.0.0',
68
+ gitBranch: 'main',
69
+ isSidechain: false,
70
+ };
71
+ const remapped = mapper.remapMessage(original);
72
+ // UUIDs should be different
73
+ expect(remapped.uuid).not.toBe(original.uuid);
74
+ expect(remapped.sessionId).not.toBe(original.sessionId);
75
+ expect(remapped.parentUuid).not.toBe(original.parentUuid);
76
+ // Other fields should be preserved
77
+ expect(remapped.type).toBe(original.type);
78
+ expect(remapped.timestamp).toBe(original.timestamp);
79
+ expect(remapped.message).toEqual(original.message);
80
+ expect(remapped.cwd).toBe(original.cwd);
81
+ expect(remapped.version).toBe(original.version);
82
+ expect(remapped.gitBranch).toBe(original.gitBranch);
83
+ expect(remapped.isSidechain).toBe(original.isSidechain);
84
+ });
85
+ it('should remap UUIDs in an assistant message', () => {
86
+ const original = {
87
+ type: 'assistant',
88
+ uuid: 'assistant-uuid-1',
89
+ sessionId: 'session-123',
90
+ timestamp: '2024-01-01T00:01:00Z',
91
+ parentUuid: 'parent-uuid-2',
92
+ messageId: 'msg-123',
93
+ snapshot: {
94
+ thinking: 'Let me help you',
95
+ messages: [
96
+ { role: 'assistant', content: 'Here is the answer' },
97
+ ],
98
+ },
99
+ };
100
+ const remapped = mapper.remapMessage(original);
101
+ // UUIDs should be different
102
+ expect(remapped.uuid).not.toBe(original.uuid);
103
+ expect(remapped.sessionId).not.toBe(original.sessionId);
104
+ expect(remapped.parentUuid).not.toBe(original.parentUuid);
105
+ // Other fields should be preserved
106
+ expect(remapped.type).toBe(original.type);
107
+ expect(remapped.timestamp).toBe(original.timestamp);
108
+ expect(remapped.messageId).toBe(original.messageId);
109
+ expect(remapped.snapshot).toEqual(original.snapshot);
110
+ });
111
+ it('should remap UUIDs in a file-history-snapshot message', () => {
112
+ const original = {
113
+ type: 'file-history-snapshot',
114
+ uuid: 'snapshot-uuid-1',
115
+ sessionId: 'session-123',
116
+ timestamp: '2024-01-01T00:02:00Z',
117
+ parentUuid: null,
118
+ isSnapshotUpdate: true,
119
+ snapshot: {
120
+ files: [
121
+ { path: '/home/user/project/file.ts' },
122
+ ],
123
+ },
124
+ };
125
+ const remapped = mapper.remapMessage(original);
126
+ // UUIDs should be different (except parentUuid which is null)
127
+ expect(remapped.uuid).not.toBe(original.uuid);
128
+ expect(remapped.sessionId).not.toBe(original.sessionId);
129
+ expect(remapped.parentUuid).toBeNull();
130
+ // Other fields should be preserved
131
+ expect(remapped.type).toBe(original.type);
132
+ expect(remapped.timestamp).toBe(original.timestamp);
133
+ expect(remapped.isSnapshotUpdate).toBe(original.isSnapshotUpdate);
134
+ expect(remapped.snapshot).toEqual(original.snapshot);
135
+ });
136
+ it('should not mutate the original message (immutability)', () => {
137
+ const original = {
138
+ type: 'user',
139
+ uuid: 'user-uuid-1',
140
+ sessionId: 'session-123',
141
+ timestamp: '2024-01-01T00:00:00Z',
142
+ parentUuid: 'parent-uuid-1',
143
+ message: {
144
+ role: 'user',
145
+ content: 'Test message',
146
+ },
147
+ cwd: '/home/user/project',
148
+ version: '1.0.0',
149
+ };
150
+ const originalCopy = { ...original };
151
+ const remapped = mapper.remapMessage(original);
152
+ // Original should be unchanged
153
+ expect(original).toEqual(originalCopy);
154
+ expect(original.uuid).toBe('user-uuid-1');
155
+ expect(original.sessionId).toBe('session-123');
156
+ expect(original.parentUuid).toBe('parent-uuid-1');
157
+ // Remapped should be different
158
+ expect(remapped.uuid).not.toBe(original.uuid);
159
+ });
160
+ it('should preserve parent-child relationships through consistent remapping', () => {
161
+ const parentMessage = {
162
+ type: 'user',
163
+ uuid: 'parent-uuid',
164
+ sessionId: 'session-123',
165
+ timestamp: '2024-01-01T00:00:00Z',
166
+ parentUuid: null,
167
+ message: { role: 'user', content: 'Parent' },
168
+ cwd: '/home/user/project',
169
+ version: '1.0.0',
170
+ };
171
+ const childMessage = {
172
+ type: 'assistant',
173
+ uuid: 'child-uuid',
174
+ sessionId: 'session-123',
175
+ timestamp: '2024-01-01T00:01:00Z',
176
+ parentUuid: 'parent-uuid', // References parent
177
+ messageId: 'msg-1',
178
+ snapshot: {
179
+ thinking: null,
180
+ messages: [],
181
+ },
182
+ };
183
+ const remappedParent = mapper.remapMessage(parentMessage);
184
+ const remappedChild = mapper.remapMessage(childMessage);
185
+ // Child's parentUuid should equal parent's uuid after remapping
186
+ expect(remappedChild.parentUuid).toBe(remappedParent.uuid);
187
+ // Both should have same sessionId after remapping
188
+ expect(remappedChild.sessionId).toBe(remappedParent.sessionId);
189
+ });
190
+ it('should handle multiple messages with consistent session ID remapping', () => {
191
+ const messages = [
192
+ {
193
+ type: 'user',
194
+ uuid: 'uuid-1',
195
+ sessionId: 'session-abc',
196
+ timestamp: '2024-01-01T00:00:00Z',
197
+ parentUuid: null,
198
+ message: { role: 'user', content: 'Message 1' },
199
+ cwd: '/home/user/project',
200
+ version: '1.0.0',
201
+ },
202
+ {
203
+ type: 'user',
204
+ uuid: 'uuid-2',
205
+ sessionId: 'session-abc',
206
+ timestamp: '2024-01-01T00:01:00Z',
207
+ parentUuid: 'uuid-1',
208
+ message: { role: 'user', content: 'Message 2' },
209
+ cwd: '/home/user/project',
210
+ version: '1.0.0',
211
+ },
212
+ {
213
+ type: 'user',
214
+ uuid: 'uuid-3',
215
+ sessionId: 'session-abc',
216
+ timestamp: '2024-01-01T00:02:00Z',
217
+ parentUuid: 'uuid-2',
218
+ message: { role: 'user', content: 'Message 3' },
219
+ cwd: '/home/user/project',
220
+ version: '1.0.0',
221
+ },
222
+ ];
223
+ const remapped = messages.map((msg) => mapper.remapMessage(msg));
224
+ // All should have the same remapped sessionId
225
+ expect(remapped[0].sessionId).toBe(remapped[1].sessionId);
226
+ expect(remapped[1].sessionId).toBe(remapped[2].sessionId);
227
+ // Parent-child chain should be preserved
228
+ expect(remapped[1].parentUuid).toBe(remapped[0].uuid);
229
+ expect(remapped[2].parentUuid).toBe(remapped[1].uuid);
230
+ });
231
+ it('should handle null parentUuid correctly', () => {
232
+ const message = {
233
+ type: 'user',
234
+ uuid: 'root-uuid',
235
+ sessionId: 'session-123',
236
+ timestamp: '2024-01-01T00:00:00Z',
237
+ parentUuid: null, // Root message
238
+ message: { role: 'user', content: 'Root message' },
239
+ cwd: '/home/user/project',
240
+ version: '1.0.0',
241
+ };
242
+ const remapped = mapper.remapMessage(message);
243
+ // parentUuid should remain null
244
+ expect(remapped.parentUuid).toBeNull();
245
+ expect(remapped.uuid).not.toBe(message.uuid);
246
+ expect(remapped.sessionId).not.toBe(message.sessionId);
247
+ });
248
+ });
249
+ });