Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import { describe, test, expect } from 'bun:test' 2import { 3 shouldCompressFile, 4 compressFile, 5 processUploadedFiles, 6 createManifest, 7 updateFileBlobs, 8 computeCID, 9 extractBlobMap, 10 type UploadedFile, 11 type FileUploadResult, 12} from './wisp-utils' 13import type { Directory } from '../lexicons/types/place/wisp/fs' 14import { gunzipSync } from 'zlib' 15import { BlobRef } from '@atproto/api' 16import { CID } from 'multiformats/cid' 17 18// Helper function to create a valid CID for testing 19// Using a real valid CID from actual AT Protocol usage 20const TEST_CID_STRING = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y' 21 22function createMockBlobRef(mimeType: string, size: number): BlobRef { 23 // Create a properly formatted CID 24 const cid = CID.parse(TEST_CID_STRING) 25 return new BlobRef(cid, mimeType, size) 26} 27 28describe('shouldCompressFile', () => { 29 test('should compress HTML files', () => { 30 expect(shouldCompressFile('text/html')).toBe(true) 31 expect(shouldCompressFile('text/html; charset=utf-8')).toBe(true) 32 }) 33 34 test('should compress CSS files', () => { 35 expect(shouldCompressFile('text/css')).toBe(true) 36 }) 37 38 test('should compress JavaScript files', () => { 39 expect(shouldCompressFile('text/javascript')).toBe(true) 40 expect(shouldCompressFile('application/javascript')).toBe(true) 41 expect(shouldCompressFile('application/x-javascript')).toBe(true) 42 }) 43 44 test('should compress JSON files', () => { 45 expect(shouldCompressFile('application/json')).toBe(true) 46 }) 47 48 test('should compress SVG files', () => { 49 expect(shouldCompressFile('image/svg+xml')).toBe(true) 50 }) 51 52 test('should compress XML files', () => { 53 expect(shouldCompressFile('text/xml')).toBe(true) 54 expect(shouldCompressFile('application/xml')).toBe(true) 55 }) 56 57 test('should compress plain text files', () => { 58 expect(shouldCompressFile('text/plain')).toBe(true) 59 }) 60 61 test('should NOT compress images', () => { 62 expect(shouldCompressFile('image/png')).toBe(false) 63 expect(shouldCompressFile('image/jpeg')).toBe(false) 64 expect(shouldCompressFile('image/jpg')).toBe(false) 65 expect(shouldCompressFile('image/gif')).toBe(false) 66 expect(shouldCompressFile('image/webp')).toBe(false) 67 }) 68 69 test('should NOT compress videos', () => { 70 expect(shouldCompressFile('video/mp4')).toBe(false) 71 expect(shouldCompressFile('video/webm')).toBe(false) 72 }) 73 74 test('should NOT compress already compressed formats', () => { 75 expect(shouldCompressFile('application/zip')).toBe(false) 76 expect(shouldCompressFile('application/gzip')).toBe(false) 77 expect(shouldCompressFile('application/pdf')).toBe(false) 78 }) 79 80 test('should NOT compress fonts', () => { 81 expect(shouldCompressFile('font/woff')).toBe(false) 82 expect(shouldCompressFile('font/woff2')).toBe(false) 83 expect(shouldCompressFile('font/ttf')).toBe(false) 84 }) 85}) 86 87describe('compressFile', () => { 88 test('should compress text content', () => { 89 const content = Buffer.from('Hello, World! '.repeat(100)) 90 const compressed = compressFile(content) 91 92 expect(compressed.length).toBeLessThan(content.length) 93 94 // Verify we can decompress it back 95 const decompressed = gunzipSync(compressed) 96 expect(decompressed.toString()).toBe(content.toString()) 97 }) 98 99 test('should compress HTML content significantly', () => { 100 const html = ` 101 <!DOCTYPE html> 102 <html> 103 <head><title>Test</title></head> 104 <body> 105 ${'<p>Hello World!</p>\n'.repeat(50)} 106 </body> 107 </html> 108 ` 109 const content = Buffer.from(html) 110 const compressed = compressFile(content) 111 112 expect(compressed.length).toBeLessThan(content.length) 113 114 // Verify decompression 115 const decompressed = gunzipSync(compressed) 116 expect(decompressed.toString()).toBe(html) 117 }) 118 119 test('should handle empty content', () => { 120 const content = Buffer.from('') 121 const compressed = compressFile(content) 122 const decompressed = gunzipSync(compressed) 123 expect(decompressed.toString()).toBe('') 124 }) 125 126 test('should produce deterministic compression', () => { 127 const content = Buffer.from('Test content') 128 const compressed1 = compressFile(content) 129 const compressed2 = compressFile(content) 130 131 expect(compressed1.toString('base64')).toBe(compressed2.toString('base64')) 132 }) 133}) 134 135describe('processUploadedFiles', () => { 136 test('should process single root-level file', () => { 137 const files: UploadedFile[] = [ 138 { 139 name: 'index.html', 140 content: Buffer.from('<html></html>'), 141 mimeType: 'text/html', 142 size: 13, 143 }, 144 ] 145 146 const result = processUploadedFiles(files) 147 148 expect(result.fileCount).toBe(1) 149 expect(result.directory.type).toBe('directory') 150 expect(result.directory.entries).toHaveLength(1) 151 expect(result.directory.entries[0].name).toBe('index.html') 152 153 const node = result.directory.entries[0].node 154 expect('blob' in node).toBe(true) // It's a file node 155 }) 156 157 test('should process multiple root-level files', () => { 158 const files: UploadedFile[] = [ 159 { 160 name: 'index.html', 161 content: Buffer.from('<html></html>'), 162 mimeType: 'text/html', 163 size: 13, 164 }, 165 { 166 name: 'styles.css', 167 content: Buffer.from('body {}'), 168 mimeType: 'text/css', 169 size: 7, 170 }, 171 { 172 name: 'script.js', 173 content: Buffer.from('console.log("hi")'), 174 mimeType: 'application/javascript', 175 size: 17, 176 }, 177 ] 178 179 const result = processUploadedFiles(files) 180 181 expect(result.fileCount).toBe(3) 182 expect(result.directory.entries).toHaveLength(3) 183 184 const names = result.directory.entries.map(e => e.name) 185 expect(names).toContain('index.html') 186 expect(names).toContain('styles.css') 187 expect(names).toContain('script.js') 188 }) 189 190 test('should process files with subdirectories', () => { 191 const files: UploadedFile[] = [ 192 { 193 name: 'dist/index.html', 194 content: Buffer.from('<html></html>'), 195 mimeType: 'text/html', 196 size: 13, 197 }, 198 { 199 name: 'dist/css/styles.css', 200 content: Buffer.from('body {}'), 201 mimeType: 'text/css', 202 size: 7, 203 }, 204 { 205 name: 'dist/js/app.js', 206 content: Buffer.from('console.log()'), 207 mimeType: 'application/javascript', 208 size: 13, 209 }, 210 ] 211 212 const result = processUploadedFiles(files) 213 214 expect(result.fileCount).toBe(3) 215 expect(result.directory.entries).toHaveLength(3) // index.html, css/, js/ 216 217 // Check root has index.html (after base folder removal) 218 const indexEntry = result.directory.entries.find(e => e.name === 'index.html') 219 expect(indexEntry).toBeDefined() 220 221 // Check css directory exists 222 const cssDir = result.directory.entries.find(e => e.name === 'css') 223 expect(cssDir).toBeDefined() 224 expect('entries' in cssDir!.node).toBe(true) 225 226 if ('entries' in cssDir!.node) { 227 expect(cssDir!.node.entries).toHaveLength(1) 228 expect(cssDir!.node.entries[0].name).toBe('styles.css') 229 } 230 231 // Check js directory exists 232 const jsDir = result.directory.entries.find(e => e.name === 'js') 233 expect(jsDir).toBeDefined() 234 expect('entries' in jsDir!.node).toBe(true) 235 }) 236 237 test('should handle deeply nested subdirectories', () => { 238 const files: UploadedFile[] = [ 239 { 240 name: 'dist/deep/nested/folder/file.txt', 241 content: Buffer.from('content'), 242 mimeType: 'text/plain', 243 size: 7, 244 }, 245 ] 246 247 const result = processUploadedFiles(files) 248 249 expect(result.fileCount).toBe(1) 250 251 // Navigate through the directory structure (base folder removed) 252 const deepDir = result.directory.entries.find(e => e.name === 'deep') 253 expect(deepDir).toBeDefined() 254 expect('entries' in deepDir!.node).toBe(true) 255 256 if ('entries' in deepDir!.node) { 257 const nestedDir = deepDir!.node.entries.find(e => e.name === 'nested') 258 expect(nestedDir).toBeDefined() 259 260 if (nestedDir && 'entries' in nestedDir.node) { 261 const folderDir = nestedDir.node.entries.find(e => e.name === 'folder') 262 expect(folderDir).toBeDefined() 263 264 if (folderDir && 'entries' in folderDir.node) { 265 expect(folderDir.node.entries).toHaveLength(1) 266 expect(folderDir.node.entries[0].name).toBe('file.txt') 267 } 268 } 269 } 270 }) 271 272 test('should remove base folder name from paths', () => { 273 const files: UploadedFile[] = [ 274 { 275 name: 'dist/index.html', 276 content: Buffer.from('<html></html>'), 277 mimeType: 'text/html', 278 size: 13, 279 }, 280 { 281 name: 'dist/css/styles.css', 282 content: Buffer.from('body {}'), 283 mimeType: 'text/css', 284 size: 7, 285 }, 286 ] 287 288 const result = processUploadedFiles(files) 289 290 // After removing 'dist/', we should have index.html and css/ at root 291 expect(result.directory.entries.find(e => e.name === 'index.html')).toBeDefined() 292 expect(result.directory.entries.find(e => e.name === 'css')).toBeDefined() 293 expect(result.directory.entries.find(e => e.name === 'dist')).toBeUndefined() 294 }) 295 296 test('should handle empty file list', () => { 297 const files: UploadedFile[] = [] 298 const result = processUploadedFiles(files) 299 300 expect(result.fileCount).toBe(0) 301 expect(result.directory.entries).toHaveLength(0) 302 }) 303 304 test('should handle multiple files in same subdirectory', () => { 305 const files: UploadedFile[] = [ 306 { 307 name: 'dist/assets/image1.png', 308 content: Buffer.from('png1'), 309 mimeType: 'image/png', 310 size: 4, 311 }, 312 { 313 name: 'dist/assets/image2.png', 314 content: Buffer.from('png2'), 315 mimeType: 'image/png', 316 size: 4, 317 }, 318 ] 319 320 const result = processUploadedFiles(files) 321 322 expect(result.fileCount).toBe(2) 323 324 const assetsDir = result.directory.entries.find(e => e.name === 'assets') 325 expect(assetsDir).toBeDefined() 326 327 if ('entries' in assetsDir!.node) { 328 expect(assetsDir!.node.entries).toHaveLength(2) 329 const names = assetsDir!.node.entries.map(e => e.name) 330 expect(names).toContain('image1.png') 331 expect(names).toContain('image2.png') 332 } 333 }) 334}) 335 336describe('createManifest', () => { 337 test('should create valid manifest', () => { 338 const root: Directory = { 339 $type: 'place.wisp.fs#directory', 340 type: 'directory', 341 entries: [], 342 } 343 344 const manifest = createManifest('example.com', root, 0) 345 346 expect(manifest.$type).toBe('place.wisp.fs') 347 expect(manifest.site).toBe('example.com') 348 expect(manifest.root).toBe(root) 349 expect(manifest.fileCount).toBe(0) 350 expect(manifest.createdAt).toBeDefined() 351 352 // Verify it's a valid ISO date string 353 const date = new Date(manifest.createdAt) 354 expect(date.toISOString()).toBe(manifest.createdAt) 355 }) 356 357 test('should create manifest with file count', () => { 358 const root: Directory = { 359 $type: 'place.wisp.fs#directory', 360 type: 'directory', 361 entries: [], 362 } 363 364 const manifest = createManifest('test-site', root, 42) 365 366 expect(manifest.fileCount).toBe(42) 367 expect(manifest.site).toBe('test-site') 368 }) 369 370 test('should create manifest with populated directory', () => { 371 const mockBlob = createMockBlobRef('text/html', 100) 372 373 const root: Directory = { 374 $type: 'place.wisp.fs#directory', 375 type: 'directory', 376 entries: [ 377 { 378 name: 'index.html', 379 node: { 380 $type: 'place.wisp.fs#file', 381 type: 'file', 382 blob: mockBlob, 383 }, 384 }, 385 ], 386 } 387 388 const manifest = createManifest('populated-site', root, 1) 389 390 expect(manifest).toBeDefined() 391 expect(manifest.site).toBe('populated-site') 392 expect(manifest.root.entries).toHaveLength(1) 393 }) 394}) 395 396describe('updateFileBlobs', () => { 397 test('should update single file blob at root', () => { 398 const directory: Directory = { 399 $type: 'place.wisp.fs#directory', 400 type: 'directory', 401 entries: [ 402 { 403 name: 'index.html', 404 node: { 405 $type: 'place.wisp.fs#file', 406 type: 'file', 407 blob: undefined as any, 408 }, 409 }, 410 ], 411 } 412 413 const mockBlob = createMockBlobRef('text/html', 100) 414 const uploadResults: FileUploadResult[] = [ 415 { 416 hash: TEST_CID_STRING, 417 blobRef: mockBlob, 418 mimeType: 'text/html', 419 }, 420 ] 421 422 const filePaths = ['index.html'] 423 424 const updated = updateFileBlobs(directory, uploadResults, filePaths) 425 426 expect(updated.entries).toHaveLength(1) 427 const fileNode = updated.entries[0].node 428 429 if ('blob' in fileNode) { 430 expect(fileNode.blob).toBeDefined() 431 expect(fileNode.blob.mimeType).toBe('text/html') 432 expect(fileNode.blob.size).toBe(100) 433 } else { 434 throw new Error('Expected file node') 435 } 436 }) 437 438 test('should update files in nested directories', () => { 439 const directory: Directory = { 440 $type: 'place.wisp.fs#directory', 441 type: 'directory', 442 entries: [ 443 { 444 name: 'css', 445 node: { 446 $type: 'place.wisp.fs#directory', 447 type: 'directory', 448 entries: [ 449 { 450 name: 'styles.css', 451 node: { 452 $type: 'place.wisp.fs#file', 453 type: 'file', 454 blob: undefined as any, 455 }, 456 }, 457 ], 458 }, 459 }, 460 ], 461 } 462 463 const mockBlob = createMockBlobRef('text/css', 50) 464 const uploadResults: FileUploadResult[] = [ 465 { 466 hash: TEST_CID_STRING, 467 blobRef: mockBlob, 468 mimeType: 'text/css', 469 encoding: 'gzip', 470 }, 471 ] 472 473 const filePaths = ['css/styles.css'] 474 475 const updated = updateFileBlobs(directory, uploadResults, filePaths) 476 477 const cssDir = updated.entries[0] 478 expect(cssDir.name).toBe('css') 479 480 if ('entries' in cssDir.node) { 481 const cssFile = cssDir.node.entries[0] 482 expect(cssFile.name).toBe('styles.css') 483 484 if ('blob' in cssFile.node) { 485 expect(cssFile.node.blob.mimeType).toBe('text/css') 486 if ('encoding' in cssFile.node) { 487 expect(cssFile.node.encoding).toBe('gzip') 488 } 489 } else { 490 throw new Error('Expected file node') 491 } 492 } else { 493 throw new Error('Expected directory node') 494 } 495 }) 496 497 test('should handle normalized paths with base folder removed', () => { 498 const directory: Directory = { 499 $type: 'place.wisp.fs#directory', 500 type: 'directory', 501 entries: [ 502 { 503 name: 'index.html', 504 node: { 505 $type: 'place.wisp.fs#file', 506 type: 'file', 507 blob: undefined as any, 508 }, 509 }, 510 ], 511 } 512 513 const mockBlob = createMockBlobRef('text/html', 100) 514 const uploadResults: FileUploadResult[] = [ 515 { 516 hash: TEST_CID_STRING, 517 blobRef: mockBlob, 518 }, 519 ] 520 521 // Path includes base folder that should be normalized 522 const filePaths = ['dist/index.html'] 523 524 const updated = updateFileBlobs(directory, uploadResults, filePaths) 525 526 const fileNode = updated.entries[0].node 527 if ('blob' in fileNode) { 528 expect(fileNode.blob).toBeDefined() 529 } else { 530 throw new Error('Expected file node') 531 } 532 }) 533 534 test('should preserve file metadata (encoding, mimeType, base64)', () => { 535 const directory: Directory = { 536 $type: 'place.wisp.fs#directory', 537 type: 'directory', 538 entries: [ 539 { 540 name: 'data.json', 541 node: { 542 $type: 'place.wisp.fs#file', 543 type: 'file', 544 blob: undefined as any, 545 }, 546 }, 547 ], 548 } 549 550 const mockBlob = createMockBlobRef('application/json', 200) 551 const uploadResults: FileUploadResult[] = [ 552 { 553 hash: TEST_CID_STRING, 554 blobRef: mockBlob, 555 mimeType: 'application/json', 556 encoding: 'gzip', 557 base64: true, 558 }, 559 ] 560 561 const filePaths = ['data.json'] 562 563 const updated = updateFileBlobs(directory, uploadResults, filePaths) 564 565 const fileNode = updated.entries[0].node 566 if ('blob' in fileNode && 'mimeType' in fileNode && 'encoding' in fileNode && 'base64' in fileNode) { 567 expect(fileNode.mimeType).toBe('application/json') 568 expect(fileNode.encoding).toBe('gzip') 569 expect(fileNode.base64).toBe(true) 570 } else { 571 throw new Error('Expected file node with metadata') 572 } 573 }) 574 575 test('should handle multiple files at different directory levels', () => { 576 const directory: Directory = { 577 $type: 'place.wisp.fs#directory', 578 type: 'directory', 579 entries: [ 580 { 581 name: 'index.html', 582 node: { 583 $type: 'place.wisp.fs#file', 584 type: 'file', 585 blob: undefined as any, 586 }, 587 }, 588 { 589 name: 'assets', 590 node: { 591 $type: 'place.wisp.fs#directory', 592 type: 'directory', 593 entries: [ 594 { 595 name: 'logo.svg', 596 node: { 597 $type: 'place.wisp.fs#file', 598 type: 'file', 599 blob: undefined as any, 600 }, 601 }, 602 ], 603 }, 604 }, 605 ], 606 } 607 608 const htmlBlob = createMockBlobRef('text/html', 100) 609 const svgBlob = createMockBlobRef('image/svg+xml', 500) 610 611 const uploadResults: FileUploadResult[] = [ 612 { 613 hash: TEST_CID_STRING, 614 blobRef: htmlBlob, 615 }, 616 { 617 hash: TEST_CID_STRING, 618 blobRef: svgBlob, 619 }, 620 ] 621 622 const filePaths = ['index.html', 'assets/logo.svg'] 623 624 const updated = updateFileBlobs(directory, uploadResults, filePaths) 625 626 // Check root file 627 const indexNode = updated.entries[0].node 628 if ('blob' in indexNode) { 629 expect(indexNode.blob.mimeType).toBe('text/html') 630 } 631 632 // Check nested file 633 const assetsDir = updated.entries[1] 634 if ('entries' in assetsDir.node) { 635 const logoNode = assetsDir.node.entries[0].node 636 if ('blob' in logoNode) { 637 expect(logoNode.blob.mimeType).toBe('image/svg+xml') 638 } 639 } 640 }) 641}) 642 643describe('computeCID', () => { 644 test('should compute CID for gzipped+base64 encoded content', () => { 645 // This simulates the actual flow: gzip -> base64 -> compute CID 646 const originalContent = Buffer.from('Hello, World!') 647 const gzipped = compressFile(originalContent) 648 const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 649 650 const cid = computeCID(base64Content) 651 652 // CID should be a valid CIDv1 string starting with 'bafkrei' 653 expect(cid).toMatch(/^bafkrei[a-z0-9]+$/) 654 expect(cid.length).toBeGreaterThan(10) 655 }) 656 657 test('should compute deterministic CIDs for identical content', () => { 658 const content = Buffer.from('Test content for CID calculation') 659 const gzipped = compressFile(content) 660 const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 661 662 const cid1 = computeCID(base64Content) 663 const cid2 = computeCID(base64Content) 664 665 expect(cid1).toBe(cid2) 666 }) 667 668 test('should compute different CIDs for different content', () => { 669 const content1 = Buffer.from('Content A') 670 const content2 = Buffer.from('Content B') 671 672 const gzipped1 = compressFile(content1) 673 const gzipped2 = compressFile(content2) 674 675 const base64Content1 = Buffer.from(gzipped1.toString('base64'), 'binary') 676 const base64Content2 = Buffer.from(gzipped2.toString('base64'), 'binary') 677 678 const cid1 = computeCID(base64Content1) 679 const cid2 = computeCID(base64Content2) 680 681 expect(cid1).not.toBe(cid2) 682 }) 683 684 test('should handle empty content', () => { 685 const emptyContent = Buffer.from('') 686 const gzipped = compressFile(emptyContent) 687 const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 688 689 const cid = computeCID(base64Content) 690 691 expect(cid).toMatch(/^bafkrei[a-z0-9]+$/) 692 }) 693 694 test('should compute same CID as PDS for base64-encoded content', () => { 695 // Test that binary encoding produces correct bytes for CID calculation 696 const testContent = Buffer.from('<!DOCTYPE html><html><body>Hello</body></html>') 697 const gzipped = compressFile(testContent) 698 const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 699 700 // Compute CID twice to ensure consistency 701 const cid1 = computeCID(base64Content) 702 const cid2 = computeCID(base64Content) 703 704 expect(cid1).toBe(cid2) 705 expect(cid1).toMatch(/^bafkrei/) 706 }) 707 708 test('should use binary encoding for base64 strings', () => { 709 // This test verifies we're using the correct encoding method 710 // For base64 strings, 'binary' encoding ensures each character becomes exactly one byte 711 const content = Buffer.from('Test content') 712 const gzipped = compressFile(content) 713 const base64String = gzipped.toString('base64') 714 715 // Using binary encoding (what we use in production) 716 const base64Content = Buffer.from(base64String, 'binary') 717 718 // Verify the length matches the base64 string length 719 expect(base64Content.length).toBe(base64String.length) 720 721 // Verify CID is computed correctly 722 const cid = computeCID(base64Content) 723 expect(cid).toMatch(/^bafkrei/) 724 }) 725}) 726 727describe('extractBlobMap', () => { 728 test('should extract blob map from flat directory structure', () => { 729 const mockCid = CID.parse(TEST_CID_STRING) 730 const mockBlob = new BlobRef(mockCid, 'text/html', 100) 731 732 const directory: Directory = { 733 $type: 'place.wisp.fs#directory', 734 type: 'directory', 735 entries: [ 736 { 737 name: 'index.html', 738 node: { 739 $type: 'place.wisp.fs#file', 740 type: 'file', 741 blob: mockBlob, 742 }, 743 }, 744 ], 745 } 746 747 const blobMap = extractBlobMap(directory) 748 749 expect(blobMap.size).toBe(1) 750 expect(blobMap.has('index.html')).toBe(true) 751 752 const entry = blobMap.get('index.html') 753 expect(entry?.cid).toBe(TEST_CID_STRING) 754 expect(entry?.blobRef).toBe(mockBlob) 755 }) 756 757 test('should extract blob map from nested directory structure', () => { 758 const mockCid1 = CID.parse(TEST_CID_STRING) 759 const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 760 761 const mockBlob1 = new BlobRef(mockCid1, 'text/html', 100) 762 const mockBlob2 = new BlobRef(mockCid2, 'text/css', 50) 763 764 const directory: Directory = { 765 $type: 'place.wisp.fs#directory', 766 type: 'directory', 767 entries: [ 768 { 769 name: 'index.html', 770 node: { 771 $type: 'place.wisp.fs#file', 772 type: 'file', 773 blob: mockBlob1, 774 }, 775 }, 776 { 777 name: 'assets', 778 node: { 779 $type: 'place.wisp.fs#directory', 780 type: 'directory', 781 entries: [ 782 { 783 name: 'styles.css', 784 node: { 785 $type: 'place.wisp.fs#file', 786 type: 'file', 787 blob: mockBlob2, 788 }, 789 }, 790 ], 791 }, 792 }, 793 ], 794 } 795 796 const blobMap = extractBlobMap(directory) 797 798 expect(blobMap.size).toBe(2) 799 expect(blobMap.has('index.html')).toBe(true) 800 expect(blobMap.has('assets/styles.css')).toBe(true) 801 802 expect(blobMap.get('index.html')?.cid).toBe(TEST_CID_STRING) 803 expect(blobMap.get('assets/styles.css')?.cid).toBe('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 804 }) 805 806 test('should handle deeply nested directory structures', () => { 807 const mockCid = CID.parse(TEST_CID_STRING) 808 const mockBlob = new BlobRef(mockCid, 'text/javascript', 200) 809 810 const directory: Directory = { 811 $type: 'place.wisp.fs#directory', 812 type: 'directory', 813 entries: [ 814 { 815 name: 'src', 816 node: { 817 $type: 'place.wisp.fs#directory', 818 type: 'directory', 819 entries: [ 820 { 821 name: 'lib', 822 node: { 823 $type: 'place.wisp.fs#directory', 824 type: 'directory', 825 entries: [ 826 { 827 name: 'utils.js', 828 node: { 829 $type: 'place.wisp.fs#file', 830 type: 'file', 831 blob: mockBlob, 832 }, 833 }, 834 ], 835 }, 836 }, 837 ], 838 }, 839 }, 840 ], 841 } 842 843 const blobMap = extractBlobMap(directory) 844 845 expect(blobMap.size).toBe(1) 846 expect(blobMap.has('src/lib/utils.js')).toBe(true) 847 expect(blobMap.get('src/lib/utils.js')?.cid).toBe(TEST_CID_STRING) 848 }) 849 850 test('should handle empty directory', () => { 851 const directory: Directory = { 852 $type: 'place.wisp.fs#directory', 853 type: 'directory', 854 entries: [], 855 } 856 857 const blobMap = extractBlobMap(directory) 858 859 expect(blobMap.size).toBe(0) 860 }) 861 862 test('should correctly extract CID from BlobRef instances (not plain objects)', () => { 863 // This test verifies the fix: AT Protocol SDK returns BlobRef instances, 864 // not plain objects with $type and $link properties 865 const mockCid = CID.parse(TEST_CID_STRING) 866 const mockBlob = new BlobRef(mockCid, 'application/octet-stream', 500) 867 868 const directory: Directory = { 869 $type: 'place.wisp.fs#directory', 870 type: 'directory', 871 entries: [ 872 { 873 name: 'test.bin', 874 node: { 875 $type: 'place.wisp.fs#file', 876 type: 'file', 877 blob: mockBlob, 878 }, 879 }, 880 ], 881 } 882 883 const blobMap = extractBlobMap(directory) 884 885 // The fix: we call .toString() on the CID instance instead of accessing $link 886 expect(blobMap.get('test.bin')?.cid).toBe(TEST_CID_STRING) 887 expect(blobMap.get('test.bin')?.blobRef.ref.toString()).toBe(TEST_CID_STRING) 888 }) 889 890 test('should handle multiple files in same directory', () => { 891 const mockCid1 = CID.parse(TEST_CID_STRING) 892 const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 893 const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq') 894 895 const mockBlob1 = new BlobRef(mockCid1, 'image/png', 1000) 896 const mockBlob2 = new BlobRef(mockCid2, 'image/png', 2000) 897 const mockBlob3 = new BlobRef(mockCid3, 'image/png', 3000) 898 899 const directory: Directory = { 900 $type: 'place.wisp.fs#directory', 901 type: 'directory', 902 entries: [ 903 { 904 name: 'images', 905 node: { 906 $type: 'place.wisp.fs#directory', 907 type: 'directory', 908 entries: [ 909 { 910 name: 'logo.png', 911 node: { 912 $type: 'place.wisp.fs#file', 913 type: 'file', 914 blob: mockBlob1, 915 }, 916 }, 917 { 918 name: 'banner.png', 919 node: { 920 $type: 'place.wisp.fs#file', 921 type: 'file', 922 blob: mockBlob2, 923 }, 924 }, 925 { 926 name: 'icon.png', 927 node: { 928 $type: 'place.wisp.fs#file', 929 type: 'file', 930 blob: mockBlob3, 931 }, 932 }, 933 ], 934 }, 935 }, 936 ], 937 } 938 939 const blobMap = extractBlobMap(directory) 940 941 expect(blobMap.size).toBe(3) 942 expect(blobMap.has('images/logo.png')).toBe(true) 943 expect(blobMap.has('images/banner.png')).toBe(true) 944 expect(blobMap.has('images/icon.png')).toBe(true) 945 }) 946 947 test('should handle mixed directory and file structure', () => { 948 const mockCid1 = CID.parse(TEST_CID_STRING) 949 const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 950 const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq') 951 952 const directory: Directory = { 953 $type: 'place.wisp.fs#directory', 954 type: 'directory', 955 entries: [ 956 { 957 name: 'index.html', 958 node: { 959 $type: 'place.wisp.fs#file', 960 type: 'file', 961 blob: new BlobRef(mockCid1, 'text/html', 100), 962 }, 963 }, 964 { 965 name: 'assets', 966 node: { 967 $type: 'place.wisp.fs#directory', 968 type: 'directory', 969 entries: [ 970 { 971 name: 'styles.css', 972 node: { 973 $type: 'place.wisp.fs#file', 974 type: 'file', 975 blob: new BlobRef(mockCid2, 'text/css', 50), 976 }, 977 }, 978 ], 979 }, 980 }, 981 { 982 name: 'README.md', 983 node: { 984 $type: 'place.wisp.fs#file', 985 type: 'file', 986 blob: new BlobRef(mockCid3, 'text/markdown', 200), 987 }, 988 }, 989 ], 990 } 991 992 const blobMap = extractBlobMap(directory) 993 994 expect(blobMap.size).toBe(3) 995 expect(blobMap.has('index.html')).toBe(true) 996 expect(blobMap.has('assets/styles.css')).toBe(true) 997 expect(blobMap.has('README.md')).toBe(true) 998 }) 999})