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.
- package/LICENSE +21 -0
- package/README.md +313 -0
- package/dist/__tests__/e2e.test.js +532 -0
- package/dist/__tests__/gist-client.test.js +341 -0
- package/dist/__tests__/index.test.js +16 -0
- package/dist/__tests__/mcp-integration.test.js +403 -0
- package/dist/__tests__/path-encoding.test.js +77 -0
- package/dist/__tests__/pipeline.test.js +342 -0
- package/dist/__tests__/redactor.test.js +162 -0
- package/dist/__tests__/sanitizer.test.js +345 -0
- package/dist/__tests__/session-importer.test.js +216 -0
- package/dist/__tests__/session-reader.test.js +298 -0
- package/dist/__tests__/session-uploader.test.js +216 -0
- package/dist/__tests__/session-writer.test.js +286 -0
- package/dist/__tests__/uuid-mapper.test.js +249 -0
- package/dist/gist/client.js +199 -0
- package/dist/gist/types.js +7 -0
- package/dist/index.js +214 -0
- package/dist/sanitization/pipeline.js +48 -0
- package/dist/sanitization/redactor.js +48 -0
- package/dist/sanitization/sanitizer.js +87 -0
- package/dist/services/session-importer.js +88 -0
- package/dist/services/session-uploader.js +64 -0
- package/dist/session/finder.js +65 -0
- package/dist/session/metadata.js +55 -0
- package/dist/session/reader.js +101 -0
- package/dist/session/types.js +11 -0
- package/dist/session/writer.js +74 -0
- package/dist/utils/path-encoding.js +54 -0
- package/dist/utils/uuid-mapper.js +73 -0
- package/package.json +54 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sanitizeAssistantMessage, sanitizeUserMessage, sanitizeFileHistorySnapshot, } from '../sanitization/sanitizer.js';
|
|
3
|
+
describe('sanitizeAssistantMessage', () => {
|
|
4
|
+
const basePath = '/Users/testuser/project';
|
|
5
|
+
it('should strip thinking block', () => {
|
|
6
|
+
const msg = {
|
|
7
|
+
type: 'assistant',
|
|
8
|
+
uuid: 'msg-1',
|
|
9
|
+
sessionId: 'session-1',
|
|
10
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
11
|
+
parentUuid: null,
|
|
12
|
+
messageId: 'msg-1',
|
|
13
|
+
snapshot: {
|
|
14
|
+
thinking: 'This is internal reasoning that should be removed',
|
|
15
|
+
messages: [{ role: 'assistant', content: 'Hello' }],
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const sanitized = sanitizeAssistantMessage(msg, basePath);
|
|
19
|
+
expect(sanitized.snapshot.thinking).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
it('should handle null thinking gracefully', () => {
|
|
22
|
+
const msg = {
|
|
23
|
+
type: 'assistant',
|
|
24
|
+
uuid: 'msg-1',
|
|
25
|
+
sessionId: 'session-1',
|
|
26
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
27
|
+
parentUuid: null,
|
|
28
|
+
messageId: 'msg-1',
|
|
29
|
+
snapshot: {
|
|
30
|
+
thinking: null,
|
|
31
|
+
messages: [{ role: 'assistant', content: 'Hello' }],
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const sanitized = sanitizeAssistantMessage(msg, basePath);
|
|
35
|
+
expect(sanitized.snapshot.thinking).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
it('should handle empty thinking string', () => {
|
|
38
|
+
const msg = {
|
|
39
|
+
type: 'assistant',
|
|
40
|
+
uuid: 'msg-1',
|
|
41
|
+
sessionId: 'session-1',
|
|
42
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
43
|
+
parentUuid: null,
|
|
44
|
+
messageId: 'msg-1',
|
|
45
|
+
snapshot: {
|
|
46
|
+
thinking: '',
|
|
47
|
+
messages: [{ role: 'assistant', content: 'Hello' }],
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
const sanitized = sanitizeAssistantMessage(msg, basePath);
|
|
51
|
+
expect(sanitized.snapshot.thinking).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
it('should sanitize absolute paths in tool results', () => {
|
|
54
|
+
const msg = {
|
|
55
|
+
type: 'assistant',
|
|
56
|
+
uuid: 'msg-1',
|
|
57
|
+
sessionId: 'session-1',
|
|
58
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
59
|
+
parentUuid: null,
|
|
60
|
+
messageId: 'msg-1',
|
|
61
|
+
snapshot: {
|
|
62
|
+
thinking: null,
|
|
63
|
+
messages: [
|
|
64
|
+
{
|
|
65
|
+
role: 'assistant',
|
|
66
|
+
content: 'File at /Users/testuser/project/src/index.ts',
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
const sanitized = sanitizeAssistantMessage(msg, basePath);
|
|
72
|
+
expect(sanitized.snapshot.messages[0].content).toContain('src/index.ts');
|
|
73
|
+
expect(sanitized.snapshot.messages[0].content).not.toContain('/Users/testuser/project/src');
|
|
74
|
+
});
|
|
75
|
+
it('should leave external paths unchanged', () => {
|
|
76
|
+
const msg = {
|
|
77
|
+
type: 'assistant',
|
|
78
|
+
uuid: 'msg-1',
|
|
79
|
+
sessionId: 'session-1',
|
|
80
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
81
|
+
parentUuid: null,
|
|
82
|
+
messageId: 'msg-1',
|
|
83
|
+
snapshot: {
|
|
84
|
+
thinking: null,
|
|
85
|
+
messages: [
|
|
86
|
+
{
|
|
87
|
+
role: 'assistant',
|
|
88
|
+
content: 'External file at /opt/external/file.txt',
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
const sanitized = sanitizeAssistantMessage(msg, basePath);
|
|
94
|
+
expect(sanitized.snapshot.messages[0].content).toBe('External file at /opt/external/file.txt');
|
|
95
|
+
});
|
|
96
|
+
it('should handle multiple paths in same content', () => {
|
|
97
|
+
const msg = {
|
|
98
|
+
type: 'assistant',
|
|
99
|
+
uuid: 'msg-1',
|
|
100
|
+
sessionId: 'session-1',
|
|
101
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
102
|
+
parentUuid: null,
|
|
103
|
+
messageId: 'msg-1',
|
|
104
|
+
snapshot: {
|
|
105
|
+
thinking: null,
|
|
106
|
+
messages: [
|
|
107
|
+
{
|
|
108
|
+
role: 'assistant',
|
|
109
|
+
content: 'Files: /Users/testuser/project/src/a.ts and /Users/testuser/project/src/b.ts',
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const sanitized = sanitizeAssistantMessage(msg, basePath);
|
|
115
|
+
expect(sanitized.snapshot.messages[0].content).toContain('src/a.ts');
|
|
116
|
+
expect(sanitized.snapshot.messages[0].content).toContain('src/b.ts');
|
|
117
|
+
expect(sanitized.snapshot.messages[0].content).not.toContain('/Users/testuser/project');
|
|
118
|
+
});
|
|
119
|
+
it('should preserve original message (immutable)', () => {
|
|
120
|
+
const msg = {
|
|
121
|
+
type: 'assistant',
|
|
122
|
+
uuid: 'msg-1',
|
|
123
|
+
sessionId: 'session-1',
|
|
124
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
125
|
+
parentUuid: null,
|
|
126
|
+
messageId: 'msg-1',
|
|
127
|
+
snapshot: {
|
|
128
|
+
thinking: 'Original thinking',
|
|
129
|
+
messages: [{ role: 'assistant', content: 'Original content' }],
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
const sanitized = sanitizeAssistantMessage(msg, basePath);
|
|
133
|
+
expect(msg.snapshot.thinking).toBe('Original thinking');
|
|
134
|
+
expect(msg.snapshot.messages[0].content).toBe('Original content');
|
|
135
|
+
expect(sanitized.snapshot.thinking).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
it('should handle empty messages array', () => {
|
|
138
|
+
const msg = {
|
|
139
|
+
type: 'assistant',
|
|
140
|
+
uuid: 'msg-1',
|
|
141
|
+
sessionId: 'session-1',
|
|
142
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
143
|
+
parentUuid: null,
|
|
144
|
+
messageId: 'msg-1',
|
|
145
|
+
snapshot: {
|
|
146
|
+
thinking: 'Thinking',
|
|
147
|
+
messages: [],
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
const sanitized = sanitizeAssistantMessage(msg, basePath);
|
|
151
|
+
expect(sanitized.snapshot.messages).toEqual([]);
|
|
152
|
+
expect(sanitized.snapshot.thinking).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
it('should handle paths in JSON tool results', () => {
|
|
155
|
+
const msg = {
|
|
156
|
+
type: 'assistant',
|
|
157
|
+
uuid: 'msg-1',
|
|
158
|
+
sessionId: 'session-1',
|
|
159
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
160
|
+
parentUuid: null,
|
|
161
|
+
messageId: 'msg-1',
|
|
162
|
+
snapshot: {
|
|
163
|
+
thinking: null,
|
|
164
|
+
messages: [
|
|
165
|
+
{
|
|
166
|
+
role: 'tool',
|
|
167
|
+
content: JSON.stringify({
|
|
168
|
+
file_path: '/Users/testuser/project/src/index.ts',
|
|
169
|
+
status: 'success',
|
|
170
|
+
}),
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const sanitized = sanitizeAssistantMessage(msg, basePath);
|
|
176
|
+
expect(sanitized.snapshot.messages[0].content).toContain('src/index.ts');
|
|
177
|
+
expect(sanitized.snapshot.messages[0].content).not.toContain('/Users/testuser/project/src');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
describe('sanitizeUserMessage', () => {
|
|
181
|
+
const basePath = '/Users/testuser/project';
|
|
182
|
+
it('should sanitize absolute cwd to relative', () => {
|
|
183
|
+
const msg = {
|
|
184
|
+
type: 'user',
|
|
185
|
+
uuid: 'msg-1',
|
|
186
|
+
sessionId: 'session-1',
|
|
187
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
188
|
+
parentUuid: null,
|
|
189
|
+
message: { role: 'user', content: 'Hello' },
|
|
190
|
+
cwd: '/Users/testuser/project/src',
|
|
191
|
+
version: '1.0',
|
|
192
|
+
};
|
|
193
|
+
const sanitized = sanitizeUserMessage(msg, basePath);
|
|
194
|
+
expect(sanitized.cwd).toBe('src');
|
|
195
|
+
});
|
|
196
|
+
it('should handle cwd equal to basePath', () => {
|
|
197
|
+
const msg = {
|
|
198
|
+
type: 'user',
|
|
199
|
+
uuid: 'msg-1',
|
|
200
|
+
sessionId: 'session-1',
|
|
201
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
202
|
+
parentUuid: null,
|
|
203
|
+
message: { role: 'user', content: 'Hello' },
|
|
204
|
+
cwd: '/Users/testuser/project',
|
|
205
|
+
version: '1.0',
|
|
206
|
+
};
|
|
207
|
+
const sanitized = sanitizeUserMessage(msg, basePath);
|
|
208
|
+
expect(sanitized.cwd).toBe('.');
|
|
209
|
+
});
|
|
210
|
+
it('should leave external cwd unchanged', () => {
|
|
211
|
+
const msg = {
|
|
212
|
+
type: 'user',
|
|
213
|
+
uuid: 'msg-1',
|
|
214
|
+
sessionId: 'session-1',
|
|
215
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
216
|
+
parentUuid: null,
|
|
217
|
+
message: { role: 'user', content: 'Hello' },
|
|
218
|
+
cwd: '/opt/external',
|
|
219
|
+
version: '1.0',
|
|
220
|
+
};
|
|
221
|
+
const sanitized = sanitizeUserMessage(msg, basePath);
|
|
222
|
+
expect(sanitized.cwd).toBe('/opt/external');
|
|
223
|
+
});
|
|
224
|
+
it('should preserve original message (immutable)', () => {
|
|
225
|
+
const msg = {
|
|
226
|
+
type: 'user',
|
|
227
|
+
uuid: 'msg-1',
|
|
228
|
+
sessionId: 'session-1',
|
|
229
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
230
|
+
parentUuid: null,
|
|
231
|
+
message: { role: 'user', content: 'Hello' },
|
|
232
|
+
cwd: '/Users/testuser/project/src',
|
|
233
|
+
version: '1.0',
|
|
234
|
+
};
|
|
235
|
+
const sanitized = sanitizeUserMessage(msg, basePath);
|
|
236
|
+
expect(msg.cwd).toBe('/Users/testuser/project/src');
|
|
237
|
+
expect(sanitized.cwd).toBe('src');
|
|
238
|
+
});
|
|
239
|
+
it('should handle empty basePath', () => {
|
|
240
|
+
const msg = {
|
|
241
|
+
type: 'user',
|
|
242
|
+
uuid: 'msg-1',
|
|
243
|
+
sessionId: 'session-1',
|
|
244
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
245
|
+
parentUuid: null,
|
|
246
|
+
message: { role: 'user', content: 'Hello' },
|
|
247
|
+
cwd: '/Users/testuser/project',
|
|
248
|
+
version: '1.0',
|
|
249
|
+
};
|
|
250
|
+
const sanitized = sanitizeUserMessage(msg, '');
|
|
251
|
+
expect(sanitized.cwd).toBe('/Users/testuser/project');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
describe('sanitizeFileHistorySnapshot', () => {
|
|
255
|
+
const basePath = '/Users/testuser/project';
|
|
256
|
+
it('should sanitize all file paths', () => {
|
|
257
|
+
const msg = {
|
|
258
|
+
type: 'file-history-snapshot',
|
|
259
|
+
uuid: 'msg-1',
|
|
260
|
+
sessionId: 'session-1',
|
|
261
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
262
|
+
parentUuid: null,
|
|
263
|
+
isSnapshotUpdate: false,
|
|
264
|
+
snapshot: {
|
|
265
|
+
files: [
|
|
266
|
+
{ path: '/Users/testuser/project/src/index.ts' },
|
|
267
|
+
{ path: '/Users/testuser/project/src/utils.ts' },
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
const sanitized = sanitizeFileHistorySnapshot(msg, basePath);
|
|
272
|
+
expect(sanitized.snapshot.files[0].path).toBe('src/index.ts');
|
|
273
|
+
expect(sanitized.snapshot.files[1].path).toBe('src/utils.ts');
|
|
274
|
+
});
|
|
275
|
+
it('should handle empty files array', () => {
|
|
276
|
+
const msg = {
|
|
277
|
+
type: 'file-history-snapshot',
|
|
278
|
+
uuid: 'msg-1',
|
|
279
|
+
sessionId: 'session-1',
|
|
280
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
281
|
+
parentUuid: null,
|
|
282
|
+
isSnapshotUpdate: false,
|
|
283
|
+
snapshot: {
|
|
284
|
+
files: [],
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
const sanitized = sanitizeFileHistorySnapshot(msg, basePath);
|
|
288
|
+
expect(sanitized.snapshot.files).toEqual([]);
|
|
289
|
+
});
|
|
290
|
+
it('should leave external paths unchanged', () => {
|
|
291
|
+
const msg = {
|
|
292
|
+
type: 'file-history-snapshot',
|
|
293
|
+
uuid: 'msg-1',
|
|
294
|
+
sessionId: 'session-1',
|
|
295
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
296
|
+
parentUuid: null,
|
|
297
|
+
isSnapshotUpdate: false,
|
|
298
|
+
snapshot: {
|
|
299
|
+
files: [
|
|
300
|
+
{ path: '/Users/testuser/project/src/index.ts' },
|
|
301
|
+
{ path: '/opt/external/lib.ts' },
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
const sanitized = sanitizeFileHistorySnapshot(msg, basePath);
|
|
306
|
+
expect(sanitized.snapshot.files[0].path).toBe('src/index.ts');
|
|
307
|
+
expect(sanitized.snapshot.files[1].path).toBe('/opt/external/lib.ts');
|
|
308
|
+
});
|
|
309
|
+
it('should preserve original message (immutable)', () => {
|
|
310
|
+
const msg = {
|
|
311
|
+
type: 'file-history-snapshot',
|
|
312
|
+
uuid: 'msg-1',
|
|
313
|
+
sessionId: 'session-1',
|
|
314
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
315
|
+
parentUuid: null,
|
|
316
|
+
isSnapshotUpdate: false,
|
|
317
|
+
snapshot: {
|
|
318
|
+
files: [{ path: '/Users/testuser/project/src/index.ts' }],
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
const sanitized = sanitizeFileHistorySnapshot(msg, basePath);
|
|
322
|
+
expect(msg.snapshot.files[0].path).toBe('/Users/testuser/project/src/index.ts');
|
|
323
|
+
expect(sanitized.snapshot.files[0].path).toBe('src/index.ts');
|
|
324
|
+
});
|
|
325
|
+
it('should handle mix of relative and absolute paths', () => {
|
|
326
|
+
const msg = {
|
|
327
|
+
type: 'file-history-snapshot',
|
|
328
|
+
uuid: 'msg-1',
|
|
329
|
+
sessionId: 'session-1',
|
|
330
|
+
timestamp: '2026-01-11T12:00:00Z',
|
|
331
|
+
parentUuid: null,
|
|
332
|
+
isSnapshotUpdate: false,
|
|
333
|
+
snapshot: {
|
|
334
|
+
files: [
|
|
335
|
+
{ path: '/Users/testuser/project/src/index.ts' },
|
|
336
|
+
{ path: 'src/already-relative.ts' },
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
const sanitized = sanitizeFileHistorySnapshot(msg, basePath);
|
|
341
|
+
expect(sanitized.snapshot.files[0].path).toBe('src/index.ts');
|
|
342
|
+
// Already relative path should remain unchanged
|
|
343
|
+
expect(sanitized.snapshot.files[1].path).toBe('src/already-relative.ts');
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for session import service
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
5
|
+
import { importSession } from '../services/session-importer.js';
|
|
6
|
+
import * as gistClient from '../gist/client.js';
|
|
7
|
+
import * as sessionWriter from '../session/writer.js';
|
|
8
|
+
describe('importSession', () => {
|
|
9
|
+
const mockGist = {
|
|
10
|
+
id: 'abc123',
|
|
11
|
+
url: 'https://api.github.com/gists/abc123',
|
|
12
|
+
html_url: 'https://gist.github.com/user/abc123',
|
|
13
|
+
files: {
|
|
14
|
+
'session.jsonl': {
|
|
15
|
+
filename: 'session.jsonl',
|
|
16
|
+
type: 'text/plain',
|
|
17
|
+
language: 'JSON',
|
|
18
|
+
raw_url: 'https://gist.githubusercontent.com/user/abc123/raw/session.jsonl',
|
|
19
|
+
size: 100,
|
|
20
|
+
content: '{"type":"user","uuid":"uuid1","sessionId":"session1","parentUuid":null,"message":"Hello","timestamp":"2024-01-01T00:00:00Z"}\n' +
|
|
21
|
+
'{"type":"assistant","uuid":"uuid2","sessionId":"session1","parentUuid":"uuid1","message":"Hi","timestamp":"2024-01-01T00:00:01Z"}',
|
|
22
|
+
},
|
|
23
|
+
'metadata.json': {
|
|
24
|
+
filename: 'metadata.json',
|
|
25
|
+
type: 'text/plain',
|
|
26
|
+
language: 'JSON',
|
|
27
|
+
raw_url: 'https://gist.githubusercontent.com/user/abc123/raw/metadata.json',
|
|
28
|
+
size: 30,
|
|
29
|
+
content: '{"title":"Test Session"}',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
public: false,
|
|
33
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
34
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
35
|
+
description: 'Test Gist',
|
|
36
|
+
};
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
process.env.GITHUB_TOKEN = 'test_token';
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
});
|
|
41
|
+
// Helper to mock GistClient
|
|
42
|
+
function mockGistClient(fetchGistFn) {
|
|
43
|
+
vi.spyOn(gistClient, 'GistClient').mockImplementation(function () {
|
|
44
|
+
this.fetchGist = fetchGistFn;
|
|
45
|
+
this.createGist = vi.fn();
|
|
46
|
+
this.getOctokit = vi.fn();
|
|
47
|
+
return this;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
describe('successful import', () => {
|
|
51
|
+
it('should orchestrate full import pipeline', async () => {
|
|
52
|
+
const mockFetchGist = vi.fn().mockResolvedValue(mockGist);
|
|
53
|
+
mockGistClient(mockFetchGist);
|
|
54
|
+
const mockWriteSession = vi.spyOn(sessionWriter, 'writeSessionToLocal').mockResolvedValue({
|
|
55
|
+
filePath: '/Users/test/.claude/projects/encoded/session-id.jsonl',
|
|
56
|
+
sessionId: 'new-session-id',
|
|
57
|
+
});
|
|
58
|
+
const result = await importSession('https://gist.github.com/user/abc123', '/Users/test/project');
|
|
59
|
+
expect(result).toEqual({
|
|
60
|
+
sessionPath: '/Users/test/.claude/projects/encoded/session-id.jsonl',
|
|
61
|
+
sessionId: 'new-session-id',
|
|
62
|
+
messageCount: 2,
|
|
63
|
+
projectPath: '/Users/test/project',
|
|
64
|
+
});
|
|
65
|
+
// Verify fetchGist was called with correct URL
|
|
66
|
+
expect(mockFetchGist).toHaveBeenCalledWith('https://gist.github.com/user/abc123');
|
|
67
|
+
// Verify writeSessionToLocal was called
|
|
68
|
+
expect(mockWriteSession).toHaveBeenCalledWith(expect.any(Array), '/Users/test/project');
|
|
69
|
+
// Verify messages were remapped (UUIDs should be different)
|
|
70
|
+
const writtenMessages = mockWriteSession.mock.calls[0][0];
|
|
71
|
+
expect(writtenMessages).toHaveLength(2);
|
|
72
|
+
expect(writtenMessages[0].uuid).not.toBe('uuid1');
|
|
73
|
+
expect(writtenMessages[1].uuid).not.toBe('uuid2');
|
|
74
|
+
expect(writtenMessages[0].sessionId).not.toBe('session1');
|
|
75
|
+
expect(writtenMessages[1].sessionId).not.toBe('session1');
|
|
76
|
+
});
|
|
77
|
+
it('should extract JSONL file from gist', async () => {
|
|
78
|
+
const mockFetchGist = vi.fn().mockResolvedValue(mockGist);
|
|
79
|
+
mockGistClient(mockFetchGist);
|
|
80
|
+
const mockWriteSession = vi.spyOn(sessionWriter, 'writeSessionToLocal').mockResolvedValue({
|
|
81
|
+
filePath: '/Users/test/.claude/projects/encoded/session-id.jsonl',
|
|
82
|
+
sessionId: 'new-session-id',
|
|
83
|
+
});
|
|
84
|
+
await importSession('abc123', '/Users/test/project');
|
|
85
|
+
// Verify writeSessionToLocal received parsed messages
|
|
86
|
+
const writtenMessages = mockWriteSession.mock.calls[0][0];
|
|
87
|
+
expect(writtenMessages).toHaveLength(2);
|
|
88
|
+
expect(writtenMessages[0].type).toBe('user');
|
|
89
|
+
// User messages have 'message' field with role/content
|
|
90
|
+
expect(writtenMessages[1].type).toBe('assistant');
|
|
91
|
+
});
|
|
92
|
+
it('should remap UUIDs consistently', async () => {
|
|
93
|
+
const mockFetchGist = vi.fn().mockResolvedValue(mockGist);
|
|
94
|
+
mockGistClient(mockFetchGist);
|
|
95
|
+
const mockWriteSession = vi.spyOn(sessionWriter, 'writeSessionToLocal').mockResolvedValue({
|
|
96
|
+
filePath: '/Users/test/.claude/projects/encoded/session-id.jsonl',
|
|
97
|
+
sessionId: 'new-session-id',
|
|
98
|
+
});
|
|
99
|
+
await importSession('abc123', '/Users/test/project');
|
|
100
|
+
const writtenMessages = mockWriteSession.mock.calls[0][0];
|
|
101
|
+
// First message parent should be remapped consistently
|
|
102
|
+
expect(writtenMessages[0].parentUuid).toBeNull();
|
|
103
|
+
// Second message parent should reference first message's remapped UUID
|
|
104
|
+
expect(writtenMessages[1].parentUuid).toBe(writtenMessages[0].uuid);
|
|
105
|
+
// Session IDs should be consistently remapped
|
|
106
|
+
expect(writtenMessages[0].sessionId).toBe(writtenMessages[1].sessionId);
|
|
107
|
+
});
|
|
108
|
+
it('should handle bare gist ID (not URL)', async () => {
|
|
109
|
+
const mockFetchGist = vi.fn().mockResolvedValue(mockGist);
|
|
110
|
+
mockGistClient(mockFetchGist);
|
|
111
|
+
vi.spyOn(sessionWriter, 'writeSessionToLocal').mockResolvedValue({
|
|
112
|
+
filePath: '/Users/test/.claude/projects/encoded/session-id.jsonl',
|
|
113
|
+
sessionId: 'new-session-id',
|
|
114
|
+
});
|
|
115
|
+
await importSession('abc123', '/Users/test/project');
|
|
116
|
+
expect(mockFetchGist).toHaveBeenCalledWith('abc123');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('error handling', () => {
|
|
120
|
+
it('should throw error if no JSONL file in gist', async () => {
|
|
121
|
+
const gistWithoutJsonl = {
|
|
122
|
+
...mockGist,
|
|
123
|
+
files: {
|
|
124
|
+
'metadata.json': {
|
|
125
|
+
filename: 'metadata.json',
|
|
126
|
+
type: 'text/plain',
|
|
127
|
+
language: 'JSON',
|
|
128
|
+
raw_url: 'https://gist.githubusercontent.com/user/abc123/raw/metadata.json',
|
|
129
|
+
size: 20,
|
|
130
|
+
content: '{"title":"Test"}',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
const mockFetchGist = vi.fn().mockResolvedValue(gistWithoutJsonl);
|
|
135
|
+
mockGistClient(mockFetchGist);
|
|
136
|
+
await expect(importSession('abc123', '/Users/test/project')).rejects.toThrow('No JSONL file found in gist. Expected a .jsonl file containing session messages.');
|
|
137
|
+
});
|
|
138
|
+
it('should throw error if JSONL file has no content', async () => {
|
|
139
|
+
const gistWithEmptyJsonl = {
|
|
140
|
+
...mockGist,
|
|
141
|
+
files: {
|
|
142
|
+
'session.jsonl': {
|
|
143
|
+
filename: 'session.jsonl',
|
|
144
|
+
type: 'text/plain',
|
|
145
|
+
language: 'JSON',
|
|
146
|
+
raw_url: 'https://gist.githubusercontent.com/user/abc123/raw/session.jsonl',
|
|
147
|
+
size: 0,
|
|
148
|
+
content: '',
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
const mockFetchGist = vi.fn().mockResolvedValue(gistWithEmptyJsonl);
|
|
153
|
+
mockGistClient(mockFetchGist);
|
|
154
|
+
await expect(importSession('abc123', '/Users/test/project')).rejects.toThrow('JSONL file "session.jsonl" has no content');
|
|
155
|
+
});
|
|
156
|
+
it('should handle gist not found (404)', async () => {
|
|
157
|
+
const notFoundError = new Error('Gist not found');
|
|
158
|
+
notFoundError.status = 404;
|
|
159
|
+
const mockFetchGist = vi.fn().mockRejectedValue(notFoundError);
|
|
160
|
+
mockGistClient(mockFetchGist);
|
|
161
|
+
await expect(importSession('nonexistent', '/Users/test/project')).rejects.toThrow('Failed to import session: Gist not found');
|
|
162
|
+
});
|
|
163
|
+
it('should recover from parse errors on individual lines', async () => {
|
|
164
|
+
const gistWithBadLines = {
|
|
165
|
+
...mockGist,
|
|
166
|
+
files: {
|
|
167
|
+
'session.jsonl': {
|
|
168
|
+
filename: 'session.jsonl',
|
|
169
|
+
type: 'text/plain',
|
|
170
|
+
language: 'JSON',
|
|
171
|
+
raw_url: 'https://gist.githubusercontent.com/user/abc123/raw/session.jsonl',
|
|
172
|
+
size: 150,
|
|
173
|
+
content: '{"type":"user","uuid":"uuid1","sessionId":"session1","parentUuid":null,"message":"Hello","timestamp":"2024-01-01T00:00:00Z"}\n' +
|
|
174
|
+
'invalid json line\n' +
|
|
175
|
+
'{"type":"assistant","uuid":"uuid2","sessionId":"session1","parentUuid":"uuid1","message":"Hi","timestamp":"2024-01-01T00:00:01Z"}',
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const mockFetchGist = vi.fn().mockResolvedValue(gistWithBadLines);
|
|
180
|
+
mockGistClient(mockFetchGist);
|
|
181
|
+
const mockWriteSession = vi.spyOn(sessionWriter, 'writeSessionToLocal').mockResolvedValue({
|
|
182
|
+
filePath: '/Users/test/.claude/projects/encoded/session-id.jsonl',
|
|
183
|
+
sessionId: 'new-session-id',
|
|
184
|
+
});
|
|
185
|
+
const result = await importSession('abc123', '/Users/test/project');
|
|
186
|
+
// Should import 2 valid messages despite parse error
|
|
187
|
+
expect(result.messageCount).toBe(2);
|
|
188
|
+
const writtenMessages = mockWriteSession.mock.calls[0][0];
|
|
189
|
+
expect(writtenMessages).toHaveLength(2);
|
|
190
|
+
});
|
|
191
|
+
it('should throw error if all lines fail to parse', async () => {
|
|
192
|
+
const gistWithAllBadLines = {
|
|
193
|
+
...mockGist,
|
|
194
|
+
files: {
|
|
195
|
+
'session.jsonl': {
|
|
196
|
+
filename: 'session.jsonl',
|
|
197
|
+
type: 'text/plain',
|
|
198
|
+
language: 'JSON',
|
|
199
|
+
raw_url: 'https://gist.githubusercontent.com/user/abc123/raw/session.jsonl',
|
|
200
|
+
size: 40,
|
|
201
|
+
content: 'invalid json\n{bad syntax\nmore bad json',
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const mockFetchGist = vi.fn().mockResolvedValue(gistWithAllBadLines);
|
|
206
|
+
mockGistClient(mockFetchGist);
|
|
207
|
+
await expect(importSession('abc123', '/Users/test/project')).rejects.toThrow('No valid messages found in JSONL file');
|
|
208
|
+
});
|
|
209
|
+
it('should propagate write errors', async () => {
|
|
210
|
+
const mockFetchGist = vi.fn().mockResolvedValue(mockGist);
|
|
211
|
+
mockGistClient(mockFetchGist);
|
|
212
|
+
vi.spyOn(sessionWriter, 'writeSessionToLocal').mockRejectedValue(new Error('Permission denied'));
|
|
213
|
+
await expect(importSession('abc123', '/Users/test/project')).rejects.toThrow('Failed to import session: Permission denied');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|