Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ Comments explain what code does or why it exists:
- Never mock behavior in end-to-end tests - use real data
- Mock as little as possible in unit tests - try to use real data
- Find root causes, not symptoms. Read error messages carefully before attempting fixes.
- When mocking constructors (classes) with `vi.mocked(...).mockImplementation()`, use regular functions, not arrow functions. Arrow functions can't be called with `new`.
```typescript
// ✗ Wrong
vi.mocked(SomeClass).mockImplementation(() => mock);
// ✓ Correct
vi.mocked(SomeClass).mockImplementation(function () {
return mock;
});
```

## Version Control

Expand Down
2 changes: 0 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,6 @@ Some dependencies are not directly used in the source but are required anyway.

- `bufferutil` and `utf-8-validate` are peer dependencies of `ws`.
- `ua-parser-js` and `dayjs` are used by the Coder API client.
- `glob`, `nyc`, `vscode-test`, and `@vscode/test-electron` are currently unused
but we need to switch back to them from `vitest`.

The coder client is vendored from coder/coder. Every now and then, we should be running `yarn upgrade coder --latest`
to make sure we're using up to date versions of the client.
Expand Down
21 changes: 8 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -404,33 +404,31 @@
"dependencies": {
"@peculiar/x509": "^1.14.2",
"axios": "1.13.2",
"date-fns": "^3.6.0",
"eventsource": "^3.0.6",
"date-fns": "^4.1.0",
"eventsource": "^4.1.0",
"find-process": "^2.0.0",
"jsonc-parser": "^3.3.1",
"openpgp": "^6.2.2",
"openpgp": "^6.3.0",
"pretty-bytes": "^7.1.0",
"proper-lockfile": "^4.1.2",
"proxy-agent": "^6.5.0",
"semver": "^7.7.3",
"ua-parser-js": "1.0.40",
"ua-parser-js": "^1.0.41",
"ws": "^8.18.3",
"zod": "^4.1.12"
"zod": "^4.3.2"
},
"devDependencies": {
"@types/eventsource": "^3.0.0",
"@types/glob": "^7.1.3",
"@types/node": "^22.14.1",
"@types/proper-lockfile": "^4.1.4",
"@types/semver": "^7.7.1",
"@types/ua-parser-js": "0.7.36",
"@types/ua-parser-js": "0.7.39",
"@types/vscode": "^1.73.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.50.1",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/coverage-v8": "^4.0.16",
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.7.1",
"bufferutil": "^4.0.9",
"coder": "https://github.com/coder/coder#main",
Expand All @@ -443,17 +441,14 @@
"eslint-plugin-md": "^1.0.19",
"eslint-plugin-package-json": "^0.85.0",
"eslint-plugin-prettier": "^5.5.4",
"glob": "^13.0.0",
"jsonc-eslint-parser": "^2.4.2",
"markdown-eslint-parser": "^1.2.1",
"memfs": "^4.51.1",
"nyc": "^17.1.0",
"prettier": "^3.7.4",
"ts-loader": "^9.5.4",
"typescript": "^5.9.3",
"utf-8-validate": "^6.0.6",
"vitest": "^3.2.4",
"vscode-test": "^1.5.0",
"vitest": "^4.0.16",
"webpack": "^5.104.1",
"webpack-cli": "^6.0.1"
},
Expand Down
2 changes: 1 addition & 1 deletion src/api/coderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ export class CoderApi extends Api implements vscode.Disposable {
} catch (error) {
if (this.is404Error(error)) {
this.output.warn(
`WebSocket failed, using SSE fallback: ${socketConfigs.apiRoute}`,
`WebSocket failed (${socketConfigs.apiRoute}), using SSE fallback: ${fallbackApiRoute}`,
);
const sse = this.createSseConnection(
fallbackApiRoute,
Expand Down
9 changes: 6 additions & 3 deletions src/api/streamingFetchAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type AxiosInstance } from "axios";
import { type FetchLikeInit, type FetchLikeResponse } from "eventsource";
import { type EventSourceFetchInit, type FetchLikeResponse } from "eventsource";
import { type IncomingMessage } from "node:http";

/**
Expand All @@ -9,10 +9,13 @@ import { type IncomingMessage } from "node:http";
export function createStreamingFetchAdapter(
axiosInstance: AxiosInstance,
configHeaders?: Record<string, string>,
): (url: string | URL, init?: FetchLikeInit) => Promise<FetchLikeResponse> {
): (
url: string | URL,
init?: EventSourceFetchInit,
) => Promise<FetchLikeResponse> {
return async (
url: string | URL,
init?: FetchLikeInit,
init?: EventSourceFetchInit,
): Promise<FetchLikeResponse> => {
const urlStr = url.toString();

Expand Down
20 changes: 12 additions & 8 deletions test/unit/api/coderApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ describe("CoderApi", () => {

it("falls back to SSE when WebSocket creation fails with 404", async () => {
// Only 404 errors trigger SSE fallback - other errors are thrown
vi.mocked(Ws).mockImplementation(() => {
vi.mocked(Ws).mockImplementation(function () {
throw new Error("Unexpected server response: 404");
});

Expand Down Expand Up @@ -380,7 +380,7 @@ describe("CoderApi", () => {
});

it("throws non-404 errors without SSE fallback", async () => {
vi.mocked(Ws).mockImplementation(() => {
vi.mocked(Ws).mockImplementation(function () {
throw new Error("Network error");
});

Expand All @@ -398,7 +398,7 @@ describe("CoderApi", () => {
let wsAttempts = 0;
const mockEventSources: MockEventSource[] = [];

vi.mocked(Ws).mockImplementation(() => {
vi.mocked(Ws).mockImplementation(function () {
wsAttempts++;
const mockWs = createMockWebSocket("wss://test", {
on: vi.fn((event: string, handler: (e: unknown) => void) => {
Expand All @@ -413,7 +413,7 @@ describe("CoderApi", () => {
return mockWs as Ws;
});

vi.mocked(EventSource).mockImplementation(() => {
vi.mocked(EventSource).mockImplementation(function () {
const es = createMockEventSource(`${CODER_URL}/api/v2/test`);
mockEventSources.push(es);
return es as unknown as EventSource;
Expand All @@ -436,7 +436,7 @@ describe("CoderApi", () => {

const setupAutoOpeningWebSocket = () => {
const sockets: Array<Partial<Ws>> = [];
vi.mocked(Ws).mockImplementation((url: string | URL) => {
vi.mocked(Ws).mockImplementation(function (url: string | URL) {
const mockWs = createMockWebSocket(String(url), {
on: vi.fn((event, handler) => {
if (event === "open") {
Expand Down Expand Up @@ -575,7 +575,7 @@ describe("CoderApi", () => {
describe("dispose", () => {
it("disposes all tracked reconnecting sockets", async () => {
const sockets: Array<Partial<Ws>> = [];
vi.mocked(Ws).mockImplementation((url: string | URL) => {
vi.mocked(Ws).mockImplementation(function (url: string | URL) {
const mockWs = createMockWebSocket(String(url), {
on: vi.fn((event, handler) => {
if (event === "open") {
Expand Down Expand Up @@ -701,9 +701,13 @@ function createMockEventSource(url: string): MockEventSource {
}

function setupWebSocketMock(ws: Partial<Ws>): void {
vi.mocked(Ws).mockImplementation(() => ws as Ws);
vi.mocked(Ws).mockImplementation(function () {
return ws as Ws;
});
}

function setupEventSourceMock(es: Partial<EventSource>): void {
vi.mocked(EventSource).mockImplementation(() => es as EventSource);
vi.mocked(EventSource).mockImplementation(function () {
return es as EventSource;
});
}
55 changes: 41 additions & 14 deletions test/unit/api/streamingFetchAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type AxiosInstance, type AxiosResponse } from "axios";
import { type ReaderLike } from "eventsource";
import { type EventSourceFetchInit, type ReaderLike } from "eventsource";
import { EventEmitter } from "node:events";
import { type IncomingMessage } from "node:http";
import { describe, it, expect, vi } from "vitest";
Expand All @@ -8,6 +8,23 @@ import { createStreamingFetchAdapter } from "@/api/streamingFetchAdapter";

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

/**
* Creates a valid EventSourceFetchInit object for testing.
* In production, EventSource always provides all required fields.
*/
function createFetchInit(
overrides: Partial<EventSourceFetchInit> = {},
): EventSourceFetchInit {
return {
signal: new AbortController().signal,
headers: { Accept: "text/event-stream" },
mode: "cors",
cache: "no-store",
redirect: "follow",
...overrides,
};
}

describe("createStreamingFetchAdapter", () => {
describe("Request Handling", () => {
it("passes URL, signal, and responseType to axios", async () => {
Expand All @@ -18,12 +35,12 @@ describe("createStreamingFetchAdapter", () => {
const adapter = createStreamingFetchAdapter(mockAxios);
const signal = new AbortController().signal;

await adapter(TEST_URL, { signal });
await adapter(TEST_URL, createFetchInit({ signal }));

expect(mockAxios.request).toHaveBeenCalledWith({
url: TEST_URL,
signal, // correctly passes signal
headers: {},
headers: { Accept: "text/event-stream" },
responseType: "stream",
validateStatus: expect.any(Function),
});
Expand All @@ -36,27 +53,34 @@ describe("createStreamingFetchAdapter", () => {

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

expect(mockAxios.request).toHaveBeenCalledWith(
expect.objectContaining({
headers: { "X-Init": "init-value" },
headers: { Accept: "text/event-stream", "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" },
});
await adapter2(
TEST_URL,
createFetchInit({
headers: { Accept: "text/event-stream", "X-Init": "init-value" },
}),
);

expect(mockAxios.request).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
Accept: "text/event-stream",
"X-Init": "init-value",
"X-Config": "config-value",
},
Expand All @@ -67,13 +91,16 @@ describe("createStreamingFetchAdapter", () => {
const adapter3 = createStreamingFetchAdapter(mockAxios, {
"X-Header": "config-value",
});
await adapter3(TEST_URL, {
headers: { "X-Header": "init-value" },
});
await adapter3(
TEST_URL,
createFetchInit({
headers: { Accept: "text/event-stream", "X-Header": "init-value" },
}),
);

expect(mockAxios.request).toHaveBeenCalledWith(
expect.objectContaining({
headers: { "X-Header": "config-value" },
headers: { Accept: "text/event-stream", "X-Header": "config-value" },
}),
);
});
Expand Down
4 changes: 3 additions & 1 deletion test/unit/websocket/sseConnection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,9 @@ function createMockEventSource(
}

function setupEventSourceMock(es: Partial<EventSource>): void {
vi.mocked(EventSource).mockImplementation(() => es as EventSource);
vi.mocked(EventSource).mockImplementation(function () {
return es as EventSource;
});
}

function waitForNextTick(): Promise<void> {
Expand Down
Loading