Skip to content

Commit a57a5c5

Browse files
committed
refactor into a testable JsonlStream interface
1 parent 068e717 commit a57a5c5

File tree

3 files changed

+186
-39
lines changed

3 files changed

+186
-39
lines changed

src/test/jsonl-stream.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import * as assert from "node:assert";
2+
3+
import { LogLevel } from "vscode";
4+
import type { LogOutputChannel, Event } from "vscode";
5+
6+
import { createJsonlStream } from "../utils/jsonl-stream.js";
7+
8+
const setup = () => {
9+
const mockOutputChannel: LogOutputChannel = {
10+
name: "test",
11+
logLevel: LogLevel.Info,
12+
onDidChangeLogLevel: {} as Event<LogLevel>,
13+
append: () => {},
14+
appendLine: () => {},
15+
replace: () => {},
16+
clear: () => {},
17+
show: () => {},
18+
hide: () => {},
19+
dispose: () => {},
20+
trace: () => {},
21+
debug: () => {},
22+
info: () => {},
23+
warn: () => {},
24+
error: () => {},
25+
};
26+
27+
const jsonlStream = createJsonlStream(mockOutputChannel);
28+
29+
const callbackCalls: unknown[] = [];
30+
const callback = (data: unknown) => {
31+
callbackCalls.push(data);
32+
};
33+
jsonlStream.on(callback);
34+
35+
return {
36+
jsonlStream,
37+
callbackCalls,
38+
};
39+
};
40+
41+
suite("JSONL Streams", () => {
42+
test("should parse and emit complete JSONL messages", () => {
43+
const { jsonlStream, callbackCalls } = setup();
44+
45+
// Test with multiple JSON objects in a single write
46+
const testData = Buffer.from('{"key1":"value1"}\n{"key2":"value2"}\n');
47+
48+
jsonlStream.write(testData);
49+
50+
assert.strictEqual(callbackCalls.length, 2);
51+
assert.deepStrictEqual(callbackCalls[0], { key1: "value1" });
52+
assert.deepStrictEqual(callbackCalls[1], { key2: "value2" });
53+
});
54+
55+
test("should handle incomplete JSONL messages across multiple writes", () => {
56+
const { jsonlStream, callbackCalls } = setup();
57+
58+
// First write with partial message
59+
const firstChunk = Buffer.from('{"key":"value');
60+
jsonlStream.write(firstChunk);
61+
62+
// Shouldn't emit anything yet
63+
assert.strictEqual(callbackCalls.length, 0);
64+
65+
// Complete the message in second write
66+
const secondChunk = Buffer.from('1"}\n');
67+
jsonlStream.write(secondChunk);
68+
69+
// Now it should emit the complete message
70+
assert.strictEqual(callbackCalls.length, 1);
71+
assert.deepStrictEqual(callbackCalls[0], { key: "value1" });
72+
});
73+
74+
test("should handle multiple messages in chunks", () => {
75+
const { jsonlStream, callbackCalls } = setup();
76+
77+
// Write first message and part of second
78+
const firstChunk = Buffer.from('{"first":1}\n{"second":');
79+
jsonlStream.write(firstChunk);
80+
81+
// First message should be emitted
82+
assert.strictEqual(callbackCalls.length, 1);
83+
assert.deepStrictEqual(callbackCalls[0], { first: 1 });
84+
85+
// Complete second message and add third
86+
const secondChunk = Buffer.from('2}\n{"third":3}\n');
87+
jsonlStream.write(secondChunk);
88+
89+
// Should have all three messages now
90+
assert.strictEqual(callbackCalls.length, 3);
91+
assert.deepStrictEqual(callbackCalls[1], { second: 2 });
92+
assert.deepStrictEqual(callbackCalls[2], { third: 3 });
93+
});
94+
95+
test("should ignore invalid JSON lines", () => {
96+
const { jsonlStream, callbackCalls } = setup();
97+
98+
const testData = Buffer.from('not json\n{"valid":true}\n{invalid}\n');
99+
100+
jsonlStream.write(testData);
101+
102+
// Should only emit the valid JSON object
103+
assert.strictEqual(callbackCalls.length, 1);
104+
assert.deepStrictEqual(callbackCalls[0], { valid: true });
105+
});
106+
107+
test("should handle empty lines", () => {
108+
const { jsonlStream, callbackCalls } = setup();
109+
110+
const testData = Buffer.from('\n\n{"key":"value"}\n\n');
111+
112+
jsonlStream.write(testData);
113+
114+
// Should only emit the valid JSON object
115+
assert.strictEqual(callbackCalls.length, 1);
116+
assert.deepStrictEqual(callbackCalls[0], { key: "value" });
117+
});
118+
});

src/utils/container-status.ts

Lines changed: 24 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Disposable, LogOutputChannel } from "vscode";
44
import * as z from "zod/v4-mini";
55

66
import { createEmitter } from "./emitter.ts";
7+
import { createJsonlStream } from "./jsonl-stream.ts";
78

89
export type ContainerStatus = "running" | "stopping" | "stopped";
910

@@ -63,14 +64,6 @@ const DockerEventsSchema = z.object({
6364
}),
6465
});
6566

66-
function safeJsonParse(text: string): unknown {
67-
try {
68-
return JSON.parse(text);
69-
} catch {
70-
return undefined;
71-
}
72-
}
73-
7467
function listenToContainerStatus(
7568
containerName: string,
7669
outputChannel: LogOutputChannel,
@@ -130,41 +123,33 @@ function listenToContainerStatus(
130123
throw new Error("Failed to get stdout from docker events process");
131124
}
132125

133-
let buffer = "";
134-
dockerEvents.stdout.on("data", (data: Buffer) => {
135-
buffer += data.toString();
136-
137-
// Process all complete lines
138-
let newlineIndex = buffer.indexOf("\n");
139-
while (newlineIndex !== -1) {
140-
const line = buffer.substring(0, newlineIndex).trim();
141-
buffer = buffer.substring(newlineIndex + 1);
142-
143-
const json = safeJsonParse(line);
144-
const parsed = DockerEventsSchema.safeParse(json);
145-
if (!parsed.success) {
146-
continue;
147-
}
148-
149-
if (parsed.data.Actor.Attributes.name !== containerName) {
150-
continue;
151-
}
126+
const jsonlStream = createJsonlStream(outputChannel);
127+
jsonlStream.on((json) => {
128+
const parsed = DockerEventsSchema.safeParse(json);
129+
if (!parsed.success) {
130+
return;
131+
}
152132

153-
switch (parsed.data.Action) {
154-
case "start":
155-
onStatusChange("running");
156-
break;
157-
case "kill":
158-
onStatusChange("stopping");
159-
break;
160-
case "die":
161-
onStatusChange("stopped");
162-
break;
163-
}
133+
if (parsed.data.Actor.Attributes.name !== containerName) {
134+
return;
135+
}
164136

165-
newlineIndex = buffer.indexOf("\n");
137+
switch (parsed.data.Action) {
138+
case "start":
139+
onStatusChange("running");
140+
break;
141+
case "kill":
142+
onStatusChange("stopping");
143+
break;
144+
case "die":
145+
onStatusChange("stopped");
146+
break;
166147
}
167148
});
149+
150+
dockerEvents.stdout.on("data", (data: Buffer) => {
151+
jsonlStream.write(data);
152+
});
168153
} catch (error) {
169154
// If we can't spawn the process, try again after a delay
170155
scheduleRestart();

src/utils/jsonl-stream.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { LogOutputChannel } from "vscode";
2+
3+
import type { Callback } from "./emitter.ts";
4+
import { createEmitter } from "./emitter.ts";
5+
6+
interface JsonlStream {
7+
write(data: Buffer): void;
8+
on(callback: Callback<unknown>): void;
9+
}
10+
11+
function safeJsonParse(text: string): unknown {
12+
try {
13+
return JSON.parse(text);
14+
} catch {
15+
return undefined;
16+
}
17+
}
18+
19+
export const createJsonlStream = (outputChannel: LogOutputChannel): JsonlStream => {
20+
const emitter = createEmitter(outputChannel)
21+
let buffer = "";
22+
return {
23+
write(data) {
24+
buffer += data.toString();
25+
26+
// Process all complete lines
27+
let newlineIndex = buffer.indexOf("\n");
28+
while (newlineIndex !== -1) {
29+
const line = buffer.substring(0, newlineIndex).trim();
30+
buffer = buffer.substring(newlineIndex + 1);
31+
32+
const json = safeJsonParse(line);
33+
if (json) {
34+
void emitter.emit(json)
35+
}
36+
37+
newlineIndex = buffer.indexOf("\n");
38+
}
39+
},
40+
on(callback) {
41+
emitter.on(callback)
42+
},
43+
}
44+
};

0 commit comments

Comments
 (0)