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 type UploadedFile, 9 type FileUploadResult, 10} from './wisp-utils' 11import type { Directory } from '../lexicons/types/place/wisp/fs' 12import { gunzipSync } from 'zlib' 13import { BlobRef } from '@atproto/api' 14import { CID } from 'multiformats/cid' 15 16// Helper function to create a valid CID for testing 17// Using a real valid CID from actual AT Protocol usage 18const TEST_CID_STRING = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y' 19 20function createMockBlobRef(mimeType: string, size: number): BlobRef { 21 // Create a properly formatted CID 22 const cid = CID.parse(TEST_CID_STRING) 23 return new BlobRef(cid, mimeType, size) 24} 25 26describe('shouldCompressFile', () => { 27 test('should compress HTML files', () => { 28 expect(shouldCompressFile('text/html')).toBe(true) 29 expect(shouldCompressFile('text/html; charset=utf-8')).toBe(true) 30 }) 31 32 test('should compress CSS files', () => { 33 expect(shouldCompressFile('text/css')).toBe(true) 34 }) 35 36 test('should compress JavaScript files', () => { 37 expect(shouldCompressFile('text/javascript')).toBe(true) 38 expect(shouldCompressFile('application/javascript')).toBe(true) 39 expect(shouldCompressFile('application/x-javascript')).toBe(true) 40 }) 41 42 test('should compress JSON files', () => { 43 expect(shouldCompressFile('application/json')).toBe(true) 44 }) 45 46 test('should compress SVG files', () => { 47 expect(shouldCompressFile('image/svg+xml')).toBe(true) 48 }) 49 50 test('should compress XML files', () => { 51 expect(shouldCompressFile('text/xml')).toBe(true) 52 expect(shouldCompressFile('application/xml')).toBe(true) 53 }) 54 55 test('should compress plain text files', () => { 56 expect(shouldCompressFile('text/plain')).toBe(true) 57 }) 58 59 test('should NOT compress images', () => { 60 expect(shouldCompressFile('image/png')).toBe(false) 61 expect(shouldCompressFile('image/jpeg')).toBe(false) 62 expect(shouldCompressFile('image/jpg')).toBe(false) 63 expect(shouldCompressFile('image/gif')).toBe(false) 64 expect(shouldCompressFile('image/webp')).toBe(false) 65 }) 66 67 test('should NOT compress videos', () => { 68 expect(shouldCompressFile('video/mp4')).toBe(false) 69 expect(shouldCompressFile('video/webm')).toBe(false) 70 }) 71 72 test('should NOT compress already compressed formats', () => { 73 expect(shouldCompressFile('application/zip')).toBe(false) 74 expect(shouldCompressFile('application/gzip')).toBe(false) 75 expect(shouldCompressFile('application/pdf')).toBe(false) 76 }) 77 78 test('should NOT compress fonts', () => { 79 expect(shouldCompressFile('font/woff')).toBe(false) 80 expect(shouldCompressFile('font/woff2')).toBe(false) 81 expect(shouldCompressFile('font/ttf')).toBe(false) 82 }) 83}) 84 85describe('compressFile', () => { 86 test('should compress text content', () => { 87 const content = Buffer.from('Hello, World! '.repeat(100)) 88 const compressed = compressFile(content) 89 90 expect(compressed.length).toBeLessThan(content.length) 91 92 // Verify we can decompress it back 93 const decompressed = gunzipSync(compressed) 94 expect(decompressed.toString()).toBe(content.toString()) 95 }) 96 97 test('should compress HTML content significantly', () => { 98 const html = ` 99 <!DOCTYPE html> 100 <html> 101 <head><title>Test</title></head> 102 <body> 103 ${'<p>Hello World!</p>\n'.repeat(50)} 104 </body> 105 </html> 106 ` 107 const content = Buffer.from(html) 108 const compressed = compressFile(content) 109 110 expect(compressed.length).toBeLessThan(content.length) 111 112 // Verify decompression 113 const decompressed = gunzipSync(compressed) 114 expect(decompressed.toString()).toBe(html) 115 }) 116 117 test('should handle empty content', () => { 118 const content = Buffer.from('') 119 const compressed = compressFile(content) 120 const decompressed = gunzipSync(compressed) 121 expect(decompressed.toString()).toBe('') 122 }) 123 124 test('should produce deterministic compression', () => { 125 const content = Buffer.from('Test content') 126 const compressed1 = compressFile(content) 127 const compressed2 = compressFile(content) 128 129 expect(compressed1.toString('base64')).toBe(compressed2.toString('base64')) 130 }) 131}) 132 133describe('processUploadedFiles', () => { 134 test('should process single root-level file', () => { 135 const files: UploadedFile[] = [ 136 { 137 name: 'index.html', 138 content: Buffer.from('<html></html>'), 139 mimeType: 'text/html', 140 size: 13, 141 }, 142 ] 143 144 const result = processUploadedFiles(files) 145 146 expect(result.fileCount).toBe(1) 147 expect(result.directory.type).toBe('directory') 148 expect(result.directory.entries).toHaveLength(1) 149 expect(result.directory.entries[0].name).toBe('index.html') 150 151 const node = result.directory.entries[0].node 152 expect('blob' in node).toBe(true) // It's a file node 153 }) 154 155 test('should process multiple root-level files', () => { 156 const files: UploadedFile[] = [ 157 { 158 name: 'index.html', 159 content: Buffer.from('<html></html>'), 160 mimeType: 'text/html', 161 size: 13, 162 }, 163 { 164 name: 'styles.css', 165 content: Buffer.from('body {}'), 166 mimeType: 'text/css', 167 size: 7, 168 }, 169 { 170 name: 'script.js', 171 content: Buffer.from('console.log("hi")'), 172 mimeType: 'application/javascript', 173 size: 17, 174 }, 175 ] 176 177 const result = processUploadedFiles(files) 178 179 expect(result.fileCount).toBe(3) 180 expect(result.directory.entries).toHaveLength(3) 181 182 const names = result.directory.entries.map(e => e.name) 183 expect(names).toContain('index.html') 184 expect(names).toContain('styles.css') 185 expect(names).toContain('script.js') 186 }) 187 188 test('should process files with subdirectories', () => { 189 const files: UploadedFile[] = [ 190 { 191 name: 'dist/index.html', 192 content: Buffer.from('<html></html>'), 193 mimeType: 'text/html', 194 size: 13, 195 }, 196 { 197 name: 'dist/css/styles.css', 198 content: Buffer.from('body {}'), 199 mimeType: 'text/css', 200 size: 7, 201 }, 202 { 203 name: 'dist/js/app.js', 204 content: Buffer.from('console.log()'), 205 mimeType: 'application/javascript', 206 size: 13, 207 }, 208 ] 209 210 const result = processUploadedFiles(files) 211 212 expect(result.fileCount).toBe(3) 213 expect(result.directory.entries).toHaveLength(3) // index.html, css/, js/ 214 215 // Check root has index.html (after base folder removal) 216 const indexEntry = result.directory.entries.find(e => e.name === 'index.html') 217 expect(indexEntry).toBeDefined() 218 219 // Check css directory exists 220 const cssDir = result.directory.entries.find(e => e.name === 'css') 221 expect(cssDir).toBeDefined() 222 expect('entries' in cssDir!.node).toBe(true) 223 224 if ('entries' in cssDir!.node) { 225 expect(cssDir!.node.entries).toHaveLength(1) 226 expect(cssDir!.node.entries[0].name).toBe('styles.css') 227 } 228 229 // Check js directory exists 230 const jsDir = result.directory.entries.find(e => e.name === 'js') 231 expect(jsDir).toBeDefined() 232 expect('entries' in jsDir!.node).toBe(true) 233 }) 234 235 test('should handle deeply nested subdirectories', () => { 236 const files: UploadedFile[] = [ 237 { 238 name: 'dist/deep/nested/folder/file.txt', 239 content: Buffer.from('content'), 240 mimeType: 'text/plain', 241 size: 7, 242 }, 243 ] 244 245 const result = processUploadedFiles(files) 246 247 expect(result.fileCount).toBe(1) 248 249 // Navigate through the directory structure (base folder removed) 250 const deepDir = result.directory.entries.find(e => e.name === 'deep') 251 expect(deepDir).toBeDefined() 252 expect('entries' in deepDir!.node).toBe(true) 253 254 if ('entries' in deepDir!.node) { 255 const nestedDir = deepDir!.node.entries.find(e => e.name === 'nested') 256 expect(nestedDir).toBeDefined() 257 258 if (nestedDir && 'entries' in nestedDir.node) { 259 const folderDir = nestedDir.node.entries.find(e => e.name === 'folder') 260 expect(folderDir).toBeDefined() 261 262 if (folderDir && 'entries' in folderDir.node) { 263 expect(folderDir.node.entries).toHaveLength(1) 264 expect(folderDir.node.entries[0].name).toBe('file.txt') 265 } 266 } 267 } 268 }) 269 270 test('should remove base folder name from paths', () => { 271 const files: UploadedFile[] = [ 272 { 273 name: 'dist/index.html', 274 content: Buffer.from('<html></html>'), 275 mimeType: 'text/html', 276 size: 13, 277 }, 278 { 279 name: 'dist/css/styles.css', 280 content: Buffer.from('body {}'), 281 mimeType: 'text/css', 282 size: 7, 283 }, 284 ] 285 286 const result = processUploadedFiles(files) 287 288 // After removing 'dist/', we should have index.html and css/ at root 289 expect(result.directory.entries.find(e => e.name === 'index.html')).toBeDefined() 290 expect(result.directory.entries.find(e => e.name === 'css')).toBeDefined() 291 expect(result.directory.entries.find(e => e.name === 'dist')).toBeUndefined() 292 }) 293 294 test('should handle empty file list', () => { 295 const files: UploadedFile[] = [] 296 const result = processUploadedFiles(files) 297 298 expect(result.fileCount).toBe(0) 299 expect(result.directory.entries).toHaveLength(0) 300 }) 301 302 test('should handle multiple files in same subdirectory', () => { 303 const files: UploadedFile[] = [ 304 { 305 name: 'dist/assets/image1.png', 306 content: Buffer.from('png1'), 307 mimeType: 'image/png', 308 size: 4, 309 }, 310 { 311 name: 'dist/assets/image2.png', 312 content: Buffer.from('png2'), 313 mimeType: 'image/png', 314 size: 4, 315 }, 316 ] 317 318 const result = processUploadedFiles(files) 319 320 expect(result.fileCount).toBe(2) 321 322 const assetsDir = result.directory.entries.find(e => e.name === 'assets') 323 expect(assetsDir).toBeDefined() 324 325 if ('entries' in assetsDir!.node) { 326 expect(assetsDir!.node.entries).toHaveLength(2) 327 const names = assetsDir!.node.entries.map(e => e.name) 328 expect(names).toContain('image1.png') 329 expect(names).toContain('image2.png') 330 } 331 }) 332}) 333 334describe('createManifest', () => { 335 test('should create valid manifest', () => { 336 const root: Directory = { 337 $type: 'place.wisp.fs#directory', 338 type: 'directory', 339 entries: [], 340 } 341 342 const manifest = createManifest('example.com', root, 0) 343 344 expect(manifest.$type).toBe('place.wisp.fs') 345 expect(manifest.site).toBe('example.com') 346 expect(manifest.root).toBe(root) 347 expect(manifest.fileCount).toBe(0) 348 expect(manifest.createdAt).toBeDefined() 349 350 // Verify it's a valid ISO date string 351 const date = new Date(manifest.createdAt) 352 expect(date.toISOString()).toBe(manifest.createdAt) 353 }) 354 355 test('should create manifest with file count', () => { 356 const root: Directory = { 357 $type: 'place.wisp.fs#directory', 358 type: 'directory', 359 entries: [], 360 } 361 362 const manifest = createManifest('test-site', root, 42) 363 364 expect(manifest.fileCount).toBe(42) 365 expect(manifest.site).toBe('test-site') 366 }) 367 368 test('should create manifest with populated directory', () => { 369 const mockBlob = createMockBlobRef('text/html', 100) 370 371 const root: Directory = { 372 $type: 'place.wisp.fs#directory', 373 type: 'directory', 374 entries: [ 375 { 376 name: 'index.html', 377 node: { 378 $type: 'place.wisp.fs#file', 379 type: 'file', 380 blob: mockBlob, 381 }, 382 }, 383 ], 384 } 385 386 const manifest = createManifest('populated-site', root, 1) 387 388 expect(manifest).toBeDefined() 389 expect(manifest.site).toBe('populated-site') 390 expect(manifest.root.entries).toHaveLength(1) 391 }) 392}) 393 394describe('updateFileBlobs', () => { 395 test('should update single file blob at root', () => { 396 const directory: Directory = { 397 $type: 'place.wisp.fs#directory', 398 type: 'directory', 399 entries: [ 400 { 401 name: 'index.html', 402 node: { 403 $type: 'place.wisp.fs#file', 404 type: 'file', 405 blob: undefined as any, 406 }, 407 }, 408 ], 409 } 410 411 const mockBlob = createMockBlobRef('text/html', 100) 412 const uploadResults: FileUploadResult[] = [ 413 { 414 hash: TEST_CID_STRING, 415 blobRef: mockBlob, 416 mimeType: 'text/html', 417 }, 418 ] 419 420 const filePaths = ['index.html'] 421 422 const updated = updateFileBlobs(directory, uploadResults, filePaths) 423 424 expect(updated.entries).toHaveLength(1) 425 const fileNode = updated.entries[0].node 426 427 if ('blob' in fileNode) { 428 expect(fileNode.blob).toBeDefined() 429 expect(fileNode.blob.mimeType).toBe('text/html') 430 expect(fileNode.blob.size).toBe(100) 431 } else { 432 throw new Error('Expected file node') 433 } 434 }) 435 436 test('should update files in nested directories', () => { 437 const directory: Directory = { 438 $type: 'place.wisp.fs#directory', 439 type: 'directory', 440 entries: [ 441 { 442 name: 'css', 443 node: { 444 $type: 'place.wisp.fs#directory', 445 type: 'directory', 446 entries: [ 447 { 448 name: 'styles.css', 449 node: { 450 $type: 'place.wisp.fs#file', 451 type: 'file', 452 blob: undefined as any, 453 }, 454 }, 455 ], 456 }, 457 }, 458 ], 459 } 460 461 const mockBlob = createMockBlobRef('text/css', 50) 462 const uploadResults: FileUploadResult[] = [ 463 { 464 hash: TEST_CID_STRING, 465 blobRef: mockBlob, 466 mimeType: 'text/css', 467 encoding: 'gzip', 468 }, 469 ] 470 471 const filePaths = ['css/styles.css'] 472 473 const updated = updateFileBlobs(directory, uploadResults, filePaths) 474 475 const cssDir = updated.entries[0] 476 expect(cssDir.name).toBe('css') 477 478 if ('entries' in cssDir.node) { 479 const cssFile = cssDir.node.entries[0] 480 expect(cssFile.name).toBe('styles.css') 481 482 if ('blob' in cssFile.node) { 483 expect(cssFile.node.blob.mimeType).toBe('text/css') 484 if ('encoding' in cssFile.node) { 485 expect(cssFile.node.encoding).toBe('gzip') 486 } 487 } else { 488 throw new Error('Expected file node') 489 } 490 } else { 491 throw new Error('Expected directory node') 492 } 493 }) 494 495 test('should handle normalized paths with base folder removed', () => { 496 const directory: Directory = { 497 $type: 'place.wisp.fs#directory', 498 type: 'directory', 499 entries: [ 500 { 501 name: 'index.html', 502 node: { 503 $type: 'place.wisp.fs#file', 504 type: 'file', 505 blob: undefined as any, 506 }, 507 }, 508 ], 509 } 510 511 const mockBlob = createMockBlobRef('text/html', 100) 512 const uploadResults: FileUploadResult[] = [ 513 { 514 hash: TEST_CID_STRING, 515 blobRef: mockBlob, 516 }, 517 ] 518 519 // Path includes base folder that should be normalized 520 const filePaths = ['dist/index.html'] 521 522 const updated = updateFileBlobs(directory, uploadResults, filePaths) 523 524 const fileNode = updated.entries[0].node 525 if ('blob' in fileNode) { 526 expect(fileNode.blob).toBeDefined() 527 } else { 528 throw new Error('Expected file node') 529 } 530 }) 531 532 test('should preserve file metadata (encoding, mimeType, base64)', () => { 533 const directory: Directory = { 534 $type: 'place.wisp.fs#directory', 535 type: 'directory', 536 entries: [ 537 { 538 name: 'data.json', 539 node: { 540 $type: 'place.wisp.fs#file', 541 type: 'file', 542 blob: undefined as any, 543 }, 544 }, 545 ], 546 } 547 548 const mockBlob = createMockBlobRef('application/json', 200) 549 const uploadResults: FileUploadResult[] = [ 550 { 551 hash: TEST_CID_STRING, 552 blobRef: mockBlob, 553 mimeType: 'application/json', 554 encoding: 'gzip', 555 base64: true, 556 }, 557 ] 558 559 const filePaths = ['data.json'] 560 561 const updated = updateFileBlobs(directory, uploadResults, filePaths) 562 563 const fileNode = updated.entries[0].node 564 if ('blob' in fileNode && 'mimeType' in fileNode && 'encoding' in fileNode && 'base64' in fileNode) { 565 expect(fileNode.mimeType).toBe('application/json') 566 expect(fileNode.encoding).toBe('gzip') 567 expect(fileNode.base64).toBe(true) 568 } else { 569 throw new Error('Expected file node with metadata') 570 } 571 }) 572 573 test('should handle multiple files at different directory levels', () => { 574 const directory: Directory = { 575 $type: 'place.wisp.fs#directory', 576 type: 'directory', 577 entries: [ 578 { 579 name: 'index.html', 580 node: { 581 $type: 'place.wisp.fs#file', 582 type: 'file', 583 blob: undefined as any, 584 }, 585 }, 586 { 587 name: 'assets', 588 node: { 589 $type: 'place.wisp.fs#directory', 590 type: 'directory', 591 entries: [ 592 { 593 name: 'logo.svg', 594 node: { 595 $type: 'place.wisp.fs#file', 596 type: 'file', 597 blob: undefined as any, 598 }, 599 }, 600 ], 601 }, 602 }, 603 ], 604 } 605 606 const htmlBlob = createMockBlobRef('text/html', 100) 607 const svgBlob = createMockBlobRef('image/svg+xml', 500) 608 609 const uploadResults: FileUploadResult[] = [ 610 { 611 hash: TEST_CID_STRING, 612 blobRef: htmlBlob, 613 }, 614 { 615 hash: TEST_CID_STRING, 616 blobRef: svgBlob, 617 }, 618 ] 619 620 const filePaths = ['index.html', 'assets/logo.svg'] 621 622 const updated = updateFileBlobs(directory, uploadResults, filePaths) 623 624 // Check root file 625 const indexNode = updated.entries[0].node 626 if ('blob' in indexNode) { 627 expect(indexNode.blob.mimeType).toBe('text/html') 628 } 629 630 // Check nested file 631 const assetsDir = updated.entries[1] 632 if ('entries' in assetsDir.node) { 633 const logoNode = assetsDir.node.entries[0].node 634 if ('blob' in logoNode) { 635 expect(logoNode.blob.mimeType).toBe('image/svg+xml') 636 } 637 } 638 }) 639})