Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit3c499ba

Browse files
authored
Add SSE Connection and streamingFetchAdapter tests (#625)
1 parentf9b1f25 commit3c499ba

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
constsearchParams=newURLSearchParams({follow:"true"});
113-
if(logs.length){
114-
searchParams.append("after",logs[logs.length-1].id.toString());
113+
constlastLog=logs.at(-1);
114+
if(lastLog){
115+
searchParams.append("after",lastLog.id.toString());
115116
}
116117

117118
returnthis.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]ofObject.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
functiongetSize(headers:AxiosHeaders,data:unknown):number|undefined{
426427
constcontentLength=headers["content-length"];
427428
if(contentLength!==undefined){
428-
returnparseInt(contentLength,10);
429+
returnNumber.parseInt(contentLength,10);
429430
}
430431

431432
returnsizeOf(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{typeAxiosInstance}from"axios";
22
import{typeFetchLikeInit,typeFetchLikeResponse}from"eventsource";
3-
import{typeIncomingMessage}from"http";
3+
import{typeIncomingMessage}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
privatecreateErrorEvent(event:Event|ErrorEvent):WsErrorEvent{
112-
consterrorMessage=
113-
eventinstanceofErrorEvent&&event.message
114-
?event.message
115-
:"SSE connection error";
116-
consterror=eventinstanceofErrorEvent ?event.error :undefined;
112+
// Check for properties instead of instanceof to avoid browser-only ErrorEvent global
113+
consteventWithMessage=eventas{message?:string;error?:unknown};
114+
consterrorMessage=eventWithMessage.message||"SSE connection error";
115+
consterror=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 (commandoverrides configoverrides axios default)",async()=>{
129129
constapi=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 (commandoverrides configoverrides axios default)",async()=>{
229229
// Test 1: Default token from API creation
230230
awaitapi.watchBuildLogsByBuildId(BUILD_ID,[]);
231231

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import{typeAxiosInstance,typeAxiosResponse}from"axios";
2+
import{typeReaderLike}from"eventsource";
3+
import{EventEmitter}from"node:events";
4+
import{typeIncomingMessage}from"node:http";
5+
import{describe,it,expect,vi}from"vitest";
6+
7+
import{createStreamingFetchAdapter}from"@/api/streamingFetchAdapter";
8+
9+
constTEST_URL="https://example.com/api";
10+
11+
describe("createStreamingFetchAdapter",()=>{
12+
describe("Request Handling",()=>{
13+
it("passes URL, signal, and responseType to axios",async()=>{
14+
constmockAxios=createAxiosMock();
15+
constmockStream=createMockStream();
16+
setupAxiosResponse(mockAxios,200,{},mockStream);
17+
18+
constadapter=createStreamingFetchAdapter(mockAxios);
19+
constsignal=newAbortController().signal;
20+
21+
awaitadapter(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+
constmockAxios=createAxiosMock();
34+
constmockStream=createMockStream();
35+
setupAxiosResponse(mockAxios,200,{},mockStream);
36+
37+
// Test 1: No config headers, only init headers
38+
constadapter1=createStreamingFetchAdapter(mockAxios);
39+
awaitadapter1(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+
constadapter2=createStreamingFetchAdapter(mockAxios,{
51+
"X-Config":"config-value",
52+
});
53+
awaitadapter2(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+
constadapter3=createStreamingFetchAdapter(mockAxios,{
68+
"X-Header":"config-value",
69+
});
70+
awaitadapter3(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+
constmockAxios=createAxiosMock();
85+
constmockStream=createMockStream();
86+
setupAxiosResponse(
87+
mockAxios,
88+
200,
89+
{"content-type":"text/event-stream"},
90+
mockStream,
91+
);
92+
93+
constadapter=createStreamingFetchAdapter(mockAxios);
94+
constresponse=awaitadapter(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+
constmockAxios=createAxiosMock();
106+
constmockStream=createMockStream();
107+
constmockResponse={
108+
status:200,
109+
headers:{},
110+
data:mockStream,
111+
request:{
112+
res:{
113+
responseUrl:"https://redirect.com/api",
114+
},
115+
},
116+
}asAxiosResponse<IncomingMessage>;
117+
vi.mocked(mockAxios.request).mockResolvedValue(mockResponse);
118+
119+
constadapter=createStreamingFetchAdapter(mockAxios);
120+
constresponse=awaitadapter(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}=awaitsetupReaderTest();
129+
130+
constchunk1=Buffer.from("data1");
131+
constchunk2=Buffer.from("data2");
132+
mockStream.emit("data",chunk1);
133+
mockStream.emit("data",chunk2);
134+
mockStream.emit("end");
135+
136+
constresult1=awaitreader.read();
137+
expect(result1.value).toEqual(chunk1);
138+
expect(result1.done).toBe(false);
139+
140+
constresult2=awaitreader.read();
141+
expect(result2.value).toEqual(chunk2);
142+
expect(result2.done).toBe(false);
143+
144+
constresult3=awaitreader.read();
145+
// Closed after end
146+
expect(result3.done).toBe(true);
147+
});
148+
149+
it("propagates stream errors",async()=>{
150+
const{ mockStream, reader}=awaitsetupReaderTest();
151+
152+
consterror=newError("Stream error");
153+
mockStream.emit("error",error);
154+
155+
awaitexpect(reader.read()).rejects.toThrow("Stream error");
156+
});
157+
158+
it("handles errors after stream is closed",async()=>{
159+
const{ mockStream, reader}=awaitsetupReaderTest();
160+
161+
mockStream.emit("end");
162+
awaitreader.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}=awaitsetupReaderTest();
171+
172+
awaitreader.cancel();
173+
174+
expect(mockStream.destroy).toHaveBeenCalled();
175+
});
176+
});
177+
});
178+
179+
functioncreateAxiosMock():AxiosInstance{
180+
return{
181+
request:vi.fn(),
182+
}asunknownasAxiosInstance;
183+
}
184+
185+
functioncreateMockStream():IncomingMessage{
186+
conststream=newEventEmitter()asIncomingMessage;
187+
stream.destroy=vi.fn();
188+
returnstream;
189+
}
190+
191+
functionsetupAxiosResponse(
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+
asyncfunctionsetupReaderTest():Promise<{
205+
mockStream:IncomingMessage;
206+
reader:ReaderLike|ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>>;
207+
}>{
208+
constmockAxios=createAxiosMock();
209+
constmockStream=createMockStream();
210+
setupAxiosResponse(mockAxios,200,{},mockStream);
211+
212+
constadapter=createStreamingFetchAdapter(mockAxios);
213+
constresponse=awaitadapter(TEST_URL);
214+
constreader=response.body?.getReader();
215+
if(reader===undefined){
216+
thrownewError("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+
typeSignatureErrorTestCase=[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+
typeSizeOfTestCase=[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)

[8]ページ先頭

©2009-2025 Movatter.jp