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