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,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
|
+
});
|