Skip to content

Commit d6e00c3

Browse files
committed
wip: commit progress on test update
1 parent e8306cc commit d6e00c3

File tree

4 files changed

+289
-224
lines changed

4 files changed

+289
-224
lines changed

site/src/modules/resources/useAgentLogs.test.ts

Lines changed: 73 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,89 @@
1-
import { renderHook, waitFor } from "@testing-library/react";
1+
import {
2+
renderHook,
3+
type RenderHookResult,
4+
waitFor,
5+
} from "@testing-library/react";
26
import type { WorkspaceAgentLog } from "api/typesGenerated";
3-
import WS from "jest-websocket-mock";
47
import { MockWorkspaceAgent } from "testHelpers/entities";
5-
import { useAgentLogs } from "./useAgentLogs";
8+
import { createUseAgentLogs } from "./useAgentLogs";
9+
import {
10+
createMockWebSocket,
11+
type MockWebSocketPublisher,
12+
} from "testHelpers/websockets";
13+
import { OneWayWebSocket } from "utils/OneWayWebSocket";
614

7-
/**
8-
* TODO: WS does not support multiple tests running at once in isolation so we
9-
* have one single test that test the most common scenario.
10-
* Issue: https://github.com/romgain/jest-websocket-mock/issues/172
11-
*/
15+
function generateMockLogs(count: number): WorkspaceAgentLog[] {
16+
return Array.from({ length: count }, (_, i) => ({
17+
id: i,
18+
created_at: new Date().toISOString(),
19+
level: "info",
20+
output: `Log ${i}`,
21+
source_id: "",
22+
}));
23+
}
1224

13-
describe("useAgentLogs", () => {
14-
afterEach(() => {
15-
WS.clean();
25+
type MountHookResult = Readonly<
26+
RenderHookResult<readonly WorkspaceAgentLog[], { enabled: boolean }> & {
27+
publisher: MockWebSocketPublisher;
28+
}
29+
>;
30+
31+
function mountHook(): MountHookResult {
32+
let publisher!: MockWebSocketPublisher;
33+
const useAgentLogs = createUseAgentLogs((agentId, params) => {
34+
return new OneWayWebSocket({
35+
apiRoute: `/api/v2/workspaceagents/${agentId}/logs`,
36+
searchParams: new URLSearchParams({
37+
follow: "true",
38+
after: params?.after?.toString() || "0",
39+
}),
40+
websocketInit: (url) => {
41+
const [mockSocket, mockPub] = createMockWebSocket(url);
42+
publisher = mockPub;
43+
return mockSocket;
44+
},
45+
});
1646
});
1747

18-
it("clear logs when disabled to avoid duplicates", async () => {
19-
const server = new WS(
20-
`ws://localhost/api/v2/workspaceagents/${
21-
MockWorkspaceAgent.id
22-
}/logs?follow&after=0`,
23-
);
24-
const { result, rerender } = renderHook(
25-
({ enabled }) => useAgentLogs(MockWorkspaceAgent, enabled),
26-
{ initialProps: { enabled: true } },
27-
);
28-
await server.connected;
29-
30-
// Send 3 logs
31-
server.send(JSON.stringify(generateLogs(3)));
48+
const { result, rerender, unmount } = renderHook(
49+
({ enabled }) => useAgentLogs(MockWorkspaceAgent, enabled),
50+
{ initialProps: { enabled: true } },
51+
);
52+
53+
return { result, rerender, unmount, publisher };
54+
}
55+
56+
describe("useAgentLogs", () => {
57+
it("clears logs when hook becomes disabled (protection to avoid duplicate logs when hook goes back to being re-enabled)", async () => {
58+
const { result, rerender, publisher } = mountHook();
59+
60+
// Verify that logs can be received after mount
61+
const initialLogs = generateMockLogs(3);
62+
const initialEvent = new MessageEvent<string>("message", {
63+
data: JSON.stringify(initialLogs),
64+
});
65+
publisher.publishMessage(initialEvent);
3266
await waitFor(() => {
33-
expect(result.current).toHaveLength(3);
67+
// Using expect.arrayContaining to account for the fact that we're
68+
// not guaranteed to receive WebSocket events in order
69+
expect(result.current).toEqual(expect.arrayContaining(initialLogs));
3470
});
3571

36-
// Disable the hook
72+
// Disable the hook (and have the hook close the connection behind the
73+
// scenes)
3774
rerender({ enabled: false });
38-
await waitFor(() => {
39-
expect(result.current).toHaveLength(0);
40-
});
75+
await waitFor(() => expect(result.current).toHaveLength(0));
4176

42-
// Enable the hook again
77+
// Re-enable the hook (creating an entirely new connection), and send
78+
// new logs
4379
rerender({ enabled: true });
44-
await server.connected;
45-
server.send(JSON.stringify(generateLogs(3)));
80+
const newLogs = generateMockLogs(3);
81+
const newEvent = new MessageEvent<string>("message", {
82+
data: JSON.stringify(newLogs),
83+
});
84+
publisher.publishMessage(newEvent);
4685
await waitFor(() => {
47-
expect(result.current).toHaveLength(3);
86+
expect(result.current).toEqual(expect.arrayContaining(newLogs));
4887
});
4988
});
5089
});
51-
52-
function generateLogs(count: number): WorkspaceAgentLog[] {
53-
return Array.from({ length: count }, (_, i) => ({
54-
id: i,
55-
created_at: new Date().toISOString(),
56-
level: "info",
57-
output: `Log ${i}`,
58-
source_id: "",
59-
}));
60-
}

site/src/modules/resources/useAgentLogs.ts

Lines changed: 54 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,45 +3,63 @@ import type { WorkspaceAgent, WorkspaceAgentLog } from "api/typesGenerated";
33
import { displayError } from "components/GlobalSnackbar/utils";
44
import { useEffect, useState } from "react";
55

6-
export function useAgentLogs(
7-
agent: WorkspaceAgent,
8-
enabled: boolean,
9-
): readonly WorkspaceAgentLog[] {
10-
const [logs, setLogs] = useState<WorkspaceAgentLog[]>([]);
11-
12-
useEffect(() => {
13-
if (!enabled) {
14-
// Clean up the logs when the agent is not enabled. So it can receive logs
15-
// from the beginning without duplicating the logs.
6+
export function createUseAgentLogs(
7+
createSocket: typeof watchWorkspaceAgentLogs,
8+
) {
9+
return function useAgentLogs(
10+
agent: WorkspaceAgent,
11+
enabled: boolean,
12+
): readonly WorkspaceAgentLog[] {
13+
const [logs, setLogs] = useState<readonly WorkspaceAgentLog[]>([]);
14+
15+
// Clean up the logs when the agent is not enabled, using a mid-render
16+
// sync to remove any risk of screen flickering. Clearing the logs helps
17+
// ensure that if the hook flips back to being enabled, we can receive a
18+
// fresh set of logs from the beginning with zero risk of duplicates.
19+
const [prevEnabled, setPrevEnabled] = useState(enabled);
20+
if (!enabled && prevEnabled) {
1621
setLogs([]);
17-
return;
22+
setPrevEnabled(false);
1823
}
1924

20-
// Always fetch the logs from the beginning. We may want to optimize this in
21-
// the future, but it would add some complexity in the code that maybe does
22-
// not worth it.
23-
const socket = watchWorkspaceAgentLogs(agent.id, { after: 0 });
24-
socket.addEventListener("message", (e) => {
25-
if (e.parseError) {
26-
console.warn("Error parsing agent log: ", e.parseError);
25+
useEffect(() => {
26+
if (!enabled) {
2727
return;
2828
}
29-
setLogs((logs) => [...logs, ...e.parsedMessage]);
30-
});
31-
32-
socket.addEventListener("error", (e) => {
33-
console.error("Error in agent log socket: ", e);
34-
displayError(
35-
"Unable to watch the agent logs",
36-
"Please try refreshing the browser",
37-
);
38-
socket.close();
39-
});
40-
41-
return () => {
42-
socket.close();
43-
};
44-
}, [agent.id, enabled]);
45-
46-
return logs;
29+
30+
// Always fetch the logs from the beginning. We may want to optimize
31+
// this in the future, but it would add some complexity in the code
32+
// that might not be worth it.
33+
const socket = createSocket(agent.id, { after: 0 });
34+
socket.addEventListener("message", (e) => {
35+
if (e.parseError) {
36+
console.warn("Error parsing agent log: ", e.parseError);
37+
return;
38+
}
39+
setLogs((logs) => [...logs, ...e.parsedMessage]);
40+
});
41+
42+
socket.addEventListener("error", (e) => {
43+
console.error("Error in agent log socket: ", e);
44+
displayError(
45+
"Unable to watch the agent logs",
46+
"Please try refreshing the browser",
47+
);
48+
socket.close();
49+
});
50+
51+
return () => socket.close();
52+
53+
// createSocket shouldn't ever change for the lifecycle of the hook,
54+
// but Biome isn't smart enough to detect constant dependencies for
55+
// higher-order hooks. Adding it to the array (even though it
56+
// shouldn't ever be needed) seemed like the least fragile way to
57+
// resolve the warning.
58+
}, [createSocket, agent.id, enabled]);
59+
60+
return logs;
61+
};
4762
}
63+
64+
// The baseline implementation to use for production
65+
export const useAgentLogs = createUseAgentLogs(watchWorkspaceAgentLogs);

site/src/testHelpers/websockets.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import type { WebSocketEventType } from "utils/OneWayWebSocket";
2+
3+
export type MockWebSocketPublisher = Readonly<{
4+
publishMessage: (event: MessageEvent<string>) => void;
5+
publishError: (event: ErrorEvent) => void;
6+
publishClose: (event: CloseEvent) => void;
7+
publishOpen: (event: Event) => void;
8+
}>;
9+
10+
export type CreateMockWebSocketOptions = Readonly<{
11+
// The URL to use to initialize the mock socket. This should match the
12+
// "real" URL that you would pass to the built-in WebSocket constructor.
13+
url: string;
14+
15+
// The additional WebSocket protocols to use when initializing. This should
16+
// match the real protocols that you would pass to the built-in WebSocket
17+
// constructor.
18+
protocols?: string | string[];
19+
20+
// Indicates whether the mock socket should stay open after calling the
21+
// .close method, so that it can be reused for a new connection. Defaults to
22+
// false (meaning that the socket becomes completely unusable the first time
23+
// after .close is called).
24+
persistAfterClose?: boolean;
25+
}>;
26+
27+
export function createMockWebSocket(
28+
url: string,
29+
protocols?: string | string[],
30+
): readonly [WebSocket, MockWebSocketPublisher] {
31+
type EventMap = {
32+
message: MessageEvent<string>;
33+
error: ErrorEvent;
34+
close: CloseEvent;
35+
open: Event;
36+
};
37+
type CallbackStore = {
38+
[K in keyof EventMap]: ((event: EventMap[K]) => void)[];
39+
};
40+
41+
let activeProtocol: string;
42+
if (Array.isArray(protocols)) {
43+
activeProtocol = protocols[0] ?? "";
44+
} else if (typeof protocols === "string") {
45+
activeProtocol = protocols;
46+
} else {
47+
activeProtocol = "";
48+
}
49+
50+
let closed = false;
51+
const store: CallbackStore = {
52+
message: [],
53+
error: [],
54+
close: [],
55+
open: [],
56+
};
57+
58+
const mockSocket: WebSocket = {
59+
CONNECTING: 0,
60+
OPEN: 1,
61+
CLOSING: 2,
62+
CLOSED: 3,
63+
64+
url,
65+
protocol: activeProtocol,
66+
readyState: 1,
67+
binaryType: "blob",
68+
bufferedAmount: 0,
69+
extensions: "",
70+
onclose: null,
71+
onerror: null,
72+
onmessage: null,
73+
onopen: null,
74+
send: jest.fn(),
75+
dispatchEvent: jest.fn(),
76+
77+
addEventListener: <E extends WebSocketEventType>(
78+
eventType: E,
79+
callback: WebSocketEventMap[E],
80+
) => {
81+
if (closed) {
82+
return;
83+
}
84+
85+
const subscribers = store[eventType];
86+
const cb = callback as unknown as CallbackStore[E][0];
87+
if (!subscribers.includes(cb)) {
88+
subscribers.push(cb);
89+
}
90+
},
91+
92+
removeEventListener: <E extends WebSocketEventType>(
93+
eventType: E,
94+
callback: WebSocketEventMap[E],
95+
) => {
96+
if (closed) {
97+
return;
98+
}
99+
100+
const subscribers = store[eventType];
101+
const cb = callback as unknown as CallbackStore[E][0];
102+
if (subscribers.includes(cb)) {
103+
const updated = store[eventType].filter((c) => c !== cb);
104+
store[eventType] = updated as unknown as CallbackStore[E];
105+
}
106+
},
107+
108+
close: () => {
109+
closed = true;
110+
},
111+
};
112+
113+
const publisher: MockWebSocketPublisher = {
114+
publishOpen: (event) => {
115+
if (closed) {
116+
return;
117+
}
118+
for (const sub of store.open) {
119+
sub(event);
120+
}
121+
},
122+
123+
publishError: (event) => {
124+
if (closed) {
125+
return;
126+
}
127+
for (const sub of store.error) {
128+
sub(event);
129+
}
130+
},
131+
132+
publishMessage: (event) => {
133+
if (closed) {
134+
return;
135+
}
136+
for (const sub of store.message) {
137+
sub(event);
138+
}
139+
},
140+
141+
publishClose: (event) => {
142+
if (closed) {
143+
return;
144+
}
145+
for (const sub of store.close) {
146+
sub(event);
147+
}
148+
},
149+
};
150+
151+
return [mockSocket, publisher] as const;
152+
}

0 commit comments

Comments
 (0)