Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
d6e00c3
wip: commit progress on test update
Parkreiner May 23, 2025
43d0ca8
refactor: update useAgentLogs tests as unit tests
Parkreiner May 23, 2025
727bddd
docs: rewrite comment for clarity
Parkreiner May 23, 2025
d46d144
fix: remove unnecessary type
Parkreiner May 23, 2025
91a6fc1
fix: make sure logs have different timestamps
Parkreiner May 23, 2025
ecbe7b0
fix: add different dates to reduce risk of false positives
Parkreiner May 23, 2025
d13bcdc
Merge branch 'main' into mes/logs-flake
Parkreiner May 23, 2025
0f21097
Merge branch 'main' into mes/logs-flake
Parkreiner Aug 2, 2025
abd6553
refactor: decrease coupling
Parkreiner Aug 2, 2025
bade97a
wip: commit progress on updating flake
Parkreiner Aug 2, 2025
e11fefd
Merge branch 'mes/logs-flake' of https://github.com/coder/coder into …
Parkreiner Aug 2, 2025
bc3d095
fix: get all tests passing
Parkreiner Aug 2, 2025
550d09e
chore: add one more test case
Parkreiner Aug 2, 2025
cc7e632
fix: update type mismatches
Parkreiner Aug 2, 2025
79c7ffd
refactor: clean up some code
Parkreiner Aug 2, 2025
43a0d3a
fix: make testing boundaries more formal
Parkreiner Aug 2, 2025
982d3e1
fix: remove premature optimization
Parkreiner Aug 2, 2025
41c5a12
fix: update setup
Parkreiner Aug 4, 2025
42cb73b
fix: update state sync logic
Parkreiner Aug 4, 2025
3a5f7bb
Merge branch 'main' into mes/logs-flake
Parkreiner Aug 4, 2025
35a40df
fix: update wonky types
Parkreiner Aug 4, 2025
306dbc7
Merge branch 'main' into mes/logs-flake
Parkreiner Aug 4, 2025
f49e55a
Merge branch 'main' into mes/logs-flake
Parkreiner Aug 7, 2025
c2fc772
fix: update tests
Parkreiner Aug 7, 2025
2cabd85
fix: format
Parkreiner Aug 7, 2025
855f3ca
Merge branch 'main' into mes/logs-flake
Parkreiner Aug 9, 2025
453894b
fix: apply initial feedback
Parkreiner Aug 9, 2025
c9f2b12
wip: commit refactoring progress
Parkreiner Aug 9, 2025
80865fe
refactor: update assignment
Parkreiner Aug 9, 2025
f930b29
wip: prepare to change indents
Parkreiner Aug 9, 2025
a311ac9
fix: update keygen logic
Parkreiner Aug 9, 2025
5657536
chore: add basic overflow message
Parkreiner Aug 9, 2025
6547a2f
chore: swap to tailwind
Parkreiner Aug 9, 2025
c818aec
wip: commit progress
Parkreiner Aug 12, 2025
30bb008
Merge branch 'main' into mes/logs-flake
Parkreiner Aug 15, 2025
dac3828
refactor: switch to spy setup
Parkreiner Aug 15, 2025
7a013d3
fix: get scaffolding for new AgentLogs in place
Parkreiner Aug 15, 2025
b71d051
fix: remove bad import
Parkreiner Aug 15, 2025
092c4e5
fix: knip
Parkreiner Aug 15, 2025
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
Next Next commit
wip: commit progress on test update
  • Loading branch information
Parkreiner committed May 23, 2025
commit d6e00c3dd4df9bdccfbad27289fac3d7d3b6de9b
117 changes: 73 additions & 44 deletions site/src/modules/resources/useAgentLogs.test.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,89 @@
import { renderHook, waitFor } from "@testing-library/react";
import {
renderHook,
type RenderHookResult,
waitFor,
} from "@testing-library/react";
import type { WorkspaceAgentLog } from "api/typesGenerated";
import WS from "jest-websocket-mock";
import { MockWorkspaceAgent } from "testHelpers/entities";
import { useAgentLogs } from "./useAgentLogs";
import { createUseAgentLogs } from "./useAgentLogs";
import {
createMockWebSocket,
type MockWebSocketPublisher,
} from "testHelpers/websockets";
import { OneWayWebSocket } from "utils/OneWayWebSocket";

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

describe("useAgentLogs", () => {
afterEach(() => {
WS.clean();
type MountHookResult = Readonly<
RenderHookResult<readonly WorkspaceAgentLog[], { enabled: boolean }> & {
publisher: MockWebSocketPublisher;
}
>;

function mountHook(): MountHookResult {
let publisher!: MockWebSocketPublisher;
const useAgentLogs = createUseAgentLogs((agentId, params) => {
return new OneWayWebSocket({
apiRoute: `/api/v2/workspaceagents/${agentId}/logs`,
searchParams: new URLSearchParams({
follow: "true",
after: params?.after?.toString() || "0",
}),
websocketInit: (url) => {
const [mockSocket, mockPub] = createMockWebSocket(url);
publisher = mockPub;
return mockSocket;
},
});
});

it("clear logs when disabled to avoid duplicates", async () => {
const server = new WS(
`ws://localhost/api/v2/workspaceagents/${
MockWorkspaceAgent.id
}/logs?follow&after=0`,
);
const { result, rerender } = renderHook(
({ enabled }) => useAgentLogs(MockWorkspaceAgent, enabled),
{ initialProps: { enabled: true } },
);
await server.connected;

// Send 3 logs
server.send(JSON.stringify(generateLogs(3)));
const { result, rerender, unmount } = renderHook(
({ enabled }) => useAgentLogs(MockWorkspaceAgent, enabled),
{ initialProps: { enabled: true } },
);

return { result, rerender, unmount, publisher };
}

describe("useAgentLogs", () => {
it("clears logs when hook becomes disabled (protection to avoid duplicate logs when hook goes back to being re-enabled)", async () => {
const { result, rerender, publisher } = mountHook();

// Verify that logs can be received after mount
const initialLogs = generateMockLogs(3);
const initialEvent = new MessageEvent<string>("message", {
data: JSON.stringify(initialLogs),
});
publisher.publishMessage(initialEvent);
await waitFor(() => {
expect(result.current).toHaveLength(3);
// Using expect.arrayContaining to account for the fact that we're
// not guaranteed to receive WebSocket events in order
expect(result.current).toEqual(expect.arrayContaining(initialLogs));
});

// Disable the hook
// Disable the hook (and have the hook close the connection behind the
// scenes)
rerender({ enabled: false });
await waitFor(() => {
expect(result.current).toHaveLength(0);
});
await waitFor(() => expect(result.current).toHaveLength(0));

// Enable the hook again
// Re-enable the hook (creating an entirely new connection), and send
// new logs
rerender({ enabled: true });
await server.connected;
server.send(JSON.stringify(generateLogs(3)));
const newLogs = generateMockLogs(3);
const newEvent = new MessageEvent<string>("message", {
data: JSON.stringify(newLogs),
});
publisher.publishMessage(newEvent);
await waitFor(() => {
expect(result.current).toHaveLength(3);
expect(result.current).toEqual(expect.arrayContaining(newLogs));
});
});
});

function generateLogs(count: number): WorkspaceAgentLog[] {
return Array.from({ length: count }, (_, i) => ({
id: i,
created_at: new Date().toISOString(),
level: "info",
output: `Log ${i}`,
source_id: "",
}));
}
90 changes: 54 additions & 36 deletions site/src/modules/resources/useAgentLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,63 @@ import type { WorkspaceAgent, WorkspaceAgentLog } from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { useEffect, useState } from "react";

export function useAgentLogs(
agent: WorkspaceAgent,
enabled: boolean,
): readonly WorkspaceAgentLog[] {
const [logs, setLogs] = useState<WorkspaceAgentLog[]>([]);

useEffect(() => {
if (!enabled) {
// Clean up the logs when the agent is not enabled. So it can receive logs
// from the beginning without duplicating the logs.
export function createUseAgentLogs(
createSocket: typeof watchWorkspaceAgentLogs,
) {
return function useAgentLogs(
agent: WorkspaceAgent,
enabled: boolean,
): readonly WorkspaceAgentLog[] {
const [logs, setLogs] = useState<readonly WorkspaceAgentLog[]>([]);

// Clean up the logs when the agent is not enabled, using a mid-render
// sync to remove any risk of screen flickering. Clearing the logs helps
// ensure that if the hook flips back to being enabled, we can receive a
// fresh set of logs from the beginning with zero risk of duplicates.
const [prevEnabled, setPrevEnabled] = useState(enabled);
if (!enabled && prevEnabled) {
setLogs([]);
return;
setPrevEnabled(false);
}

// Always fetch the logs from the beginning. We may want to optimize this in
// the future, but it would add some complexity in the code that maybe does
// not worth it.
const socket = watchWorkspaceAgentLogs(agent.id, { after: 0 });
socket.addEventListener("message", (e) => {
if (e.parseError) {
console.warn("Error parsing agent log: ", e.parseError);
useEffect(() => {
if (!enabled) {
return;
}
setLogs((logs) => [...logs, ...e.parsedMessage]);
});

socket.addEventListener("error", (e) => {
console.error("Error in agent log socket: ", e);
displayError(
"Unable to watch the agent logs",
"Please try refreshing the browser",
);
socket.close();
});

return () => {
socket.close();
};
}, [agent.id, enabled]);

return logs;

// Always fetch the logs from the beginning. We may want to optimize
// this in the future, but it would add some complexity in the code
// that might not be worth it.
const socket = createSocket(agent.id, { after: 0 });
socket.addEventListener("message", (e) => {
if (e.parseError) {
console.warn("Error parsing agent log: ", e.parseError);
return;
}
setLogs((logs) => [...logs, ...e.parsedMessage]);
});

socket.addEventListener("error", (e) => {
console.error("Error in agent log socket: ", e);
displayError(
"Unable to watch the agent logs",
"Please try refreshing the browser",
);
socket.close();
});

return () => socket.close();

// createSocket shouldn't ever change for the lifecycle of the hook,
// but Biome isn't smart enough to detect constant dependencies for
// higher-order hooks. Adding it to the array (even though it
// shouldn't ever be needed) seemed like the least fragile way to
// resolve the warning.
}, [createSocket, agent.id, enabled]);

return logs;
};
}

// The baseline implementation to use for production
export const useAgentLogs = createUseAgentLogs(watchWorkspaceAgentLogs);
152 changes: 152 additions & 0 deletions site/src/testHelpers/websockets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { WebSocketEventType } from "utils/OneWayWebSocket";

export type MockWebSocketPublisher = Readonly<{
publishMessage: (event: MessageEvent<string>) => void;
publishError: (event: ErrorEvent) => void;
publishClose: (event: CloseEvent) => void;
publishOpen: (event: Event) => void;
}>;

export type CreateMockWebSocketOptions = Readonly<{
// The URL to use to initialize the mock socket. This should match the
// "real" URL that you would pass to the built-in WebSocket constructor.
url: string;

// The additional WebSocket protocols to use when initializing. This should
// match the real protocols that you would pass to the built-in WebSocket
// constructor.
protocols?: string | string[];

// Indicates whether the mock socket should stay open after calling the
// .close method, so that it can be reused for a new connection. Defaults to
// false (meaning that the socket becomes completely unusable the first time
// after .close is called).
persistAfterClose?: boolean;
}>;

export function createMockWebSocket(
url: string,
protocols?: string | string[],
): readonly [WebSocket, MockWebSocketPublisher] {
type EventMap = {
message: MessageEvent<string>;
error: ErrorEvent;
close: CloseEvent;
open: Event;
};
type CallbackStore = {
[K in keyof EventMap]: ((event: EventMap[K]) => void)[];
};

let activeProtocol: string;
if (Array.isArray(protocols)) {
activeProtocol = protocols[0] ?? "";
} else if (typeof protocols === "string") {
activeProtocol = protocols;
} else {
activeProtocol = "";
}

let closed = false;
const store: CallbackStore = {
message: [],
error: [],
close: [],
open: [],
};

const mockSocket: WebSocket = {
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3,

url,
protocol: activeProtocol,
readyState: 1,
binaryType: "blob",
bufferedAmount: 0,
extensions: "",
onclose: null,
onerror: null,
onmessage: null,
onopen: null,
send: jest.fn(),
dispatchEvent: jest.fn(),

addEventListener: <E extends WebSocketEventType>(
eventType: E,
callback: WebSocketEventMap[E],
) => {
if (closed) {
return;
}

const subscribers = store[eventType];
const cb = callback as unknown as CallbackStore[E][0];
if (!subscribers.includes(cb)) {
subscribers.push(cb);
}
},

removeEventListener: <E extends WebSocketEventType>(
eventType: E,
callback: WebSocketEventMap[E],
) => {
if (closed) {
return;
}

const subscribers = store[eventType];
const cb = callback as unknown as CallbackStore[E][0];
if (subscribers.includes(cb)) {
const updated = store[eventType].filter((c) => c !== cb);
store[eventType] = updated as unknown as CallbackStore[E];
}
},

close: () => {
closed = true;
},
};

const publisher: MockWebSocketPublisher = {
publishOpen: (event) => {
if (closed) {
return;
}
for (const sub of store.open) {
sub(event);
}
},

publishError: (event) => {
if (closed) {
return;
}
for (const sub of store.error) {
sub(event);
}
},

publishMessage: (event) => {
if (closed) {
return;
}
for (const sub of store.message) {
sub(event);
}
},

publishClose: (event) => {
if (closed) {
return;
}
for (const sub of store.close) {
sub(event);
}
},
};

return [mockSocket, publisher] as const;
}
Loading