Skip to content

Commit 1513771

Browse files
author
StackMemory Bot (CLI)
committed
test(coverage): add tests for env, formatting, async-mutex, streaming-jsonl-parser
63 new tests covering 4 previously-untested utility modules: - env.ts: getEnv, getOptionalEnv, getRequiredEnv, getBooleanEnv, getNumberEnv - formatting.ts: formatBytes, formatDuration, formatRelativeTime, truncate, formatPercent, createProgressBar - async-mutex.ts: acquire, tryAcquire, withLock, stale lock detection, getStatus - streaming-jsonl-parser.ts: parseAll, parseStream batching, process, countLines, sampleLines, createTransformStream
1 parent 75e48c8 commit 1513771

4 files changed

Lines changed: 553 additions & 0 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import * as os from 'os';
5+
import { StreamingJSONLParser } from '../streaming-jsonl-parser.js';
6+
7+
describe('StreamingJSONLParser', () => {
8+
let tmpDir: string;
9+
let parser: StreamingJSONLParser;
10+
11+
beforeEach(() => {
12+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-test-'));
13+
parser = new StreamingJSONLParser();
14+
});
15+
16+
afterEach(() => {
17+
fs.rmSync(tmpDir, { recursive: true, force: true });
18+
});
19+
20+
function writeJsonl(name: string, lines: unknown[]): string {
21+
const fp = path.join(tmpDir, name);
22+
fs.writeFileSync(fp, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
23+
return fp;
24+
}
25+
26+
describe('parseAll', () => {
27+
it('parses all lines from a JSONL file', async () => {
28+
const fp = writeJsonl('basic.jsonl', [
29+
{ id: 1, name: 'a' },
30+
{ id: 2, name: 'b' },
31+
{ id: 3, name: 'c' },
32+
]);
33+
34+
const result = await parser.parseAll(fp);
35+
expect(result).toHaveLength(3);
36+
expect(result[0]).toEqual({ id: 1, name: 'a' });
37+
});
38+
39+
it('skips invalid JSON lines', async () => {
40+
const fp = path.join(tmpDir, 'bad.jsonl');
41+
fs.writeFileSync(fp, '{"ok":true}\nnot json\n{"ok":false}\n');
42+
43+
const result = await parser.parseAll(fp);
44+
expect(result).toHaveLength(2);
45+
});
46+
47+
it('skips empty lines', async () => {
48+
const fp = path.join(tmpDir, 'empty.jsonl');
49+
fs.writeFileSync(fp, '{"a":1}\n\n\n{"b":2}\n');
50+
51+
const result = await parser.parseAll(fp);
52+
expect(result).toHaveLength(2);
53+
});
54+
55+
it('applies filter', async () => {
56+
const fp = writeJsonl('filter.jsonl', [
57+
{ type: 'error', msg: 'bad' },
58+
{ type: 'info', msg: 'ok' },
59+
{ type: 'error', msg: 'worse' },
60+
]);
61+
62+
const result = await parser.parseAll(fp, {
63+
filter: (obj) => obj.type === 'error',
64+
});
65+
expect(result).toHaveLength(2);
66+
});
67+
68+
it('applies transform', async () => {
69+
const fp = writeJsonl('transform.jsonl', [{ x: 1 }, { x: 2 }]);
70+
71+
const result = await parser.parseAll(fp, {
72+
transform: (obj) => ({ ...obj, doubled: obj.x * 2 }),
73+
});
74+
expect(result[0]).toEqual({ x: 1, doubled: 2 });
75+
expect(result[1]).toEqual({ x: 2, doubled: 4 });
76+
});
77+
});
78+
79+
describe('parseStream', () => {
80+
it('yields batches of specified size', async () => {
81+
const lines = Array.from({ length: 10 }, (_, i) => ({ i }));
82+
const fp = writeJsonl('batch.jsonl', lines);
83+
84+
const batches: unknown[][] = [];
85+
for await (const batch of parser.parseStream(fp, { batchSize: 3 })) {
86+
batches.push(batch);
87+
}
88+
89+
expect(batches).toHaveLength(4); // 3+3+3+1
90+
expect(batches[0]).toHaveLength(3);
91+
expect(batches[3]).toHaveLength(1);
92+
});
93+
94+
it('calls onProgress callback', async () => {
95+
const fp = writeJsonl('progress.jsonl', [{ a: 1 }, { a: 2 }, { a: 3 }]);
96+
const progressCalls: number[] = [];
97+
98+
const results: unknown[] = [];
99+
for await (const batch of parser.parseStream(fp, {
100+
batchSize: 2,
101+
onProgress: (n) => progressCalls.push(n),
102+
})) {
103+
results.push(...batch);
104+
}
105+
106+
expect(results).toHaveLength(3);
107+
expect(progressCalls.length).toBeGreaterThan(0);
108+
});
109+
110+
it('skips oversized lines', async () => {
111+
const fp = path.join(tmpDir, 'oversized.jsonl');
112+
const bigLine = JSON.stringify({ data: 'x'.repeat(200) });
113+
const smallLine = JSON.stringify({ data: 'ok' });
114+
fs.writeFileSync(fp, `${smallLine}\n${bigLine}\n${smallLine}\n`);
115+
116+
const result = await parser.parseAll(fp, { maxLineLength: 100 });
117+
expect(result).toHaveLength(2);
118+
});
119+
});
120+
121+
describe('process', () => {
122+
it('processes batches with custom function', async () => {
123+
const fp = writeJsonl('process.jsonl', [{ v: 1 }, { v: 2 }, { v: 3 }]);
124+
125+
const sums = await parser.process<{ v: number }, number>(
126+
fp,
127+
async (items) => items.reduce((s, i) => s + i.v, 0),
128+
{ batchSize: 2 }
129+
);
130+
131+
// batch1: 1+2=3, batch2: 3
132+
expect(sums).toEqual([3, 3]);
133+
});
134+
});
135+
136+
describe('countLines', () => {
137+
it('counts all lines including empty', async () => {
138+
const fp = path.join(tmpDir, 'count.jsonl');
139+
fs.writeFileSync(fp, '{"a":1}\n{"a":2}\n{"a":3}\n');
140+
141+
const count = await parser.countLines(fp);
142+
// 3 content lines + 1 trailing empty line from final \n
143+
expect(count).toBeGreaterThanOrEqual(3);
144+
});
145+
});
146+
147+
describe('sampleLines', () => {
148+
it('throws for invalid sample rate', async () => {
149+
const fp = writeJsonl('sample.jsonl', [{ a: 1 }]);
150+
151+
await expect(async () => {
152+
for await (const _ of parser.sampleLines(fp, 0)) {
153+
// consume
154+
}
155+
}).rejects.toThrow(/Sample rate must be between 0 and 1/);
156+
});
157+
158+
it('yields subset of lines at rate 1.0', async () => {
159+
const lines = Array.from({ length: 5 }, (_, i) => ({ i }));
160+
const fp = writeJsonl('sample-all.jsonl', lines);
161+
162+
const results: unknown[] = [];
163+
for await (const item of parser.sampleLines(fp, 1.0)) {
164+
results.push(item);
165+
}
166+
expect(results).toHaveLength(5);
167+
});
168+
});
169+
170+
describe('createTransformStream', () => {
171+
it('transforms JSONL chunks in object mode', async () => {
172+
const transform = parser.createTransformStream({
173+
filter: (obj) => obj.keep,
174+
});
175+
176+
const results: unknown[] = [];
177+
transform.on('data', (obj) => results.push(obj));
178+
179+
await new Promise<void>((resolve, reject) => {
180+
transform.write('{"keep":true,"v":1}\n{"keep":false,"v":2}\n');
181+
transform.end(() => {
182+
resolve();
183+
});
184+
transform.on('error', reject);
185+
});
186+
187+
expect(results).toHaveLength(1);
188+
expect(results[0]).toEqual({ keep: true, v: 1 });
189+
});
190+
});
191+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import { AsyncMutex } from '../async-mutex.js';
3+
4+
describe('AsyncMutex', () => {
5+
afterEach(() => {
6+
vi.restoreAllMocks();
7+
});
8+
9+
it('acquires and releases lock', async () => {
10+
const mutex = new AsyncMutex();
11+
expect(mutex.isLocked()).toBe(false);
12+
13+
const release = await mutex.acquire('test');
14+
expect(mutex.isLocked()).toBe(true);
15+
16+
release();
17+
expect(mutex.isLocked()).toBe(false);
18+
});
19+
20+
it('queues waiters when locked', async () => {
21+
const mutex = new AsyncMutex();
22+
const order: number[] = [];
23+
24+
const release1 = await mutex.acquire('first');
25+
order.push(1);
26+
27+
const p2 = mutex.acquire('second').then((rel) => {
28+
order.push(2);
29+
rel();
30+
});
31+
32+
const p3 = mutex.acquire('third').then((rel) => {
33+
order.push(3);
34+
rel();
35+
});
36+
37+
release1();
38+
await p2;
39+
await p3;
40+
41+
expect(order).toEqual([1, 2, 3]);
42+
});
43+
44+
it('tryAcquire returns null when locked', async () => {
45+
const mutex = new AsyncMutex();
46+
const release = await mutex.acquire();
47+
48+
expect(mutex.tryAcquire('other')).toBeNull();
49+
50+
release();
51+
const rel2 = mutex.tryAcquire('other');
52+
expect(rel2).toBeInstanceOf(Function);
53+
rel2!();
54+
});
55+
56+
it('withLock executes fn and releases', async () => {
57+
const mutex = new AsyncMutex();
58+
const result = await mutex.withLock(async () => {
59+
expect(mutex.isLocked()).toBe(true);
60+
return 42;
61+
}, 'holder');
62+
63+
expect(result).toBe(42);
64+
expect(mutex.isLocked()).toBe(false);
65+
});
66+
67+
it('withLock releases on error', async () => {
68+
const mutex = new AsyncMutex();
69+
await expect(
70+
mutex.withLock(async () => {
71+
throw new Error('boom');
72+
})
73+
).rejects.toThrow('boom');
74+
75+
expect(mutex.isLocked()).toBe(false);
76+
});
77+
78+
it('detects stale lock on acquire', async () => {
79+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
80+
const mutex = new AsyncMutex(100); // 100ms timeout
81+
82+
await mutex.acquire('stale-holder');
83+
// Simulate time passing
84+
await new Promise((r) => setTimeout(r, 150));
85+
86+
const release = await mutex.acquire('new-holder');
87+
expect(warnSpy).toHaveBeenCalledWith(
88+
expect.stringContaining('Stale lock detected')
89+
);
90+
release();
91+
});
92+
93+
it('detects stale lock on tryAcquire', async () => {
94+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
95+
const mutex = new AsyncMutex(100);
96+
97+
await mutex.acquire('stale');
98+
await new Promise((r) => setTimeout(r, 150));
99+
100+
const release = mutex.tryAcquire('new');
101+
expect(release).toBeInstanceOf(Function);
102+
expect(warnSpy).toHaveBeenCalledWith(
103+
expect.stringContaining('Stale lock detected')
104+
);
105+
release!();
106+
});
107+
108+
it('getStatus returns lock info', async () => {
109+
const mutex = new AsyncMutex();
110+
const status1 = mutex.getStatus();
111+
expect(status1.locked).toBe(false);
112+
expect(status1.holder).toBeNull();
113+
expect(status1.waitingCount).toBe(0);
114+
115+
const release = await mutex.acquire('me');
116+
const status2 = mutex.getStatus();
117+
expect(status2.locked).toBe(true);
118+
expect(status2.holder).toBe('me');
119+
expect(status2.acquiredAt).toBeGreaterThan(0);
120+
121+
release();
122+
});
123+
});

0 commit comments

Comments
 (0)