Skip to content

fix(site): update useAgentLogs to make it more testable and add more tests #19126

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 our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 39 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
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
2 changes: 1 addition & 1 deletion site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export const watchWorkspaceAgentLogs = (
});
};

type WatchWorkspaceAgentLogsParams = {
export type WatchWorkspaceAgentLogsParams = {
after?: number;
};

Expand Down
3 changes: 1 addition & 2 deletions site/src/hooks/useEmbeddedMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,13 +229,12 @@ export function makeUseEmbeddedMetadata(
manager.getMetadata,
);

// biome-ignore lint/correctness/useExhaustiveDependencies(manager.clearMetadataByKey): baked into containing hook
const stableMetadataResult = useMemo<UseEmbeddedMetadataResult>(() => {
return {
metadata,
clearMetadataByKey: manager.clearMetadataByKey,
};
}, [metadata]);
}, [manager, metadata]);
Copy link
Member Author

@Parkreiner Parkreiner Aug 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A case where realistically, the value of manager won't ever change, but it technically can, since function parameters are reassignable. So better to add it to the dependencies for correctness

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the extra dep is way nicer than the excessively long biome-ignore comment. good tradeoff.


return stableMetadataResult;
};
Expand Down
21 changes: 11 additions & 10 deletions site/src/modules/resources/AgentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
Template,
Workspace,
WorkspaceAgent,
WorkspaceAgentLog,
WorkspaceAgentMetadata,
} from "api/typesGenerated";
import { Button } from "components/Button/Button";
Expand Down Expand Up @@ -76,24 +77,24 @@ export const AgentRow: FC<AgentRowProps> = ({
["starting", "start_timeout"].includes(agent.lifecycle_state) &&
hasStartupFeatures,
);
const agentLogs = useAgentLogs(agent, showLogs);
const agentLogs = useAgentLogs(agent.id, showLogs);
const logListRef = useRef<List>(null);
const logListDivRef = useRef<HTMLDivElement>(null);
const startupLogs = useMemo(() => {
const allLogs = agentLogs || [];
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agentLogs is no longer nullable, so a bunch of logic can be removed


const logs = [...allLogs];
if (agent.logs_overflowed) {
logs.push({
const startupLogs = useMemo<readonly WorkspaceAgentLog[]>(() => {
if (!agent.logs_overflowed) {
return agentLogs;
}
return [
...agentLogs,
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also I know it was already like this, but is shoving a log entry on the end really the best way to do this?

this startupLogs value eventually gets passed to AgentLogs below. what if we just passed agentLogs in unmodified and added an overflowed prop that shows the warning?

id: -1,
level: "error",
output:
"Startup logs exceeded the max size of 1MB, and will not continue to be written to the database! Logs will continue to be written to the /tmp/coder-startup-script.log file in the workspace.",
created_at: new Date().toISOString(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're leaking in here. I feel like this should be...

Suggested change
created_at: new Date().toISOString(),
created_at: agentLogs.at(-1).created_at,

since that's probably more "correct" and deterministic

source_id: "",
});
}
return logs;
},
];
}, [agentLogs, agent.logs_overflowed]);
const [bottomOfLogs, setBottomOfLogs] = useState(true);

Expand Down
239 changes: 190 additions & 49 deletions site/src/modules/resources/useAgentLogs.test.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,201 @@
import { renderHook, waitFor } from "@testing-library/react";
Copy link
Member Author

@Parkreiner Parkreiner Aug 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enough changed with the test file that it honestly might not even be worth looking at the old version of the code

import type { WorkspaceAgentLog } from "api/typesGenerated";
import WS from "jest-websocket-mock";
import { act } from "react";
import { MockWorkspaceAgent } from "testHelpers/entities";
import { useAgentLogs } from "./useAgentLogs";

/**
* 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
*/

describe.skip("useAgentLogs", () => {
afterEach(() => {
WS.clean();
});

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)));
await waitFor(() => {
expect(result.current).toHaveLength(3);
import {
type MockWebSocketPublisher,
createMockWebSocket,
} from "testHelpers/websockets";
import { OneWayWebSocket } from "utils/OneWayWebSocket";
import {
type CreateUseAgentLogsOptions,
createUseAgentLogs,
} from "./useAgentLogs";

const millisecondsInOneMinute = 60_000;

function generateMockLogs(
logCount: number,
baseDate = new Date(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
baseDate = new Date(),
baseDate = new Date("Thu, 07 Aug 2025 21:38:12 GMT"),

as an example, how about just right now

): readonly WorkspaceAgentLog[] {
return Array.from({ length: logCount }, (_, i) => {
// Make sure that the logs generated each have unique timestamps, so
// that we can test whether the hook is sorting them properly as it's
// receiving them over time
const logDate = new Date(baseDate.getTime() + i * millisecondsInOneMinute);
return {
id: i,
created_at: logDate.toISOString(),
level: "info",
output: `Log ${i}`,
source_id: "",
};
});
}

// A mutable object holding the most recent mock WebSocket publisher that was
// created when initializing a mock WebSocket. Inner value will be undefined if
// the hook is disabled on mount, but will always be defined otherwise
type PublisherResult = { current: MockWebSocketPublisher | undefined };

type MountHookOptions = Readonly<{
initialAgentId: string;
enabled?: boolean;
onError?: CreateUseAgentLogsOptions["onError"];
}>;

type MountHookResult = Readonly<{
rerender: (props: { agentId: string; enabled: boolean }) => void;
publisherResult: PublisherResult;

// Note: the `current` property is only "halfway" readonly; the value is
// readonly, but the key is still mutable
hookResult: { current: readonly WorkspaceAgentLog[] };
}>;

function mountHook(options: MountHookOptions): MountHookResult {
const { initialAgentId, enabled = true, onError = jest.fn() } = options;

const publisherResult: PublisherResult = { current: undefined };
const useAgentLogs = createUseAgentLogs({
onError,
createSocket: (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, mockPublisher] = createMockWebSocket(url);
publisherResult.current = mockPublisher;
return mockSocket;
},
});
},
});

const { result, rerender } = renderHook(
({ agentId, enabled }) => useAgentLogs(agentId, enabled),
{ initialProps: { agentId: initialAgentId, enabled: enabled } },
);

return {
rerender,
hookResult: result,
publisherResult,
};
}

describe("useAgentLogs", () => {
it("Automatically sorts logs that are received out of order", async () => {
const { hookResult, publisherResult } = mountHook({
initialAgentId: MockWorkspaceAgent.id,
});

// Disable the hook
rerender({ enabled: false });
await waitFor(() => {
expect(result.current).toHaveLength(0);
const logs = generateMockLogs(10, new Date("september 9, 1999"));
const reversed = logs.toReversed();

for (const log of reversed) {
act(() => {
publisherResult.current?.publishMessage(
new MessageEvent<string>("message", {
data: JSON.stringify([log]),
}),
);
});
}
await waitFor(() => expect(hookResult.current).toEqual(logs));
});

it("Never creates a connection if hook is disabled on mount", () => {
const { publisherResult } = mountHook({
initialAgentId: MockWorkspaceAgent.id,
enabled: false,
});

// Enable the hook again
rerender({ enabled: true });
await server.connected;
server.send(JSON.stringify(generateLogs(3)));
expect(publisherResult.current).toBe(undefined);
});

it("Automatically closes the socket connection when the hook is disabled", async () => {
const { publisherResult, rerender } = mountHook({
initialAgentId: MockWorkspaceAgent.id,
});

expect(publisherResult.current?.isConnectionOpen).toBe(true);
rerender({ agentId: MockWorkspaceAgent.id, enabled: false });
await waitFor(() => {
expect(result.current).toHaveLength(3);
expect(publisherResult.current?.isConnectionOpen).toBe(false);
});
});
});

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: "",
}));
}
it("Automatically closes the old connection when the agent ID changes", () => {
const { publisherResult, rerender } = mountHook({
initialAgentId: MockWorkspaceAgent.id,
});

const publisher1 = publisherResult.current;
expect(publisher1?.isConnectionOpen).toBe(true);

rerender({
enabled: true,
agentId: `${MockWorkspaceAgent.id}-new-value`,
});

const publisher2 = publisherResult.current;
expect(publisher1).not.toBe(publisher2);
expect(publisher1?.isConnectionOpen).toBe(false);
expect(publisher2?.isConnectionOpen).toBe(true);
});

it("Calls error callback when error is received (but only while hook is enabled)", async () => {
const onError = jest.fn();
const { publisherResult, rerender } = mountHook({
initialAgentId: MockWorkspaceAgent.id,
// Start off disabled so that we can check that the callback is
// never called when there is no connection
enabled: false,
onError,
});

const errorEvent = new Event("error");
act(() => publisherResult.current?.publishError(errorEvent));
expect(onError).not.toHaveBeenCalled();

rerender({ agentId: MockWorkspaceAgent.id, enabled: true });
act(() => publisherResult.current?.publishError(errorEvent));
expect(onError).toHaveBeenCalledTimes(1);
});

it("Clears logs when hook becomes disabled (protection to avoid duplicate logs when hook goes back to being re-enabled)", async () => {
const { hookResult, publisherResult, rerender } = mountHook({
initialAgentId: MockWorkspaceAgent.id,
});

// Send initial logs so that we have something to clear out later
const initialLogs = generateMockLogs(3, new Date("april 5, 1997"));
const initialEvent = new MessageEvent<string>("message", {
data: JSON.stringify(initialLogs),
});
act(() => publisherResult.current?.publishMessage(initialEvent));
await waitFor(() => expect(hookResult.current).toEqual(initialLogs));

// Need to do the following steps multiple times to make sure that we
// don't break anything after the first disable
const mockDates: readonly string[] = ["october 3, 2005", "august 1, 2025"];
for (const md of mockDates) {
// Disable the hook to clear current logs
rerender({ agentId: MockWorkspaceAgent.id, enabled: false });
await waitFor(() => expect(hookResult.current).toHaveLength(0));

// Re-enable the hook and send new logs
rerender({ agentId: MockWorkspaceAgent.id, enabled: true });
const newLogs = generateMockLogs(3, new Date(md));
const newEvent = new MessageEvent<string>("message", {
data: JSON.stringify(newLogs),
});
act(() => publisherResult.current?.publishMessage(newEvent));
await waitFor(() => expect(hookResult.current).toEqual(newLogs));
}
});
});
Loading
Loading