⚡ Zero-dependency plcbundle library exclusively for Bun
1import { describe, test, expect, beforeEach, mock } from 'bun:test';
2import { PLCBundle } from '../src/plcbundle';
3import { TEMP_DIR, createMockIndex, createMockOperations } from './setup';
4
5describe('Bundle Processing', () => {
6 let bundle: PLCBundle;
7
8 beforeEach(async () => {
9 bundle = new PLCBundle(TEMP_DIR);
10
11 // Setup mock index
12 const mockIndex = createMockIndex();
13 await bundle.saveIndex(mockIndex);
14
15 // Create actual bundle files for testing
16 for (let i = 1; i <= 3; i++) {
17 const operations = createMockOperations(100);
18 const jsonl = operations.map(op => JSON.stringify(op)).join('\n') + '\n';
19 const uncompressed = new TextEncoder().encode(jsonl);
20 const compressed = Bun.zstdCompressSync(uncompressed);
21
22 const bundlePath = bundle.getBundlePath(i);
23 await Bun.write(bundlePath, compressed);
24 }
25 });
26
27 describe('parseOperations', () => {
28 test('parses JSONL content', () => {
29 const ops = createMockOperations(10);
30 const jsonl = ops.map(op => JSON.stringify(op)).join('\n');
31
32 const parsed = bundle.parseOperations(jsonl);
33 expect(parsed.length).toBe(10);
34 expect(parsed[0].did).toBeDefined();
35 });
36
37 test('handles trailing newline', () => {
38 const ops = createMockOperations(5);
39 const jsonl = ops.map(op => JSON.stringify(op)).join('\n') + '\n';
40
41 const parsed = bundle.parseOperations(jsonl);
42 expect(parsed.length).toBe(5);
43 });
44
45 test('filters empty lines', () => {
46 const ops = createMockOperations(3);
47 const jsonl = ops.map(op => JSON.stringify(op)).join('\n\n\n');
48
49 const parsed = bundle.parseOperations(jsonl);
50 expect(parsed.length).toBe(3);
51 });
52 });
53
54 describe('streamOperations', () => {
55 test('streams operations from bundle', async () => {
56 const operations = [];
57
58 for await (const { op, line } of bundle.streamOperations(1)) {
59 operations.push(op);
60 expect(line).toBeDefined();
61 expect(line.length).toBeGreaterThan(0);
62 }
63
64 expect(operations.length).toBe(100);
65 expect(operations[0].did).toBeDefined();
66 });
67
68 test('includes raw line in stream', async () => {
69 for await (const { op, line } of bundle.streamOperations(1)) {
70 expect(typeof line).toBe('string');
71 expect(line.trim().length).toBeGreaterThan(0);
72
73 // Line should be valid JSON
74 const parsed = JSON.parse(line);
75 expect(parsed.did).toBe(op.did);
76 break; // Just check first
77 }
78 });
79 });
80
81 describe('processBundles', () => {
82 test('processes with callback function', async () => {
83 const callback = mock(() => {});
84
85 const stats = await bundle.processBundles(1, 1, callback);
86
87 expect(callback).toHaveBeenCalled();
88 expect(callback.mock.calls.length).toBe(100);
89 expect(stats.totalOps).toBe(100);
90 });
91
92 test('callback receives all parameters', async () => {
93 const callback = mock((op: any, position: number, bundleNum: number, line: string) => {
94 expect(op).toBeDefined();
95 expect(typeof position).toBe('number');
96 expect(typeof bundleNum).toBe('number');
97 expect(typeof line).toBe('string');
98 });
99
100 await bundle.processBundles(1, 1, callback);
101
102 expect(callback).toHaveBeenCalled();
103 });
104
105 test('tracks statistics accurately', async () => {
106 const callback = mock(() => {});
107
108 const stats = await bundle.processBundles(1, 1, callback);
109
110 expect(stats.totalOps).toBe(100);
111 expect(stats.totalBytes).toBeGreaterThan(0);
112 expect(stats.matchCount).toBe(0);
113 expect(stats.matchedBytes).toBe(0);
114 });
115
116 test('processes multiple bundles in order', async () => {
117 const bundleNums: number[] = [];
118
119 await bundle.processBundles(1, 3, (op, position, bundleNum) => {
120 bundleNums.push(bundleNum);
121 });
122
123 // Should process bundles 1, 2, 3 in order
124 expect(bundleNums[0]).toBe(1);
125 expect(bundleNums[99]).toBe(1); // Last of bundle 1
126 expect(bundleNums[100]).toBe(2); // First of bundle 2
127 expect(bundleNums[299]).toBe(3); // Last of bundle 3
128 });
129
130 test('supports progress callback', async () => {
131 const progressCallback = mock(() => {});
132
133 await bundle.processBundles(1, 1, () => {}, {
134 onProgress: progressCallback,
135 });
136
137 // Progress should not be called for only 100 operations (threshold is 10,000)
138 expect(progressCallback.mock.calls.length).toBe(0);
139 });
140
141 test('calls progress callback at threshold', async () => {
142 // Create larger bundle to trigger progress
143 const largeOps = createMockOperations(15000);
144 const jsonl = largeOps.map(op => JSON.stringify(op)).join('\n') + '\n';
145 const compressed = Bun.zstdCompressSync(new TextEncoder().encode(jsonl));
146 await Bun.write(bundle.getBundlePath(10), compressed);
147
148 const progressCallback = mock(() => {});
149
150 await bundle.processBundles(10, 10, () => {}, {
151 onProgress: progressCallback,
152 });
153
154 // Should be called at least once (at 10,000 ops)
155 expect(progressCallback.mock.calls.length).toBeGreaterThan(0);
156 });
157
158 test('line length matches original JSONL', async () => {
159 const sizes: number[] = [];
160
161 await bundle.processBundles(1, 1, (op, position, bundleNum, line) => {
162 sizes.push(line.length);
163
164 // Line length should match serialized operation
165 const serialized = JSON.stringify(op);
166 expect(line.length).toBeGreaterThanOrEqual(serialized.length - 10); // Allow small variance
167 });
168
169 expect(sizes.length).toBe(100);
170 expect(sizes.every(s => s > 0)).toBe(true);
171 });
172
173 test('supports async callbacks', async () => {
174 const callback = mock(async (op: any) => {
175 await new Promise(resolve => setTimeout(resolve, 1));
176 });
177
178 const stats = await bundle.processBundles(1, 1, callback);
179
180 expect(callback).toHaveBeenCalled();
181 expect(stats.totalOps).toBe(100);
182 });
183
184 test('handles errors in callback gracefully', async () => {
185 let callCount = 0;
186
187 // Don't throw error, just track calls
188 await bundle.processBundles(1, 1, () => {
189 callCount++;
190 // Don't throw - just count
191 });
192
193 expect(callCount).toBe(100);
194 });
195 });
196
197 describe('processBundles with module path', () => {
198 test('loads module and calls function', async () => {
199 // Create a test module with absolute path
200 const testModulePath = `${process.cwd()}/${TEMP_DIR}/test-module.ts`;
201 await Bun.write(testModulePath, `
202 export function detect({ op }) {
203 return op.did.startsWith('did:plc:') ? ['test'] : [];
204 }
205 `);
206
207 const stats = await bundle.processBundles(1, 1, {
208 module: testModulePath,
209 });
210
211 expect(stats.totalOps).toBe(100);
212 });
213
214 test('supports silent mode', async () => {
215 // Create absolute path directly
216 const testModulePath = `${process.cwd()}/${TEMP_DIR}/noisy-module.ts`;
217 await Bun.write(testModulePath, `
218 export function detect({ op }) {
219 console.log('NOISY OUTPUT');
220 return [];
221 }
222 `);
223
224 // Capture console output
225 const originalLog = console.log;
226 const originalError = console.error;
227 let logOutput = '';
228
229 console.log = (...args: any[]) => {
230 logOutput += args.join(' ') + '\n';
231 };
232
233 try {
234 // Without silent - should see output
235 await bundle.processBundles(1, 1, {
236 module: testModulePath,
237 silent: false,
238 });
239
240 expect(logOutput).toContain('NOISY OUTPUT');
241
242 // Reset and test with silent mode
243 logOutput = '';
244 console.log = () => {};
245 console.error = () => {};
246
247 await bundle.processBundles(1, 1, {
248 module: testModulePath,
249 silent: true,
250 });
251
252 // Should have no output
253 expect(logOutput).toBe('');
254 } finally {
255 console.log = originalLog;
256 console.error = originalError;
257 }
258 });
259
260 test('loads module and calls function', async () => {
261 // Create absolute path
262 const testModulePath = `${process.cwd()}/${TEMP_DIR}/test-module.ts`;
263 await Bun.write(testModulePath, `
264 export function detect({ op }) {
265 return op.did.startsWith('did:plc:') ? ['test'] : [];
266 }
267 `);
268
269 const stats = await bundle.processBundles(1, 1, {
270 module: testModulePath,
271 });
272
273 expect(stats.totalOps).toBe(100);
274 });
275
276 test('throws error for multi-threading without module', async () => {
277 let errorThrown = false;
278 let errorMessage = '';
279
280 try {
281 await bundle.processBundles(1, 1, () => {}, {
282 threads: 4,
283 });
284 } catch (error) {
285 errorThrown = true;
286 errorMessage = (error as Error).message;
287 }
288
289 expect(errorThrown).toBe(true);
290 expect(errorMessage).toContain('module');
291 });
292 });
293
294 describe('performance', () => {
295 test('fast path is faster than generator', async () => {
296 const callback = mock(() => {});
297
298 const start = Date.now();
299 await bundle.processBundles(1, 3, callback);
300 const duration = Date.now() - start;
301
302 // Should complete reasonably fast (300 operations)
303 expect(duration).toBeLessThan(1000); // Less than 1 second
304 expect(callback.mock.calls.length).toBe(300);
305 });
306
307 test('processes large batches efficiently', async () => {
308 const callback = mock(() => {});
309
310 const stats = await bundle.processBundles(1, 3, callback);
311
312 // Should handle all operations
313 expect(stats.totalOps).toBe(300);
314 expect(stats.totalBytes).toBeGreaterThan(0);
315 });
316 });
317});