Skip to content

Commit 3c499ba

Browse files
authored
Add SSE Connection and streamingFetchAdapter tests (#625)
1 parent f9b1f25 commit 3c499ba

File tree

9 files changed

+595
-17
lines changed

9 files changed

+595
-17
lines changed

src/api/coderApi.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,9 @@ export class CoderApi extends Api {
110110
options?: ClientOptions,
111111
) => {
112112
const searchParams = new URLSearchParams({ follow: "true" });
113-
if (logs.length) {
114-
searchParams.append("after", logs[logs.length - 1].id.toString());
113+
const lastLog = logs.at(-1);
114+
if (lastLog) {
115+
searchParams.append("after", lastLog.id.toString());
115116
}
116117

117118
return this.createWebSocket<ProvisionerJobLog>({
@@ -311,9 +312,9 @@ function setupInterceptors(
311312
output,
312313
);
313314
// Add headers from the header command.
314-
Object.entries(headers).forEach(([key, value]) => {
315+
for (const [key, value] of Object.entries(headers)) {
315316
config.headers[key] = value;
316-
});
317+
}
317318

318319
// Configure proxy and TLS.
319320
// Note that by default VS Code overrides the agent. To prevent this, set
@@ -425,7 +426,7 @@ function wrapResponseTransform(
425426
function getSize(headers: AxiosHeaders, data: unknown): number | undefined {
426427
const contentLength = headers["content-length"];
427428
if (contentLength !== undefined) {
428-
return parseInt(contentLength, 10);
429+
return Number.parseInt(contentLength, 10);
429430
}
430431

431432
return sizeOf(data);

src/api/streamingFetchAdapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type AxiosInstance } from "axios";
22
import { type FetchLikeInit, type FetchLikeResponse } from "eventsource";
3-
import { type IncomingMessage } from "http";
3+
import { type IncomingMessage } from "node:http";
44

55
/**
66
* Creates a fetch adapter using an Axios instance that returns streaming responses.

src/websocket/sseConnection.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,10 @@ export class SseConnection implements UnidirectionalStream<ServerSentEvent> {
109109
}
110110

111111
private createErrorEvent(event: Event | ErrorEvent): WsErrorEvent {
112-
const errorMessage =
113-
event instanceof ErrorEvent && event.message
114-
? event.message
115-
: "SSE connection error";
116-
const error = event instanceof ErrorEvent ? event.error : undefined;
112+
// Check for properties instead of instanceof to avoid browser-only ErrorEvent global
113+
const eventWithMessage = event as { message?: string; error?: unknown };
114+
const errorMessage = eventWithMessage.message || "SSE connection error";
115+
const error = eventWithMessage.error;
117116

118117
return {
119118
error: error,

test/unit/api/coderApi.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ describe("CoderApi", () => {
125125
expect(thrownError.x509Err).toBeDefined();
126126
});
127127

128-
it("applies headers in correct precedence order (command > config > axios default)", async () => {
128+
it("applies headers in correct precedence order (command overrides config overrides axios default)", async () => {
129129
const api = createApi(CODER_URL, AXIOS_TOKEN);
130130

131131
// Test 1: Headers from config, default token from API creation
@@ -225,7 +225,7 @@ describe("CoderApi", () => {
225225
});
226226
});
227227

228-
it("applies headers in correct precedence order (command > config > axios default)", async () => {
228+
it("applies headers in correct precedence order (command overrides config overrides axios default)", async () => {
229229
// Test 1: Default token from API creation
230230
await api.watchBuildLogsByBuildId(BUILD_ID, []);
231231

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { type AxiosInstance, type AxiosResponse } from "axios";
2+
import { type ReaderLike } from "eventsource";
3+
import { EventEmitter } from "node:events";
4+
import { type IncomingMessage } from "node:http";
5+
import { describe, it, expect, vi } from "vitest";
6+
7+
import { createStreamingFetchAdapter } from "@/api/streamingFetchAdapter";
8+
9+
const TEST_URL = "https://example.com/api";
10+
11+
describe("createStreamingFetchAdapter", () => {
12+
describe("Request Handling", () => {
13+
it("passes URL, signal, and responseType to axios", async () => {
14+
const mockAxios = createAxiosMock();
15+
const mockStream = createMockStream();
16+
setupAxiosResponse(mockAxios, 200, {}, mockStream);
17+
18+
const adapter = createStreamingFetchAdapter(mockAxios);
19+
const signal = new AbortController().signal;
20+
21+
await adapter(TEST_URL, { signal });
22+
23+
expect(mockAxios.request).toHaveBeenCalledWith({
24+
url: TEST_URL,
25+
signal, // correctly passes signal
26+
headers: {},
27+
responseType: "stream",
28+
validateStatus: expect.any(Function),
29+
});
30+
});
31+
32+
it("applies headers in correct precedence order (config overrides init)", async () => {
33+
const mockAxios = createAxiosMock();
34+
const mockStream = createMockStream();
35+
setupAxiosResponse(mockAxios, 200, {}, mockStream);
36+
37+
// Test 1: No config headers, only init headers
38+
const adapter1 = createStreamingFetchAdapter(mockAxios);
39+
await adapter1(TEST_URL, {
40+
headers: { "X-Init": "init-value" },
41+
});
42+
43+
expect(mockAxios.request).toHaveBeenCalledWith(
44+
expect.objectContaining({
45+
headers: { "X-Init": "init-value" },
46+
}),
47+
);
48+
49+
// Test 2: Config headers merge with init headers
50+
const adapter2 = createStreamingFetchAdapter(mockAxios, {
51+
"X-Config": "config-value",
52+
});
53+
await adapter2(TEST_URL, {
54+
headers: { "X-Init": "init-value" },
55+
});
56+
57+
expect(mockAxios.request).toHaveBeenCalledWith(
58+
expect.objectContaining({
59+
headers: {
60+
"X-Init": "init-value",
61+
"X-Config": "config-value",
62+
},
63+
}),
64+
);
65+
66+
// Test 3: Config headers override init headers
67+
const adapter3 = createStreamingFetchAdapter(mockAxios, {
68+
"X-Header": "config-value",
69+
});
70+
await adapter3(TEST_URL, {
71+
headers: { "X-Header": "init-value" },
72+
});
73+
74+
expect(mockAxios.request).toHaveBeenCalledWith(
75+
expect.objectContaining({
76+
headers: { "X-Header": "config-value" },
77+
}),
78+
);
79+
});
80+
});
81+
82+
describe("Response Properties", () => {
83+
it("returns response with correct properties", async () => {
84+
const mockAxios = createAxiosMock();
85+
const mockStream = createMockStream();
86+
setupAxiosResponse(
87+
mockAxios,
88+
200,
89+
{ "content-type": "text/event-stream" },
90+
mockStream,
91+
);
92+
93+
const adapter = createStreamingFetchAdapter(mockAxios);
94+
const response = await adapter(TEST_URL);
95+
96+
expect(response.url).toBe(TEST_URL);
97+
expect(response.status).toBe(200);
98+
expect(response.headers.get("content-type")).toBe("text/event-stream");
99+
// Headers are lowercased when we retrieve them
100+
expect(response.headers.get("CoNtEnT-TyPe")).toBe("text/event-stream");
101+
expect(response.body?.getReader).toBeDefined();
102+
});
103+
104+
it("detects redirected requests", async () => {
105+
const mockAxios = createAxiosMock();
106+
const mockStream = createMockStream();
107+
const mockResponse = {
108+
status: 200,
109+
headers: {},
110+
data: mockStream,
111+
request: {
112+
res: {
113+
responseUrl: "https://redirect.com/api",
114+
},
115+
},
116+
} as AxiosResponse<IncomingMessage>;
117+
vi.mocked(mockAxios.request).mockResolvedValue(mockResponse);
118+
119+
const adapter = createStreamingFetchAdapter(mockAxios);
120+
const response = await adapter(TEST_URL);
121+
122+
expect(response.redirected).toBe(true);
123+
});
124+
});
125+
126+
describe("Stream Handling", () => {
127+
it("enqueues data chunks from stream", async () => {
128+
const { mockStream, reader } = await setupReaderTest();
129+
130+
const chunk1 = Buffer.from("data1");
131+
const chunk2 = Buffer.from("data2");
132+
mockStream.emit("data", chunk1);
133+
mockStream.emit("data", chunk2);
134+
mockStream.emit("end");
135+
136+
const result1 = await reader.read();
137+
expect(result1.value).toEqual(chunk1);
138+
expect(result1.done).toBe(false);
139+
140+
const result2 = await reader.read();
141+
expect(result2.value).toEqual(chunk2);
142+
expect(result2.done).toBe(false);
143+
144+
const result3 = await reader.read();
145+
// Closed after end
146+
expect(result3.done).toBe(true);
147+
});
148+
149+
it("propagates stream errors", async () => {
150+
const { mockStream, reader } = await setupReaderTest();
151+
152+
const error = new Error("Stream error");
153+
mockStream.emit("error", error);
154+
155+
await expect(reader.read()).rejects.toThrow("Stream error");
156+
});
157+
158+
it("handles errors after stream is closed", async () => {
159+
const { mockStream, reader } = await setupReaderTest();
160+
161+
mockStream.emit("end");
162+
await reader.read();
163+
164+
// Emit events after stream is closed - should not throw
165+
expect(() => mockStream.emit("data", Buffer.from("late"))).not.toThrow();
166+
expect(() => mockStream.emit("end")).not.toThrow();
167+
});
168+
169+
it("destroys stream on cancel", async () => {
170+
const { mockStream, reader } = await setupReaderTest();
171+
172+
await reader.cancel();
173+
174+
expect(mockStream.destroy).toHaveBeenCalled();
175+
});
176+
});
177+
});
178+
179+
function createAxiosMock(): AxiosInstance {
180+
return {
181+
request: vi.fn(),
182+
} as unknown as AxiosInstance;
183+
}
184+
185+
function createMockStream(): IncomingMessage {
186+
const stream = new EventEmitter() as IncomingMessage;
187+
stream.destroy = vi.fn();
188+
return stream;
189+
}
190+
191+
function setupAxiosResponse(
192+
axios: AxiosInstance,
193+
status: number,
194+
headers: Record<string, string>,
195+
stream: IncomingMessage,
196+
): void {
197+
vi.mocked(axios.request).mockResolvedValue({
198+
status,
199+
headers,
200+
data: stream,
201+
});
202+
}
203+
204+
async function setupReaderTest(): Promise<{
205+
mockStream: IncomingMessage;
206+
reader: ReaderLike | ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>>;
207+
}> {
208+
const mockAxios = createAxiosMock();
209+
const mockStream = createMockStream();
210+
setupAxiosResponse(mockAxios, 200, {}, mockStream);
211+
212+
const adapter = createStreamingFetchAdapter(mockAxios);
213+
const response = await adapter(TEST_URL);
214+
const reader = response.body?.getReader();
215+
if (reader === undefined) {
216+
throw new Error("Reader is undefined");
217+
}
218+
219+
return { mockStream, reader };
220+
}

test/unit/core/cliManager.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,8 @@ describe("CliManager", () => {
546546
expect(files.find((file) => file.includes(".asc"))).toBeUndefined();
547547
});
548548

549-
it.each([
549+
type SignatureErrorTestCase = [status: number, message: string];
550+
it.each<SignatureErrorTestCase>([
550551
[404, "Signature not found"],
551552
[500, "Failed to download signature"],
552553
])("allows skipping verification on %i", async (status, message) => {
@@ -558,7 +559,7 @@ describe("CliManager", () => {
558559
expect(pgp.verifySignature).not.toHaveBeenCalled();
559560
});
560561

561-
it.each([
562+
it.each<SignatureErrorTestCase>([
562563
[404, "Signature not found"],
563564
[500, "Failed to download signature"],
564565
])(

test/unit/logging/utils.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ describe("Logging utils", () => {
2323
});
2424

2525
describe("sizeOf", () => {
26-
it.each([
26+
type SizeOfTestCase = [data: unknown, bytes: number | undefined];
27+
it.each<SizeOfTestCase>([
2728
// Primitives return a fixed value
2829
[null, 0],
2930
[undefined, 0],

0 commit comments

Comments
 (0)