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

Add SSE Connection and streamingFetchAdapter tests#625

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
EhabY merged 3 commits intocoder:mainfromEhabY:add-sse-connection-tests
Oct 21, 2025
Merged
Show file tree
Hide file tree
Changes fromall commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletionssrc/api/coderApi.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,8 +110,9 @@ export class CoderApi extends Api {
options?: ClientOptions,
) => {
const searchParams = new URLSearchParams({ follow: "true" });
if (logs.length) {
searchParams.append("after", logs[logs.length - 1].id.toString());
const lastLog = logs.at(-1);
if (lastLog) {
searchParams.append("after", lastLog.id.toString());
}

return this.createWebSocket<ProvisionerJobLog>({
Expand DownExpand Up@@ -311,9 +312,9 @@ function setupInterceptors(
output,
);
// Add headers from the header command.
Object.entries(headers).forEach(([key, value]) => {
for (const[key, value] of Object.entries(headers)) {
config.headers[key] = value;
});
}

// Configure proxy and TLS.
// Note that by default VS Code overrides the agent. To prevent this, set
Expand DownExpand Up@@ -425,7 +426,7 @@ function wrapResponseTransform(
function getSize(headers: AxiosHeaders, data: unknown): number | undefined {
const contentLength = headers["content-length"];
if (contentLength !== undefined) {
return parseInt(contentLength, 10);
returnNumber.parseInt(contentLength, 10);
}

return sizeOf(data);
Expand Down
2 changes: 1 addition & 1 deletionsrc/api/streamingFetchAdapter.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { type AxiosInstance } from "axios";
import { type FetchLikeInit, type FetchLikeResponse } from "eventsource";
import { type IncomingMessage } from "http";
import { type IncomingMessage } from "node:http";

/**
* Creates a fetch adapter using an Axios instance that returns streaming responses.
Expand Down
9 changes: 4 additions & 5 deletionssrc/websocket/sseConnection.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,11 +109,10 @@ export class SseConnection implements UnidirectionalStream<ServerSentEvent> {
}

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

return {
error: error,
Expand Down
4 changes: 2 additions & 2 deletionstest/unit/api/coderApi.test.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,7 +125,7 @@ describe("CoderApi", () => {
expect(thrownError.x509Err).toBeDefined();
});

it("applies headers in correct precedence order (command> config> axios default)", async () => {
it("applies headers in correct precedence order (commandoverrides configoverrides axios default)", async () => {
const api = createApi(CODER_URL, AXIOS_TOKEN);

// Test 1: Headers from config, default token from API creation
Expand DownExpand Up@@ -225,7 +225,7 @@ describe("CoderApi", () => {
});
});

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

Expand Down
220 changes: 220 additions & 0 deletionstest/unit/api/streamingFetchAdapter.test.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
import { type AxiosInstance, type AxiosResponse } from "axios";
import { type ReaderLike } from "eventsource";
import { EventEmitter } from "node:events";
import { type IncomingMessage } from "node:http";
import { describe, it, expect, vi } from "vitest";

import { createStreamingFetchAdapter } from "@/api/streamingFetchAdapter";

const TEST_URL = "https://example.com/api";

describe("createStreamingFetchAdapter", () => {
describe("Request Handling", () => {
it("passes URL, signal, and responseType to axios", async () => {
const mockAxios = createAxiosMock();
const mockStream = createMockStream();
setupAxiosResponse(mockAxios, 200, {}, mockStream);

const adapter = createStreamingFetchAdapter(mockAxios);
const signal = new AbortController().signal;

await adapter(TEST_URL, { signal });

expect(mockAxios.request).toHaveBeenCalledWith({
url: TEST_URL,
signal, // correctly passes signal
headers: {},
responseType: "stream",
validateStatus: expect.any(Function),
});
});

it("applies headers in correct precedence order (config overrides init)", async () => {
const mockAxios = createAxiosMock();
const mockStream = createMockStream();
setupAxiosResponse(mockAxios, 200, {}, mockStream);

// Test 1: No config headers, only init headers
const adapter1 = createStreamingFetchAdapter(mockAxios);
await adapter1(TEST_URL, {
headers: { "X-Init": "init-value" },
});

expect(mockAxios.request).toHaveBeenCalledWith(
expect.objectContaining({
headers: { "X-Init": "init-value" },
}),
);

// Test 2: Config headers merge with init headers
const adapter2 = createStreamingFetchAdapter(mockAxios, {
"X-Config": "config-value",
});
await adapter2(TEST_URL, {
headers: { "X-Init": "init-value" },
});

expect(mockAxios.request).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
"X-Init": "init-value",
"X-Config": "config-value",
},
}),
);

// Test 3: Config headers override init headers
const adapter3 = createStreamingFetchAdapter(mockAxios, {
"X-Header": "config-value",
});
await adapter3(TEST_URL, {
headers: { "X-Header": "init-value" },
});

expect(mockAxios.request).toHaveBeenCalledWith(
expect.objectContaining({
headers: { "X-Header": "config-value" },
}),
);
});
});

describe("Response Properties", () => {
it("returns response with correct properties", async () => {
const mockAxios = createAxiosMock();
const mockStream = createMockStream();
setupAxiosResponse(
mockAxios,
200,
{ "content-type": "text/event-stream" },
mockStream,
);

const adapter = createStreamingFetchAdapter(mockAxios);
const response = await adapter(TEST_URL);

expect(response.url).toBe(TEST_URL);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/event-stream");
// Headers are lowercased when we retrieve them
expect(response.headers.get("CoNtEnT-TyPe")).toBe("text/event-stream");
expect(response.body?.getReader).toBeDefined();
});

it("detects redirected requests", async () => {
const mockAxios = createAxiosMock();
const mockStream = createMockStream();
const mockResponse = {
status: 200,
headers: {},
data: mockStream,
request: {
res: {
responseUrl: "https://redirect.com/api",
},
},
} as AxiosResponse<IncomingMessage>;
vi.mocked(mockAxios.request).mockResolvedValue(mockResponse);

const adapter = createStreamingFetchAdapter(mockAxios);
const response = await adapter(TEST_URL);

expect(response.redirected).toBe(true);
});
});

describe("Stream Handling", () => {
it("enqueues data chunks from stream", async () => {
const { mockStream, reader } = await setupReaderTest();

const chunk1 = Buffer.from("data1");
const chunk2 = Buffer.from("data2");
mockStream.emit("data", chunk1);
mockStream.emit("data", chunk2);
mockStream.emit("end");

const result1 = await reader.read();
expect(result1.value).toEqual(chunk1);
expect(result1.done).toBe(false);

const result2 = await reader.read();
expect(result2.value).toEqual(chunk2);
expect(result2.done).toBe(false);

const result3 = await reader.read();
// Closed after end
expect(result3.done).toBe(true);
});

it("propagates stream errors", async () => {
const { mockStream, reader } = await setupReaderTest();

const error = new Error("Stream error");
mockStream.emit("error", error);

await expect(reader.read()).rejects.toThrow("Stream error");
});

it("handles errors after stream is closed", async () => {
const { mockStream, reader } = await setupReaderTest();

mockStream.emit("end");
await reader.read();

// Emit events after stream is closed - should not throw
expect(() => mockStream.emit("data", Buffer.from("late"))).not.toThrow();
expect(() => mockStream.emit("end")).not.toThrow();
});

it("destroys stream on cancel", async () => {
const { mockStream, reader } = await setupReaderTest();

await reader.cancel();

expect(mockStream.destroy).toHaveBeenCalled();
});
});
});

function createAxiosMock(): AxiosInstance {
return {
request: vi.fn(),
} as unknown as AxiosInstance;
}

function createMockStream(): IncomingMessage {
const stream = new EventEmitter() as IncomingMessage;
stream.destroy = vi.fn();
return stream;
}

function setupAxiosResponse(
axios: AxiosInstance,
status: number,
headers: Record<string, string>,
stream: IncomingMessage,
): void {
vi.mocked(axios.request).mockResolvedValue({
status,
headers,
data: stream,
});
}

async function setupReaderTest(): Promise<{
mockStream: IncomingMessage;
reader: ReaderLike | ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>>;
}> {
const mockAxios = createAxiosMock();
const mockStream = createMockStream();
setupAxiosResponse(mockAxios, 200, {}, mockStream);

const adapter = createStreamingFetchAdapter(mockAxios);
const response = await adapter(TEST_URL);
const reader = response.body?.getReader();
if (reader === undefined) {
throw new Error("Reader is undefined");
}

return { mockStream, reader };
}
5 changes: 3 additions & 2 deletionstest/unit/core/cliManager.test.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -546,7 +546,8 @@ describe("CliManager", () => {
expect(files.find((file) => file.includes(".asc"))).toBeUndefined();
});

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

it.each([
it.each<SignatureErrorTestCase>([
[404, "Signature not found"],
[500, "Failed to download signature"],
])(
Expand Down
3 changes: 2 additions & 1 deletiontest/unit/logging/utils.test.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,8 @@ describe("Logging utils", () => {
});

describe("sizeOf", () => {
it.each([
type SizeOfTestCase = [data: unknown, bytes: number | undefined];
it.each<SizeOfTestCase>([
// Primitives return a fixed value
[null, 0],
[undefined, 0],
Expand Down
Loading

[8]ページ先頭

©2009-2025 Movatter.jp