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})