@savestate/cli 0.7.0 → 0.8.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.
Files changed (89) hide show
  1. package/dist/cli/__tests__/progress.test.d.ts +5 -0
  2. package/dist/cli/__tests__/progress.test.d.ts.map +1 -0
  3. package/dist/cli/__tests__/progress.test.js +212 -0
  4. package/dist/cli/__tests__/progress.test.js.map +1 -0
  5. package/dist/cli/__tests__/signal-handler.test.d.ts +5 -0
  6. package/dist/cli/__tests__/signal-handler.test.d.ts.map +1 -0
  7. package/dist/cli/__tests__/signal-handler.test.js +99 -0
  8. package/dist/cli/__tests__/signal-handler.test.js.map +1 -0
  9. package/dist/cli/__tests__/summary.test.d.ts +5 -0
  10. package/dist/cli/__tests__/summary.test.d.ts.map +1 -0
  11. package/dist/cli/__tests__/summary.test.js +242 -0
  12. package/dist/cli/__tests__/summary.test.js.map +1 -0
  13. package/dist/cli/index.d.ts +10 -0
  14. package/dist/cli/index.d.ts.map +1 -0
  15. package/dist/cli/index.js +10 -0
  16. package/dist/cli/index.js.map +1 -0
  17. package/dist/cli/progress.d.ts +86 -0
  18. package/dist/cli/progress.d.ts.map +1 -0
  19. package/dist/cli/progress.js +205 -0
  20. package/dist/cli/progress.js.map +1 -0
  21. package/dist/cli/prompts.d.ts +49 -0
  22. package/dist/cli/prompts.d.ts.map +1 -0
  23. package/dist/cli/prompts.js +266 -0
  24. package/dist/cli/prompts.js.map +1 -0
  25. package/dist/cli/signal-handler.d.ts +63 -0
  26. package/dist/cli/signal-handler.d.ts.map +1 -0
  27. package/dist/cli/signal-handler.js +165 -0
  28. package/dist/cli/signal-handler.js.map +1 -0
  29. package/dist/cli/summary.d.ts +33 -0
  30. package/dist/cli/summary.d.ts.map +1 -0
  31. package/dist/cli/summary.js +296 -0
  32. package/dist/cli/summary.js.map +1 -0
  33. package/dist/cli.js +7 -1
  34. package/dist/cli.js.map +1 -1
  35. package/dist/commands/migrate.d.ts +18 -4
  36. package/dist/commands/migrate.d.ts.map +1 -1
  37. package/dist/commands/migrate.js +324 -163
  38. package/dist/commands/migrate.js.map +1 -1
  39. package/dist/migrate/__tests__/capabilities.test.d.ts +7 -0
  40. package/dist/migrate/__tests__/capabilities.test.d.ts.map +1 -0
  41. package/dist/migrate/__tests__/capabilities.test.js +90 -0
  42. package/dist/migrate/__tests__/capabilities.test.js.map +1 -0
  43. package/dist/migrate/__tests__/chatgpt-loader.test.d.ts +7 -0
  44. package/dist/migrate/__tests__/chatgpt-loader.test.d.ts.map +1 -0
  45. package/dist/migrate/__tests__/chatgpt-loader.test.js +889 -0
  46. package/dist/migrate/__tests__/chatgpt-loader.test.js.map +1 -0
  47. package/dist/migrate/__tests__/edge-cases.test.d.ts +7 -0
  48. package/dist/migrate/__tests__/edge-cases.test.d.ts.map +1 -0
  49. package/dist/migrate/__tests__/edge-cases.test.js +787 -0
  50. package/dist/migrate/__tests__/edge-cases.test.js.map +1 -0
  51. package/dist/migrate/__tests__/error-recovery.test.d.ts +7 -0
  52. package/dist/migrate/__tests__/error-recovery.test.d.ts.map +1 -0
  53. package/dist/migrate/__tests__/error-recovery.test.js +461 -0
  54. package/dist/migrate/__tests__/error-recovery.test.js.map +1 -0
  55. package/dist/migrate/__tests__/integration.test.d.ts +7 -0
  56. package/dist/migrate/__tests__/integration.test.d.ts.map +1 -0
  57. package/dist/migrate/__tests__/integration.test.js +536 -0
  58. package/dist/migrate/__tests__/integration.test.js.map +1 -0
  59. package/dist/migrate/__tests__/performance.test.d.ts +7 -0
  60. package/dist/migrate/__tests__/performance.test.d.ts.map +1 -0
  61. package/dist/migrate/__tests__/performance.test.js +478 -0
  62. package/dist/migrate/__tests__/performance.test.js.map +1 -0
  63. package/dist/migrate/__tests__/registry.test.d.ts +7 -0
  64. package/dist/migrate/__tests__/registry.test.d.ts.map +1 -0
  65. package/dist/migrate/__tests__/registry.test.js +167 -0
  66. package/dist/migrate/__tests__/registry.test.js.map +1 -0
  67. package/dist/migrate/compatibility.d.ts +47 -0
  68. package/dist/migrate/compatibility.d.ts.map +1 -0
  69. package/dist/migrate/compatibility.js +468 -0
  70. package/dist/migrate/compatibility.js.map +1 -0
  71. package/dist/migrate/extractors/__tests__/claude.test.d.ts +12 -0
  72. package/dist/migrate/extractors/__tests__/claude.test.d.ts.map +1 -0
  73. package/dist/migrate/extractors/__tests__/claude.test.js +789 -0
  74. package/dist/migrate/extractors/__tests__/claude.test.js.map +1 -0
  75. package/dist/migrate/extractors/claude.d.ts +69 -0
  76. package/dist/migrate/extractors/claude.d.ts.map +1 -0
  77. package/dist/migrate/extractors/claude.js +1136 -0
  78. package/dist/migrate/extractors/claude.js.map +1 -0
  79. package/dist/migrate/extractors/registry.js +3 -2
  80. package/dist/migrate/extractors/registry.js.map +1 -1
  81. package/dist/migrate/loaders/chatgpt.d.ts +72 -0
  82. package/dist/migrate/loaders/chatgpt.d.ts.map +1 -0
  83. package/dist/migrate/loaders/chatgpt.js +691 -0
  84. package/dist/migrate/loaders/chatgpt.js.map +1 -0
  85. package/dist/migrate/loaders/registry.js +3 -2
  86. package/dist/migrate/loaders/registry.js.map +1 -1
  87. package/dist/migrate/types.d.ts +2 -0
  88. package/dist/migrate/types.d.ts.map +1 -1
  89. package/package.json +2 -1
@@ -0,0 +1,889 @@
1
+ /**
2
+ * ChatGPT Loader Tests
3
+ *
4
+ * Tests for the ChatGPT loader implementation.
5
+ */
6
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
7
+ import { mkdir, writeFile, rm, readFile } from 'node:fs/promises';
8
+ import { existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { tmpdir } from 'node:os';
11
+ import { ChatGPTLoader } from '../loaders/chatgpt.js';
12
+ // Mock fetch globally
13
+ const mockFetch = vi.fn();
14
+ global.fetch = mockFetch;
15
+ describe('ChatGPTLoader', () => {
16
+ let testWorkDir;
17
+ let loader;
18
+ // Helper to create a test bundle
19
+ function createTestBundle(overrides = {}) {
20
+ return {
21
+ version: '1.0',
22
+ id: 'test-bundle-123',
23
+ source: {
24
+ platform: 'claude',
25
+ extractedAt: '2026-02-10T10:00:00Z',
26
+ extractorVersion: '1.0.0',
27
+ },
28
+ target: {
29
+ platform: 'chatgpt',
30
+ transformedAt: '2026-02-10T11:00:00Z',
31
+ transformerVersion: '1.0.0',
32
+ },
33
+ contents: {
34
+ instructions: {
35
+ content: 'You are a helpful assistant. Be concise and accurate.',
36
+ length: 49,
37
+ },
38
+ memories: {
39
+ entries: [
40
+ { id: 'm1', content: 'User prefers dark mode', createdAt: '2026-01-01T00:00:00Z', category: 'Preferences' },
41
+ { id: 'm2', content: 'User works in tech industry', createdAt: '2026-01-02T00:00:00Z', category: 'Work' },
42
+ { id: 'm3', content: 'User likes TypeScript', createdAt: '2026-01-03T00:00:00Z', category: 'Preferences' },
43
+ ],
44
+ count: 3,
45
+ },
46
+ ...overrides.contents,
47
+ },
48
+ metadata: {
49
+ totalItems: 4,
50
+ itemCounts: {
51
+ instructions: 1,
52
+ memories: 3,
53
+ conversations: 0,
54
+ files: 0,
55
+ customBots: 0,
56
+ },
57
+ warnings: [],
58
+ errors: [],
59
+ ...overrides.metadata,
60
+ },
61
+ ...overrides,
62
+ };
63
+ }
64
+ // Helper to mock successful API responses
65
+ function mockApiSuccess() {
66
+ mockFetch.mockImplementation(async (url, options) => {
67
+ const path = new URL(url).pathname;
68
+ const method = options.method || 'GET';
69
+ // List files
70
+ if (method === 'GET' && path === '/v1/files') {
71
+ return new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
72
+ }
73
+ // Upload file
74
+ if (method === 'POST' && path === '/v1/files') {
75
+ return new Response(JSON.stringify({
76
+ id: `file_${Date.now()}`,
77
+ object: 'file',
78
+ bytes: 100,
79
+ created_at: Math.floor(Date.now() / 1000),
80
+ filename: 'test.txt',
81
+ purpose: 'assistants',
82
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
83
+ }
84
+ // Delete file
85
+ if (method === 'DELETE' && path.match(/\/v1\/files\/[^/]+$/)) {
86
+ return new Response(JSON.stringify({ deleted: true }), {
87
+ status: 200,
88
+ headers: { 'Content-Type': 'application/json' },
89
+ });
90
+ }
91
+ return new Response(JSON.stringify({ error: { message: 'Not found' } }), { status: 404 });
92
+ });
93
+ }
94
+ beforeEach(async () => {
95
+ testWorkDir = join(tmpdir(), `chatgpt-loader-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
96
+ await mkdir(testWorkDir, { recursive: true });
97
+ mockFetch.mockReset();
98
+ loader = new ChatGPTLoader({
99
+ apiKey: 'test-api-key',
100
+ baseUrl: 'https://api.openai.com/v1',
101
+ outputDir: testWorkDir,
102
+ });
103
+ });
104
+ afterEach(async () => {
105
+ if (existsSync(testWorkDir)) {
106
+ await rm(testWorkDir, { recursive: true, force: true });
107
+ }
108
+ });
109
+ describe('canLoad', () => {
110
+ it('should return true even without API key (generates guidance files)', async () => {
111
+ const loaderNoKey = new ChatGPTLoader({ apiKey: '' });
112
+ const result = await loaderNoKey.canLoad();
113
+ expect(result).toBe(true);
114
+ });
115
+ it('should return true with API key', async () => {
116
+ const result = await loader.canLoad();
117
+ expect(result).toBe(true);
118
+ });
119
+ });
120
+ describe('load - success cases', () => {
121
+ it('should create migration output directory with all components', async () => {
122
+ mockApiSuccess();
123
+ const bundle = createTestBundle();
124
+ const result = await loader.load(bundle, {});
125
+ expect(result.success).toBe(true);
126
+ expect(result.loaded.instructions).toBe(true);
127
+ expect(result.loaded.memories).toBe(3);
128
+ expect(result.created?.projectId).toBeDefined();
129
+ expect(result.errors).toHaveLength(0);
130
+ });
131
+ it('should generate custom instructions file', async () => {
132
+ mockApiSuccess();
133
+ const bundle = createTestBundle();
134
+ await loader.load(bundle, {});
135
+ const instructionsPath = join(testWorkDir, 'custom-instructions.txt');
136
+ expect(existsSync(instructionsPath)).toBe(true);
137
+ const content = await readFile(instructionsPath, 'utf-8');
138
+ expect(content).toBe(bundle.contents.instructions?.content);
139
+ });
140
+ it('should generate memories file with formatted content', async () => {
141
+ mockApiSuccess();
142
+ const bundle = createTestBundle();
143
+ await loader.load(bundle, {});
144
+ const memoriesPath = join(testWorkDir, 'memories.md');
145
+ expect(existsSync(memoriesPath)).toBe(true);
146
+ const content = await readFile(memoriesPath, 'utf-8');
147
+ expect(content).toContain('ChatGPT Memories to Add');
148
+ expect(content).toContain('User prefers dark mode');
149
+ expect(content).toContain('User works in tech industry');
150
+ });
151
+ it('should handle bundle without instructions', async () => {
152
+ mockApiSuccess();
153
+ const bundle = createTestBundle({
154
+ contents: {
155
+ memories: {
156
+ entries: [{ id: 'm1', content: 'Test memory', createdAt: '2026-01-01T00:00:00Z' }],
157
+ count: 1,
158
+ },
159
+ },
160
+ });
161
+ const result = await loader.load(bundle, {});
162
+ expect(result.success).toBe(true);
163
+ expect(result.loaded.instructions).toBe(false);
164
+ expect(result.loaded.memories).toBe(1);
165
+ });
166
+ it('should handle bundle without memories', async () => {
167
+ mockApiSuccess();
168
+ const bundle = createTestBundle({
169
+ contents: {
170
+ instructions: { content: 'Test instructions', length: 17 },
171
+ },
172
+ });
173
+ const result = await loader.load(bundle, {});
174
+ expect(result.success).toBe(true);
175
+ expect(result.loaded.instructions).toBe(true);
176
+ expect(result.loaded.memories).toBe(0);
177
+ });
178
+ it('should track progress during load', async () => {
179
+ mockApiSuccess();
180
+ const bundle = createTestBundle();
181
+ const progressUpdates = [];
182
+ await loader.load(bundle, {
183
+ onProgress: (progress, message) => {
184
+ progressUpdates.push({ progress, message });
185
+ },
186
+ });
187
+ expect(progressUpdates.length).toBeGreaterThan(0);
188
+ // Progress should increase monotonically
189
+ let lastProgress = 0;
190
+ for (const update of progressUpdates) {
191
+ expect(update.progress).toBeGreaterThanOrEqual(lastProgress);
192
+ lastProgress = update.progress;
193
+ }
194
+ // Should reach 100%
195
+ expect(progressUpdates[progressUpdates.length - 1].progress).toBe(1.0);
196
+ });
197
+ it('should provide manual steps in result', async () => {
198
+ mockApiSuccess();
199
+ const bundle = createTestBundle();
200
+ const result = await loader.load(bundle, {});
201
+ expect(result.manualSteps).toBeDefined();
202
+ expect(result.manualSteps.length).toBeGreaterThan(0);
203
+ expect(result.manualSteps.some(s => s.includes('Custom Instructions'))).toBe(true);
204
+ expect(result.manualSteps.some(s => s.includes('memories'))).toBe(true);
205
+ });
206
+ });
207
+ describe('load - file uploads', () => {
208
+ it('should upload files to OpenAI when API key is provided', async () => {
209
+ mockApiSuccess();
210
+ // Create a test file
211
+ const testFilePath = join(testWorkDir, 'test-document.txt');
212
+ await writeFile(testFilePath, 'Hello, this is test content');
213
+ const bundle = createTestBundle({
214
+ source: {
215
+ platform: 'claude',
216
+ extractedAt: '2026-02-10T10:00:00Z',
217
+ extractorVersion: '1.0.0',
218
+ bundlePath: testWorkDir, // Required for path validation
219
+ },
220
+ contents: {
221
+ instructions: { content: 'Test', length: 4 },
222
+ files: {
223
+ files: [
224
+ {
225
+ id: 'f1',
226
+ filename: 'test-document.txt',
227
+ mimeType: 'text/plain',
228
+ size: 27,
229
+ path: 'test-document.txt', // Relative path within bundle
230
+ },
231
+ ],
232
+ count: 1,
233
+ totalSize: 27,
234
+ },
235
+ },
236
+ });
237
+ const result = await loader.load(bundle, {});
238
+ expect(result.success).toBe(true);
239
+ expect(result.loaded.files).toBe(1);
240
+ // Verify file upload was called
241
+ const fileCalls = mockFetch.mock.calls.filter((call) => {
242
+ const [url] = call;
243
+ return url.includes('/files');
244
+ });
245
+ expect(fileCalls.length).toBe(1);
246
+ });
247
+ it('should skip files exceeding size limit', async () => {
248
+ mockApiSuccess();
249
+ const testFilePath = join(testWorkDir, 'large-file.txt');
250
+ await writeFile(testFilePath, 'x'.repeat(100));
251
+ const bundle = createTestBundle({
252
+ contents: {
253
+ instructions: { content: 'Test', length: 4 },
254
+ files: {
255
+ files: [
256
+ {
257
+ id: 'f1',
258
+ filename: 'large-file.txt',
259
+ mimeType: 'text/plain',
260
+ size: 600 * 1024 * 1024, // 600MB (exceeds 512MB limit)
261
+ path: testFilePath,
262
+ },
263
+ ],
264
+ count: 1,
265
+ totalSize: 600 * 1024 * 1024,
266
+ },
267
+ },
268
+ });
269
+ const result = await loader.load(bundle, {});
270
+ expect(result.success).toBe(true);
271
+ expect(result.loaded.files).toBe(0);
272
+ expect(result.warnings).toContainEqual(expect.stringContaining('exceeds size limit'));
273
+ });
274
+ it('should warn about missing files within bundle directory', async () => {
275
+ mockApiSuccess();
276
+ const bundle = createTestBundle({
277
+ source: {
278
+ platform: 'claude',
279
+ extractedAt: '2026-02-10T10:00:00Z',
280
+ extractorVersion: '1.0.0',
281
+ bundlePath: testWorkDir,
282
+ },
283
+ contents: {
284
+ instructions: { content: 'Test', length: 4 },
285
+ files: {
286
+ files: [
287
+ {
288
+ id: 'f1',
289
+ filename: 'missing-file.txt',
290
+ mimeType: 'text/plain',
291
+ size: 100,
292
+ path: 'missing-file.txt', // File doesn't exist but path is valid
293
+ },
294
+ ],
295
+ count: 1,
296
+ totalSize: 100,
297
+ },
298
+ },
299
+ });
300
+ const result = await loader.load(bundle, {});
301
+ expect(result.success).toBe(true);
302
+ expect(result.loaded.files).toBe(0);
303
+ expect(result.warnings).toContainEqual(expect.stringContaining('not found'));
304
+ });
305
+ it('should reject files with absolute paths outside bundle (path traversal)', async () => {
306
+ mockApiSuccess();
307
+ const bundle = createTestBundle({
308
+ source: {
309
+ platform: 'claude',
310
+ extractedAt: '2026-02-10T10:00:00Z',
311
+ extractorVersion: '1.0.0',
312
+ bundlePath: testWorkDir,
313
+ },
314
+ contents: {
315
+ instructions: { content: 'Test', length: 4 },
316
+ files: {
317
+ files: [
318
+ {
319
+ id: 'f1',
320
+ filename: 'passwd',
321
+ mimeType: 'text/plain',
322
+ size: 100,
323
+ path: '/etc/passwd', // Absolute path outside bundle
324
+ },
325
+ ],
326
+ count: 1,
327
+ totalSize: 100,
328
+ },
329
+ },
330
+ });
331
+ const result = await loader.load(bundle, {});
332
+ expect(result.success).toBe(true);
333
+ expect(result.loaded.files).toBe(0);
334
+ expect(result.warnings).toContainEqual(expect.stringContaining('Invalid file path'));
335
+ });
336
+ it('should generate file manifest when no API key provided', async () => {
337
+ const loaderNoKey = new ChatGPTLoader({
338
+ apiKey: '',
339
+ outputDir: testWorkDir,
340
+ });
341
+ const testFilePath = join(testWorkDir, 'test-file.txt');
342
+ await writeFile(testFilePath, 'Hello world');
343
+ const bundle = createTestBundle({
344
+ source: {
345
+ platform: 'claude',
346
+ extractedAt: '2026-02-10T10:00:00Z',
347
+ extractorVersion: '1.0.0',
348
+ bundlePath: testWorkDir, // Required for path validation
349
+ },
350
+ contents: {
351
+ instructions: { content: 'Test', length: 4 },
352
+ files: {
353
+ files: [
354
+ {
355
+ id: 'f1',
356
+ filename: 'test-file.txt',
357
+ mimeType: 'text/plain',
358
+ size: 11,
359
+ path: 'test-file.txt', // Relative path within bundle
360
+ },
361
+ ],
362
+ count: 1,
363
+ totalSize: 11,
364
+ },
365
+ },
366
+ });
367
+ const result = await loaderNoKey.load(bundle, {});
368
+ expect(result.success).toBe(true);
369
+ expect(result.loaded.files).toBe(0); // No files uploaded without API key
370
+ expect(result.warnings).toContainEqual(expect.stringContaining('No API key'));
371
+ const manifestPath = join(testWorkDir, 'files-manifest.md');
372
+ expect(existsSync(manifestPath)).toBe(true);
373
+ const content = await readFile(manifestPath, 'utf-8');
374
+ expect(content).toContain('test-file.txt');
375
+ });
376
+ });
377
+ describe('load - dry run', () => {
378
+ it('should not create any files in dry run mode', async () => {
379
+ const bundle = createTestBundle();
380
+ const result = await loader.load(bundle, { dryRun: true });
381
+ expect(result.success).toBe(true);
382
+ expect(result.warnings).toContain('Dry run - no changes made');
383
+ expect(mockFetch).not.toHaveBeenCalled();
384
+ // Should not create output files
385
+ const instructionsPath = join(testWorkDir, 'custom-instructions.txt');
386
+ expect(existsSync(instructionsPath)).toBe(false);
387
+ });
388
+ it('should report potential issues in dry run', async () => {
389
+ const bundle = createTestBundle({
390
+ contents: {
391
+ instructions: { content: 'x'.repeat(2000), length: 2000 }, // Exceeds 1500 char limit
392
+ },
393
+ });
394
+ const result = await loader.load(bundle, { dryRun: true });
395
+ expect(result.warnings).toContainEqual(expect.stringContaining('would be truncated'));
396
+ });
397
+ it('should report memory limit issues in dry run', async () => {
398
+ const entries = Array.from({ length: 150 }, (_, i) => ({
399
+ id: `m${i}`,
400
+ content: `Memory ${i}`,
401
+ createdAt: '2026-01-01T00:00:00Z',
402
+ }));
403
+ const bundle = createTestBundle({
404
+ contents: {
405
+ instructions: { content: 'Test', length: 4 },
406
+ memories: {
407
+ entries,
408
+ count: 150,
409
+ },
410
+ },
411
+ });
412
+ const result = await loader.load(bundle, { dryRun: true });
413
+ expect(result.warnings).toContainEqual(expect.stringContaining('would be truncated'));
414
+ expect(result.warnings).toContainEqual(expect.stringContaining('150 > 100'));
415
+ });
416
+ });
417
+ describe('load - error handling', () => {
418
+ it('should handle API errors gracefully for file upload', async () => {
419
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({ error: { message: 'Invalid API key' } }), {
420
+ status: 401,
421
+ }));
422
+ const testFilePath = join(testWorkDir, 'test.txt');
423
+ await writeFile(testFilePath, 'Hello');
424
+ const bundle = createTestBundle({
425
+ source: {
426
+ platform: 'claude',
427
+ extractedAt: '2026-02-10T10:00:00Z',
428
+ extractorVersion: '1.0.0',
429
+ bundlePath: testWorkDir, // Required for path validation
430
+ },
431
+ contents: {
432
+ instructions: { content: 'Test', length: 4 },
433
+ files: {
434
+ files: [
435
+ {
436
+ id: 'f1',
437
+ filename: 'test.txt',
438
+ mimeType: 'text/plain',
439
+ size: 5,
440
+ path: 'test.txt', // Relative path within bundle
441
+ },
442
+ ],
443
+ count: 1,
444
+ totalSize: 5,
445
+ },
446
+ },
447
+ });
448
+ const result = await loader.load(bundle, {});
449
+ expect(result.success).toBe(true); // Overall success (instructions/memories still work)
450
+ expect(result.loaded.files).toBe(0);
451
+ expect(result.errors).toContainEqual(expect.stringContaining('Failed to upload'));
452
+ });
453
+ it('should retry on rate limit errors', async () => {
454
+ let callCount = 0;
455
+ mockFetch.mockImplementation(async (url) => {
456
+ // Only count and handle file upload requests
457
+ if (url.includes('/files')) {
458
+ callCount++;
459
+ // First 2 calls return 429, third succeeds
460
+ if (callCount <= 2) {
461
+ return new Response(JSON.stringify({ error: { message: 'Rate limited' } }), {
462
+ status: 429,
463
+ headers: { 'retry-after': '0' }, // Immediate retry for test
464
+ });
465
+ }
466
+ return new Response(JSON.stringify({
467
+ id: `file_success_${Date.now()}`,
468
+ object: 'file',
469
+ bytes: 5,
470
+ created_at: Math.floor(Date.now() / 1000),
471
+ filename: 'test.txt',
472
+ purpose: 'assistants',
473
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
474
+ }
475
+ return new Response(JSON.stringify({}), { status: 200 });
476
+ });
477
+ const testFilePath = join(testWorkDir, 'test.txt');
478
+ await writeFile(testFilePath, 'Hello');
479
+ const bundle = createTestBundle({
480
+ source: {
481
+ platform: 'claude',
482
+ extractedAt: '2026-02-10T10:00:00Z',
483
+ extractorVersion: '1.0.0',
484
+ bundlePath: testWorkDir, // Required for path validation
485
+ },
486
+ contents: {
487
+ instructions: { content: 'Test', length: 4 },
488
+ files: {
489
+ files: [
490
+ {
491
+ id: 'f1',
492
+ filename: 'test.txt',
493
+ mimeType: 'text/plain',
494
+ size: 5,
495
+ path: 'test.txt', // Relative path within bundle
496
+ },
497
+ ],
498
+ count: 1,
499
+ totalSize: 5,
500
+ },
501
+ },
502
+ });
503
+ const loaderWithRetry = new ChatGPTLoader({
504
+ apiKey: 'test-key',
505
+ retryDelayMs: 10,
506
+ maxRetries: 5, // Allow more retries
507
+ outputDir: testWorkDir,
508
+ });
509
+ const result = await loaderWithRetry.load(bundle, {});
510
+ // Verify retries happened - we should have at least 3 calls (2 failures + 1 success)
511
+ expect(callCount).toBeGreaterThanOrEqual(3);
512
+ // After retries, the upload should succeed
513
+ expect(result.success).toBe(true);
514
+ expect(result.loaded.files).toBe(1);
515
+ });
516
+ });
517
+ describe('load - bundle validation', () => {
518
+ it('should reject bundles not transformed for ChatGPT', async () => {
519
+ const bundle = createTestBundle({
520
+ target: {
521
+ platform: 'claude',
522
+ transformedAt: '2026-02-10T11:00:00Z',
523
+ transformerVersion: '1.0.0',
524
+ },
525
+ });
526
+ await expect(loader.load(bundle, {})).rejects.toThrow('not transformed for ChatGPT');
527
+ });
528
+ });
529
+ describe('load - custom GPTs handling', () => {
530
+ it('should generate GPT configuration guides', async () => {
531
+ mockApiSuccess();
532
+ const bundle = createTestBundle({
533
+ contents: {
534
+ instructions: { content: 'Test', length: 4 },
535
+ customBots: {
536
+ bots: [
537
+ {
538
+ id: 'gpt1',
539
+ name: 'My Custom GPT',
540
+ description: 'A helpful assistant',
541
+ instructions: 'Be helpful and concise',
542
+ capabilities: ['Web browsing', 'Code interpreter'],
543
+ createdAt: '2026-01-01T00:00:00Z',
544
+ },
545
+ ],
546
+ count: 1,
547
+ },
548
+ },
549
+ });
550
+ const result = await loader.load(bundle, {});
551
+ expect(result.success).toBe(true);
552
+ expect(result.loaded.customBots).toBe(0); // Always 0 - requires manual creation
553
+ expect(result.warnings).toContainEqual(expect.stringContaining('GPT configurations exported'));
554
+ // Check GPT guide was created
555
+ const gptFile = join(testWorkDir, 'gpts', 'my_custom_gpt.md');
556
+ expect(existsSync(gptFile)).toBe(true);
557
+ const content = await readFile(gptFile, 'utf-8');
558
+ expect(content).toContain('My Custom GPT');
559
+ expect(content).toContain('Be helpful and concise');
560
+ expect(content).toContain('Web browsing');
561
+ });
562
+ });
563
+ describe('load state management', () => {
564
+ it('should track and expose load state for checkpointing', async () => {
565
+ mockApiSuccess();
566
+ const bundle = createTestBundle();
567
+ await loader.load(bundle, {});
568
+ const state = loader.getLoadState();
569
+ expect(state.instructionsProcessed).toBe(true);
570
+ expect(state.memoriesProcessed).toBe(true);
571
+ expect(state.outputDir).toBeDefined();
572
+ });
573
+ it('should allow setting load state for resume', async () => {
574
+ const previousState = {
575
+ instructionsProcessed: true,
576
+ memoriesProcessed: true,
577
+ uploadedFileIds: ['file_1', 'file_2'],
578
+ uploadedFilenames: ['a.txt', 'b.txt'],
579
+ lastFileIndex: 1,
580
+ outputDir: '/test/path',
581
+ };
582
+ loader.setLoadState(previousState);
583
+ const state = loader.getLoadState();
584
+ expect(state.instructionsProcessed).toBe(true);
585
+ expect(state.uploadedFileIds).toEqual(['file_1', 'file_2']);
586
+ expect(state.lastFileIndex).toBe(1);
587
+ });
588
+ });
589
+ describe('getProgress', () => {
590
+ it('should return current progress', async () => {
591
+ mockApiSuccess();
592
+ const bundle = createTestBundle();
593
+ // Before load
594
+ expect(loader.getProgress()).toBe(0);
595
+ await loader.load(bundle, {});
596
+ // After load
597
+ expect(loader.getProgress()).toBe(100);
598
+ });
599
+ });
600
+ describe('security - path traversal prevention', () => {
601
+ it('should reject file paths with path traversal attempts', async () => {
602
+ mockApiSuccess();
603
+ const bundle = createTestBundle({
604
+ source: {
605
+ platform: 'claude',
606
+ extractedAt: '2026-02-10T10:00:00Z',
607
+ extractorVersion: '1.0.0',
608
+ bundlePath: testWorkDir,
609
+ },
610
+ contents: {
611
+ instructions: { content: 'Test', length: 4 },
612
+ files: {
613
+ files: [
614
+ {
615
+ id: 'f1',
616
+ filename: 'secret.txt',
617
+ mimeType: 'text/plain',
618
+ size: 100,
619
+ path: '../../../etc/passwd', // Path traversal attempt
620
+ },
621
+ ],
622
+ count: 1,
623
+ totalSize: 100,
624
+ },
625
+ },
626
+ });
627
+ const result = await loader.load(bundle, {});
628
+ expect(result.success).toBe(true);
629
+ expect(result.loaded.files).toBe(0);
630
+ expect(result.warnings).toContainEqual(expect.stringContaining('Invalid file path'));
631
+ });
632
+ it('should reject absolute paths outside bundle directory', async () => {
633
+ mockApiSuccess();
634
+ const bundle = createTestBundle({
635
+ source: {
636
+ platform: 'claude',
637
+ extractedAt: '2026-02-10T10:00:00Z',
638
+ extractorVersion: '1.0.0',
639
+ bundlePath: testWorkDir,
640
+ },
641
+ contents: {
642
+ instructions: { content: 'Test', length: 4 },
643
+ files: {
644
+ files: [
645
+ {
646
+ id: 'f1',
647
+ filename: 'secret.txt',
648
+ mimeType: 'text/plain',
649
+ size: 100,
650
+ path: '/etc/passwd', // Absolute path outside bundle
651
+ },
652
+ ],
653
+ count: 1,
654
+ totalSize: 100,
655
+ },
656
+ },
657
+ });
658
+ const result = await loader.load(bundle, {});
659
+ expect(result.success).toBe(true);
660
+ expect(result.loaded.files).toBe(0);
661
+ expect(result.warnings).toContainEqual(expect.stringContaining('Invalid file path'));
662
+ });
663
+ it('should accept valid file paths within bundle directory', async () => {
664
+ mockApiSuccess();
665
+ // Create a test file inside the work directory
666
+ const testFilePath = join(testWorkDir, 'valid-file.txt');
667
+ await writeFile(testFilePath, 'Valid file content');
668
+ const bundle = createTestBundle({
669
+ source: {
670
+ platform: 'claude',
671
+ extractedAt: '2026-02-10T10:00:00Z',
672
+ extractorVersion: '1.0.0',
673
+ bundlePath: testWorkDir,
674
+ },
675
+ contents: {
676
+ instructions: { content: 'Test', length: 4 },
677
+ files: {
678
+ files: [
679
+ {
680
+ id: 'f1',
681
+ filename: 'valid-file.txt',
682
+ mimeType: 'text/plain',
683
+ size: 18,
684
+ path: 'valid-file.txt', // Relative path within bundle
685
+ },
686
+ ],
687
+ count: 1,
688
+ totalSize: 18,
689
+ },
690
+ },
691
+ });
692
+ const result = await loader.load(bundle, {});
693
+ expect(result.success).toBe(true);
694
+ expect(result.loaded.files).toBe(1);
695
+ });
696
+ });
697
+ describe('security - output directory sanitization', () => {
698
+ it('should sanitize output directory with path traversal in projectName', async () => {
699
+ mockApiSuccess();
700
+ // No outputDir in config - tests projectName sanitization
701
+ const loaderCustom = new ChatGPTLoader({
702
+ apiKey: 'test-api-key',
703
+ baseUrl: 'https://api.openai.com/v1',
704
+ });
705
+ const bundle = createTestBundle();
706
+ const result = await loaderCustom.load(bundle, {
707
+ projectName: '../../../tmp/evil-directory', // Path traversal attempt
708
+ });
709
+ expect(result.success).toBe(true);
710
+ // The output directory should be sanitized to just the basename
711
+ expect(result.created?.projectId).toBe('evil-directory');
712
+ expect(result.created?.projectId).not.toContain('..');
713
+ });
714
+ it('should sanitize output directory with absolute path in projectName', async () => {
715
+ mockApiSuccess();
716
+ // No outputDir in config - tests projectName sanitization
717
+ const loaderCustom = new ChatGPTLoader({
718
+ apiKey: 'test-api-key',
719
+ baseUrl: 'https://api.openai.com/v1',
720
+ });
721
+ const bundle = createTestBundle();
722
+ const result = await loaderCustom.load(bundle, {
723
+ projectName: '/tmp/absolute-path-attempt', // Absolute path attempt
724
+ });
725
+ expect(result.success).toBe(true);
726
+ // The output directory should be sanitized to just the basename
727
+ expect(result.created?.projectId).toBe('absolute-path-attempt');
728
+ expect(result.created?.projectId).not.toContain('/tmp');
729
+ });
730
+ it('should fallback to default name when projectName is empty after sanitization', async () => {
731
+ mockApiSuccess();
732
+ // No outputDir in config - tests projectName sanitization
733
+ const loaderCustom = new ChatGPTLoader({
734
+ apiKey: 'test-api-key',
735
+ baseUrl: 'https://api.openai.com/v1',
736
+ });
737
+ const bundle = createTestBundle();
738
+ const result = await loaderCustom.load(bundle, {
739
+ projectName: '/', // Edge case: just a slash
740
+ });
741
+ expect(result.success).toBe(true);
742
+ expect(result.created?.projectId).toBe('chatgpt-migration');
743
+ });
744
+ it('should respect config.outputDir when set (trusted developer input)', async () => {
745
+ mockApiSuccess();
746
+ // When outputDir is configured, it should be used as-is (trusted)
747
+ const loaderWithConfig = new ChatGPTLoader({
748
+ apiKey: 'test-api-key',
749
+ baseUrl: 'https://api.openai.com/v1',
750
+ outputDir: testWorkDir, // Developer-configured path
751
+ });
752
+ const bundle = createTestBundle();
753
+ const result = await loaderWithConfig.load(bundle, {
754
+ projectName: '../../../should-be-ignored', // Should be ignored
755
+ });
756
+ expect(result.success).toBe(true);
757
+ expect(result.created?.projectId).toBe(testWorkDir);
758
+ });
759
+ });
760
+ describe('instructions truncation', () => {
761
+ it('should truncate instructions exceeding limit and warn', async () => {
762
+ mockApiSuccess();
763
+ const longInstructions = 'x'.repeat(2000); // Exceeds 1500 char limit
764
+ const bundle = createTestBundle({
765
+ contents: {
766
+ instructions: { content: longInstructions, length: 2000 },
767
+ },
768
+ });
769
+ const result = await loader.load(bundle, {});
770
+ expect(result.success).toBe(true);
771
+ expect(result.warnings).toContainEqual(expect.stringContaining('truncated'));
772
+ // Verify truncated content was written
773
+ const instructionsPath = join(testWorkDir, 'custom-instructions.txt');
774
+ const content = await readFile(instructionsPath, 'utf-8');
775
+ expect(content.length).toBeLessThanOrEqual(1500);
776
+ // Full content should also be saved
777
+ const fullPath = join(testWorkDir, 'custom-instructions-full.txt');
778
+ expect(existsSync(fullPath)).toBe(true);
779
+ });
780
+ it('should use intelligent truncation', async () => {
781
+ mockApiSuccess();
782
+ // Create content with examples that will exceed the 1500 char limit
783
+ const contentWithExamples = `
784
+ You are a helpful assistant.
785
+
786
+ Example: This is a long example that should be removed.
787
+ \`\`\`
788
+ function example() {
789
+ console.log('This is code that might be removed');
790
+ }
791
+ \`\`\`
792
+
793
+ Be concise and accurate.
794
+
795
+ Note: This is a verbose note that might be removed.
796
+
797
+ Always respond in a professional manner.
798
+
799
+ Here are some guidelines:
800
+ - Be helpful and informative
801
+ - Answer questions accurately
802
+ - Stay on topic
803
+ - Be respectful and professional
804
+ `.repeat(10); // Repeat 10x to definitely exceed 1500 char limit
805
+ expect(contentWithExamples.length).toBeGreaterThan(1500); // Verify it exceeds limit
806
+ const bundle = createTestBundle({
807
+ contents: {
808
+ instructions: { content: contentWithExamples, length: contentWithExamples.length },
809
+ },
810
+ });
811
+ const result = await loader.load(bundle, {});
812
+ expect(result.success).toBe(true);
813
+ expect(result.warnings).toContainEqual(expect.stringContaining('truncated'));
814
+ });
815
+ });
816
+ describe('memories processing', () => {
817
+ it('should group memories by category', async () => {
818
+ mockApiSuccess();
819
+ const bundle = createTestBundle({
820
+ contents: {
821
+ instructions: { content: 'Test', length: 4 },
822
+ memories: {
823
+ entries: [
824
+ { id: 'm1', content: 'Memory 1', createdAt: '2026-01-01T00:00:00Z', category: 'Work' },
825
+ { id: 'm2', content: 'Memory 2', createdAt: '2026-01-02T00:00:00Z', category: 'Preferences' },
826
+ { id: 'm3', content: 'Memory 3', createdAt: '2026-01-03T00:00:00Z', category: 'Work' },
827
+ ],
828
+ count: 3,
829
+ },
830
+ },
831
+ });
832
+ await loader.load(bundle, {});
833
+ const memoriesPath = join(testWorkDir, 'memories.md');
834
+ const content = await readFile(memoriesPath, 'utf-8');
835
+ expect(content).toContain('## Work');
836
+ expect(content).toContain('## Preferences');
837
+ expect(content).toContain('- Memory 1');
838
+ expect(content).toContain('- Memory 2');
839
+ expect(content).toContain('- Memory 3');
840
+ });
841
+ it('should truncate memories exceeding limit', async () => {
842
+ mockApiSuccess();
843
+ const entries = Array.from({ length: 150 }, (_, i) => ({
844
+ id: `m${i}`,
845
+ content: `Memory ${i}`,
846
+ createdAt: '2026-01-01T00:00:00Z',
847
+ }));
848
+ const bundle = createTestBundle({
849
+ contents: {
850
+ instructions: { content: 'Test', length: 4 },
851
+ memories: {
852
+ entries,
853
+ count: 150,
854
+ },
855
+ },
856
+ });
857
+ const result = await loader.load(bundle, {});
858
+ expect(result.success).toBe(true);
859
+ expect(result.loaded.memories).toBe(100); // Truncated to limit
860
+ expect(result.warnings).toContainEqual(expect.stringContaining('Memories truncated'));
861
+ });
862
+ it('should also write memories as JSON', async () => {
863
+ mockApiSuccess();
864
+ const bundle = createTestBundle();
865
+ await loader.load(bundle, {});
866
+ const jsonPath = join(testWorkDir, 'memories.json');
867
+ expect(existsSync(jsonPath)).toBe(true);
868
+ const content = await readFile(jsonPath, 'utf-8');
869
+ const parsed = JSON.parse(content);
870
+ expect(parsed).toBeInstanceOf(Array);
871
+ expect(parsed.length).toBe(3);
872
+ });
873
+ });
874
+ describe('README generation', () => {
875
+ it('should generate README with migration summary', async () => {
876
+ mockApiSuccess();
877
+ const bundle = createTestBundle();
878
+ await loader.load(bundle, {});
879
+ const readmePath = join(testWorkDir, 'README.md');
880
+ expect(existsSync(readmePath)).toBe(true);
881
+ const content = await readFile(readmePath, 'utf-8');
882
+ expect(content).toContain('ChatGPT Migration Package');
883
+ expect(content).toContain('Custom Instructions');
884
+ expect(content).toContain('Memories');
885
+ expect(content).toContain('Manual Steps Required');
886
+ });
887
+ });
888
+ });
889
+ //# sourceMappingURL=chatgpt-loader.test.js.map