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,298 @@
1
+ /**
2
+ * Tests for session reading pipeline
3
+ *
4
+ * Validates JSONL parsing, error recovery, and metadata extraction.
5
+ */
6
+ import { describe, it, expect } from 'vitest';
7
+ import { parseSessionFile } from '../session/reader.js';
8
+ import { extractMetadata } from '../session/metadata.js';
9
+ import { writeFile, unlink } from 'fs/promises';
10
+ import { join } from 'path';
11
+ import { tmpdir } from 'os';
12
+ describe('session-reader', () => {
13
+ describe('parseSessionFile', () => {
14
+ it('parses valid JSONL session file', async () => {
15
+ // Create minimal test fixture with 3 lines
16
+ const testData = [
17
+ {
18
+ uuid: 'msg-1',
19
+ sessionId: 'session-123',
20
+ timestamp: '2026-01-11T10:00:00.000Z',
21
+ parentUuid: null,
22
+ type: 'user',
23
+ message: { role: 'user', content: 'Hello' },
24
+ cwd: '/Users/test/project',
25
+ version: '1.0.0',
26
+ },
27
+ {
28
+ uuid: 'msg-2',
29
+ sessionId: 'session-123',
30
+ timestamp: '2026-01-11T10:01:00.000Z',
31
+ parentUuid: 'msg-1',
32
+ type: 'assistant',
33
+ messageId: 'assist-1',
34
+ snapshot: { thinking: null, messages: [] },
35
+ },
36
+ {
37
+ uuid: 'msg-3',
38
+ sessionId: 'session-123',
39
+ timestamp: '2026-01-11T10:02:00.000Z',
40
+ parentUuid: 'msg-2',
41
+ type: 'file-history-snapshot',
42
+ isSnapshotUpdate: true,
43
+ snapshot: { files: [] },
44
+ },
45
+ ];
46
+ // Write test JSONL file
47
+ const tmpFile = join(tmpdir(), `test-session-${Date.now()}.jsonl`);
48
+ const jsonlContent = testData.map(obj => JSON.stringify(obj)).join('\n');
49
+ await writeFile(tmpFile, jsonlContent, 'utf-8');
50
+ try {
51
+ // Parse the file
52
+ const messages = await parseSessionFile(tmpFile);
53
+ // Verify all messages parsed
54
+ expect(messages).toHaveLength(3);
55
+ expect(messages[0].type).toBe('user');
56
+ expect(messages[1].type).toBe('assistant');
57
+ expect(messages[2].type).toBe('file-history-snapshot');
58
+ expect(messages[0].uuid).toBe('msg-1');
59
+ expect(messages[0].sessionId).toBe('session-123');
60
+ }
61
+ finally {
62
+ // Cleanup
63
+ await unlink(tmpFile);
64
+ }
65
+ });
66
+ it('recovers from malformed JSON line and continues processing', async () => {
67
+ // Create JSONL with one malformed line
68
+ const lines = [
69
+ JSON.stringify({
70
+ uuid: 'msg-1',
71
+ sessionId: 'session-123',
72
+ timestamp: '2026-01-11T10:00:00.000Z',
73
+ parentUuid: null,
74
+ type: 'user',
75
+ message: { role: 'user', content: 'First' },
76
+ cwd: '/test',
77
+ version: '1.0.0',
78
+ }),
79
+ '{ invalid json here }', // Malformed line
80
+ JSON.stringify({
81
+ uuid: 'msg-3',
82
+ sessionId: 'session-123',
83
+ timestamp: '2026-01-11T10:02:00.000Z',
84
+ parentUuid: 'msg-1',
85
+ type: 'user',
86
+ message: { role: 'user', content: 'Third' },
87
+ cwd: '/test',
88
+ version: '1.0.0',
89
+ }),
90
+ ];
91
+ const tmpFile = join(tmpdir(), `test-error-recovery-${Date.now()}.jsonl`);
92
+ await writeFile(tmpFile, lines.join('\n'), 'utf-8');
93
+ try {
94
+ const messages = await parseSessionFile(tmpFile);
95
+ // Should have 2 messages (malformed line skipped)
96
+ expect(messages).toHaveLength(2);
97
+ expect(messages[0].uuid).toBe('msg-1');
98
+ expect(messages[1].uuid).toBe('msg-3');
99
+ }
100
+ finally {
101
+ await unlink(tmpFile);
102
+ }
103
+ });
104
+ it('skips empty lines without breaking parsing', async () => {
105
+ const lines = [
106
+ JSON.stringify({
107
+ uuid: 'msg-1',
108
+ sessionId: 'session-123',
109
+ timestamp: '2026-01-11T10:00:00.000Z',
110
+ parentUuid: null,
111
+ type: 'user',
112
+ message: { role: 'user', content: 'First' },
113
+ cwd: '/test',
114
+ version: '1.0.0',
115
+ }),
116
+ '', // Empty line
117
+ ' ', // Whitespace-only line
118
+ JSON.stringify({
119
+ uuid: 'msg-2',
120
+ sessionId: 'session-123',
121
+ timestamp: '2026-01-11T10:01:00.000Z',
122
+ parentUuid: 'msg-1',
123
+ type: 'user',
124
+ message: { role: 'user', content: 'Second' },
125
+ cwd: '/test',
126
+ version: '1.0.0',
127
+ }),
128
+ ];
129
+ const tmpFile = join(tmpdir(), `test-empty-lines-${Date.now()}.jsonl`);
130
+ await writeFile(tmpFile, lines.join('\n'), 'utf-8');
131
+ try {
132
+ const messages = await parseSessionFile(tmpFile);
133
+ // Should have 2 messages (empty lines skipped)
134
+ expect(messages).toHaveLength(2);
135
+ expect(messages[0].uuid).toBe('msg-1');
136
+ expect(messages[1].uuid).toBe('msg-2');
137
+ }
138
+ finally {
139
+ await unlink(tmpFile);
140
+ }
141
+ });
142
+ it('skips messages missing required fields', async () => {
143
+ const lines = [
144
+ JSON.stringify({
145
+ uuid: 'msg-1',
146
+ sessionId: 'session-123',
147
+ timestamp: '2026-01-11T10:00:00.000Z',
148
+ parentUuid: null,
149
+ type: 'user',
150
+ message: { role: 'user', content: 'Valid' },
151
+ cwd: '/test',
152
+ version: '1.0.0',
153
+ }),
154
+ JSON.stringify({
155
+ // Missing uuid
156
+ sessionId: 'session-123',
157
+ timestamp: '2026-01-11T10:01:00.000Z',
158
+ type: 'user',
159
+ }),
160
+ JSON.stringify({
161
+ // Missing type
162
+ uuid: 'msg-3',
163
+ sessionId: 'session-123',
164
+ timestamp: '2026-01-11T10:02:00.000Z',
165
+ }),
166
+ JSON.stringify({
167
+ uuid: 'msg-4',
168
+ sessionId: 'session-123',
169
+ timestamp: '2026-01-11T10:03:00.000Z',
170
+ parentUuid: 'msg-1',
171
+ type: 'user',
172
+ message: { role: 'user', content: 'Also valid' },
173
+ cwd: '/test',
174
+ version: '1.0.0',
175
+ }),
176
+ ];
177
+ const tmpFile = join(tmpdir(), `test-missing-fields-${Date.now()}.jsonl`);
178
+ await writeFile(tmpFile, lines.join('\n'), 'utf-8');
179
+ try {
180
+ const messages = await parseSessionFile(tmpFile);
181
+ // Should have 2 valid messages (2 with missing fields skipped)
182
+ expect(messages).toHaveLength(2);
183
+ expect(messages[0].uuid).toBe('msg-1');
184
+ expect(messages[1].uuid).toBe('msg-4');
185
+ }
186
+ finally {
187
+ await unlink(tmpFile);
188
+ }
189
+ });
190
+ });
191
+ describe('extractMetadata', () => {
192
+ it('extracts all fields correctly from valid messages', () => {
193
+ const messages = [
194
+ {
195
+ uuid: 'msg-1',
196
+ sessionId: 'session-abc',
197
+ timestamp: '2026-01-11T10:00:00.000Z',
198
+ parentUuid: null,
199
+ type: 'user',
200
+ message: { role: 'user', content: 'Hello' },
201
+ cwd: '/Users/test/project',
202
+ version: '1.2.3',
203
+ },
204
+ {
205
+ uuid: 'msg-2',
206
+ sessionId: 'session-abc',
207
+ timestamp: '2026-01-11T10:05:00.000Z',
208
+ parentUuid: 'msg-1',
209
+ type: 'assistant',
210
+ messageId: 'assist-1',
211
+ snapshot: { thinking: null, messages: [] },
212
+ },
213
+ ];
214
+ const metadata = extractMetadata(messages);
215
+ expect(metadata).not.toBeNull();
216
+ expect(metadata?.sessionId).toBe('session-abc');
217
+ expect(metadata?.projectPath).toBe('/Users/test/project');
218
+ expect(metadata?.messageCount).toBe(2);
219
+ expect(metadata?.firstTimestamp).toBe('2026-01-11T10:00:00.000Z');
220
+ expect(metadata?.lastTimestamp).toBe('2026-01-11T10:05:00.000Z');
221
+ expect(metadata?.hasAgentConversations).toBe(false);
222
+ expect(metadata?.version).toBe('1.2.3');
223
+ });
224
+ it('detects agent conversations from isSidechain flag', () => {
225
+ const messages = [
226
+ {
227
+ uuid: 'msg-1',
228
+ sessionId: 'session-abc',
229
+ timestamp: '2026-01-11T10:00:00.000Z',
230
+ parentUuid: null,
231
+ type: 'user',
232
+ message: { role: 'user', content: 'Hello' },
233
+ cwd: '/test',
234
+ version: '1.0.0',
235
+ },
236
+ {
237
+ uuid: 'msg-2',
238
+ sessionId: 'session-abc',
239
+ timestamp: '2026-01-11T10:01:00.000Z',
240
+ parentUuid: 'msg-1',
241
+ isSidechain: true, // Agent conversation marker
242
+ type: 'assistant',
243
+ messageId: 'assist-1',
244
+ snapshot: { thinking: null, messages: [] },
245
+ },
246
+ ];
247
+ const metadata = extractMetadata(messages);
248
+ expect(metadata?.hasAgentConversations).toBe(true);
249
+ });
250
+ it('returns null for empty message array', () => {
251
+ const metadata = extractMetadata([]);
252
+ expect(metadata).toBeNull();
253
+ });
254
+ it('handles missing cwd and version with fallback', () => {
255
+ const messages = [
256
+ {
257
+ uuid: 'msg-1',
258
+ sessionId: 'session-abc',
259
+ timestamp: '2026-01-11T10:00:00.000Z',
260
+ parentUuid: null,
261
+ type: 'assistant',
262
+ messageId: 'assist-1',
263
+ snapshot: { thinking: null, messages: [] },
264
+ },
265
+ ];
266
+ const metadata = extractMetadata(messages);
267
+ expect(metadata).not.toBeNull();
268
+ expect(metadata?.projectPath).toBe('unknown');
269
+ expect(metadata?.version).toBe('unknown');
270
+ });
271
+ it('finds user message even if not first message', () => {
272
+ const messages = [
273
+ {
274
+ uuid: 'msg-1',
275
+ sessionId: 'session-abc',
276
+ timestamp: '2026-01-11T10:00:00.000Z',
277
+ parentUuid: null,
278
+ type: 'assistant',
279
+ messageId: 'assist-1',
280
+ snapshot: { thinking: null, messages: [] },
281
+ },
282
+ {
283
+ uuid: 'msg-2',
284
+ sessionId: 'session-abc',
285
+ timestamp: '2026-01-11T10:01:00.000Z',
286
+ parentUuid: 'msg-1',
287
+ type: 'user',
288
+ message: { role: 'user', content: 'Hello' },
289
+ cwd: '/found/this/path',
290
+ version: '2.0.0',
291
+ },
292
+ ];
293
+ const metadata = extractMetadata(messages);
294
+ expect(metadata?.projectPath).toBe('/found/this/path');
295
+ expect(metadata?.version).toBe('2.0.0');
296
+ });
297
+ });
298
+ });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Tests for session-to-gist upload service
3
+ *
4
+ * Verifies orchestration of:
5
+ * - Session reading
6
+ * - Privacy sanitization
7
+ * - JSONL formatting
8
+ * - Metadata extraction
9
+ * - Gist upload
10
+ */
11
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
12
+ import { uploadSession } from '../services/session-uploader.js';
13
+ import * as reader from '../session/reader.js';
14
+ import * as metadata from '../session/metadata.js';
15
+ import * as pipeline from '../sanitization/pipeline.js';
16
+ import * as gistClient from '../gist/client.js';
17
+ describe('uploadSession', () => {
18
+ const mockSessionPath = '/test/session.jsonl';
19
+ const mockUserMessage = {
20
+ type: 'user',
21
+ uuid: 'user-1',
22
+ sessionId: 'test-session',
23
+ timestamp: '2026-01-12T10:00:00.000Z',
24
+ parentUuid: null,
25
+ message: { role: 'user', content: 'Hello' },
26
+ cwd: '/Users/test/project',
27
+ version: '1.0.0',
28
+ };
29
+ const mockAssistantMessage = {
30
+ type: 'assistant',
31
+ uuid: 'assistant-1',
32
+ sessionId: 'test-session',
33
+ timestamp: '2026-01-12T10:01:00.000Z',
34
+ parentUuid: 'user-1',
35
+ messageId: 'msg-1',
36
+ snapshot: {
37
+ thinking: 'Internal thinking',
38
+ messages: [{ role: 'assistant', content: 'Hi there!' }],
39
+ },
40
+ };
41
+ const mockMessages = [mockUserMessage, mockAssistantMessage];
42
+ const mockSanitizedMessages = [
43
+ { ...mockUserMessage, cwd: 'project' },
44
+ {
45
+ ...mockAssistantMessage,
46
+ snapshot: {
47
+ thinking: null,
48
+ messages: [{ role: 'assistant', content: 'Hi there!' }],
49
+ },
50
+ },
51
+ ];
52
+ const mockMetadata = {
53
+ sessionId: 'test-session',
54
+ projectPath: '/Users/test/project',
55
+ messageCount: 2,
56
+ firstTimestamp: '2026-01-12T10:00:00.000Z',
57
+ lastTimestamp: '2026-01-12T10:01:00.000Z',
58
+ hasAgentConversations: false,
59
+ version: '1.0.0',
60
+ };
61
+ const mockGistResponse = {
62
+ id: 'gist123',
63
+ url: 'https://api.github.com/gists/gist123',
64
+ html_url: 'https://gist.github.com/user/gist123',
65
+ files: {},
66
+ public: false,
67
+ created_at: '2026-01-12T10:00:00Z',
68
+ updated_at: '2026-01-12T10:00:00Z',
69
+ description: 'Test gist',
70
+ };
71
+ // Set up environment variable before tests
72
+ beforeEach(() => {
73
+ process.env.GITHUB_TOKEN = 'test_token';
74
+ vi.clearAllMocks();
75
+ });
76
+ // Helper to mock GistClient
77
+ function mockGistClient(createGistFn) {
78
+ vi.spyOn(gistClient, 'GistClient').mockImplementation(function () {
79
+ this.createGist = createGistFn;
80
+ this.getOctokit = vi.fn();
81
+ return this;
82
+ });
83
+ }
84
+ describe('successful upload', () => {
85
+ it('should orchestrate full upload pipeline and return gist URL', async () => {
86
+ // Mock all dependencies
87
+ const parseSessionFileSpy = vi.spyOn(reader, 'parseSessionFile').mockResolvedValue(mockMessages);
88
+ const inferBasePathSpy = vi.spyOn(pipeline, 'inferBasePath').mockReturnValue('/Users/test/project');
89
+ const sanitizeSessionSpy = vi.spyOn(pipeline, 'sanitizeSession').mockReturnValue(mockSanitizedMessages);
90
+ const extractMetadataSpy = vi.spyOn(metadata, 'extractMetadata').mockReturnValue(mockMetadata);
91
+ const mockCreateGist = vi.fn().mockResolvedValue(mockGistResponse);
92
+ mockGistClient(mockCreateGist);
93
+ // Execute
94
+ const result = await uploadSession(mockSessionPath);
95
+ // Verify correct call sequence
96
+ expect(parseSessionFileSpy).toHaveBeenCalledWith(mockSessionPath);
97
+ expect(inferBasePathSpy).toHaveBeenCalledWith(mockMessages);
98
+ expect(sanitizeSessionSpy).toHaveBeenCalledWith(mockMessages, '/Users/test/project');
99
+ expect(extractMetadataSpy).toHaveBeenCalledWith(mockSanitizedMessages);
100
+ expect(mockCreateGist).toHaveBeenCalled();
101
+ // Verify result
102
+ expect(result).toBe('https://gist.github.com/user/gist123');
103
+ });
104
+ it('should format JSONL correctly (one message per line)', async () => {
105
+ vi.spyOn(reader, 'parseSessionFile').mockResolvedValue(mockMessages);
106
+ vi.spyOn(pipeline, 'inferBasePath').mockReturnValue('/Users/test/project');
107
+ vi.spyOn(pipeline, 'sanitizeSession').mockReturnValue(mockSanitizedMessages);
108
+ vi.spyOn(metadata, 'extractMetadata').mockReturnValue(mockMetadata);
109
+ const mockCreateGist = vi.fn().mockResolvedValue(mockGistResponse);
110
+ mockGistClient(mockCreateGist);
111
+ await uploadSession(mockSessionPath);
112
+ // Verify JSONL format
113
+ const createGistCall = mockCreateGist.mock.calls[0];
114
+ const files = createGistCall[1];
115
+ const sessionJsonl = files['session.jsonl'];
116
+ // Should have one JSON object per line
117
+ const lines = sessionJsonl.split('\n');
118
+ expect(lines).toHaveLength(2);
119
+ // Each line should be valid JSON
120
+ const parsed1 = JSON.parse(lines[0]);
121
+ const parsed2 = JSON.parse(lines[1]);
122
+ expect(parsed1.type).toBe('user');
123
+ expect(parsed2.type).toBe('assistant');
124
+ });
125
+ it('should include metadata.json with correct structure', async () => {
126
+ vi.spyOn(reader, 'parseSessionFile').mockResolvedValue(mockMessages);
127
+ vi.spyOn(pipeline, 'inferBasePath').mockReturnValue('/Users/test/project');
128
+ vi.spyOn(pipeline, 'sanitizeSession').mockReturnValue(mockSanitizedMessages);
129
+ vi.spyOn(metadata, 'extractMetadata').mockReturnValue(mockMetadata);
130
+ const mockCreateGist = vi.fn().mockResolvedValue(mockGistResponse);
131
+ mockGistClient(mockCreateGist);
132
+ await uploadSession(mockSessionPath);
133
+ // Verify metadata file
134
+ const createGistCall = mockCreateGist.mock.calls[0];
135
+ const files = createGistCall[1];
136
+ const metadataJson = files['metadata.json'];
137
+ const parsedMetadata = JSON.parse(metadataJson);
138
+ expect(parsedMetadata).toEqual(mockMetadata);
139
+ });
140
+ it('should use project name in gist description when available', async () => {
141
+ vi.spyOn(reader, 'parseSessionFile').mockResolvedValue(mockMessages);
142
+ vi.spyOn(pipeline, 'inferBasePath').mockReturnValue('/Users/test/project');
143
+ vi.spyOn(pipeline, 'sanitizeSession').mockReturnValue(mockSanitizedMessages);
144
+ vi.spyOn(metadata, 'extractMetadata').mockReturnValue(mockMetadata);
145
+ const mockCreateGist = vi.fn().mockResolvedValue(mockGistResponse);
146
+ mockGistClient(mockCreateGist);
147
+ await uploadSession(mockSessionPath);
148
+ const createGistCall = mockCreateGist.mock.calls[0];
149
+ const description = createGistCall[0];
150
+ expect(description).toContain('project'); // Last path segment
151
+ });
152
+ it('should fallback to timestamp in description when no project path', async () => {
153
+ vi.spyOn(reader, 'parseSessionFile').mockResolvedValue(mockMessages);
154
+ vi.spyOn(pipeline, 'inferBasePath').mockReturnValue('');
155
+ vi.spyOn(pipeline, 'sanitizeSession').mockReturnValue(mockSanitizedMessages);
156
+ const metadataNoPath = { ...mockMetadata, projectPath: 'unknown' };
157
+ vi.spyOn(metadata, 'extractMetadata').mockReturnValue(metadataNoPath);
158
+ const mockCreateGist = vi.fn().mockResolvedValue(mockGistResponse);
159
+ mockGistClient(mockCreateGist);
160
+ await uploadSession(mockSessionPath);
161
+ const createGistCall = mockCreateGist.mock.calls[0];
162
+ const description = createGistCall[0];
163
+ expect(description).toContain('2026-01-12'); // Timestamp fallback
164
+ });
165
+ });
166
+ describe('error handling', () => {
167
+ it('should propagate error from parseSessionFile', async () => {
168
+ const parseError = new Error('File not found');
169
+ vi.spyOn(reader, 'parseSessionFile').mockRejectedValue(parseError);
170
+ await expect(uploadSession(mockSessionPath)).rejects.toThrow('Failed to upload session: File not found');
171
+ });
172
+ it('should throw error for empty session file', async () => {
173
+ vi.spyOn(reader, 'parseSessionFile').mockResolvedValue([]);
174
+ await expect(uploadSession(mockSessionPath)).rejects.toThrow('Session file is empty or contains no valid messages');
175
+ });
176
+ it('should propagate error from sanitizeSession', async () => {
177
+ vi.spyOn(reader, 'parseSessionFile').mockResolvedValue(mockMessages);
178
+ vi.spyOn(pipeline, 'inferBasePath').mockReturnValue('/Users/test/project');
179
+ vi.spyOn(pipeline, 'sanitizeSession').mockImplementation(() => {
180
+ throw new Error('Sanitization failed');
181
+ });
182
+ await expect(uploadSession(mockSessionPath)).rejects.toThrow('Failed to upload session: Sanitization failed');
183
+ });
184
+ it('should throw error when metadata extraction returns null', async () => {
185
+ vi.spyOn(reader, 'parseSessionFile').mockResolvedValue(mockMessages);
186
+ vi.spyOn(pipeline, 'inferBasePath').mockReturnValue('/Users/test/project');
187
+ vi.spyOn(pipeline, 'sanitizeSession').mockReturnValue(mockSanitizedMessages);
188
+ vi.spyOn(metadata, 'extractMetadata').mockReturnValue(null);
189
+ await expect(uploadSession(mockSessionPath)).rejects.toThrow('Failed to extract session metadata');
190
+ });
191
+ it('should propagate error from GistClient.createGist', async () => {
192
+ vi.spyOn(reader, 'parseSessionFile').mockResolvedValue(mockMessages);
193
+ vi.spyOn(pipeline, 'inferBasePath').mockReturnValue('/Users/test/project');
194
+ vi.spyOn(pipeline, 'sanitizeSession').mockReturnValue(mockSanitizedMessages);
195
+ vi.spyOn(metadata, 'extractMetadata').mockReturnValue(mockMetadata);
196
+ const gistError = new Error('GitHub API rate limit exceeded');
197
+ const mockCreateGist = vi.fn().mockRejectedValue(gistError);
198
+ mockGistClient(mockCreateGist);
199
+ await expect(uploadSession(mockSessionPath)).rejects.toThrow('Failed to upload session: GitHub API rate limit exceeded');
200
+ });
201
+ });
202
+ describe('immutability', () => {
203
+ it('should not modify original messages array', async () => {
204
+ const originalMessages = [...mockMessages];
205
+ vi.spyOn(reader, 'parseSessionFile').mockResolvedValue(mockMessages);
206
+ vi.spyOn(pipeline, 'inferBasePath').mockReturnValue('/Users/test/project');
207
+ vi.spyOn(pipeline, 'sanitizeSession').mockReturnValue(mockSanitizedMessages);
208
+ vi.spyOn(metadata, 'extractMetadata').mockReturnValue(mockMetadata);
209
+ const mockCreateGist = vi.fn().mockResolvedValue(mockGistResponse);
210
+ mockGistClient(mockCreateGist);
211
+ await uploadSession(mockSessionPath);
212
+ // Verify original not mutated
213
+ expect(mockMessages).toEqual(originalMessages);
214
+ });
215
+ });
216
+ });