From 047880435c00b66551a15d4068bf3fd39a233ef0 Mon Sep 17 00:00:00 2001 From: Gaurav Bhola Date: Mon, 6 Apr 2026 07:04:46 -0700 Subject: [PATCH] fix(ci): prevent OOM in web coverage by splitting tests and raising heap limit useSessionEvents tests OOM the vitest worker when coverage instrumentation is enabled (4GB default isn't enough). Split the test file into SSE and mux suites so the worker can GC between files, and raise the heap to 6GB for the coverage job. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/coverage.yml | 2 +- .../__tests__/useSessionEvents.mux.test.ts | 161 ++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/hooks/__tests__/useSessionEvents.mux.test.ts diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 7c8fea589..fdc706f42 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -73,7 +73,7 @@ jobs: - name: Web tests with coverage if: needs.detect-changes.outputs.web == 'true' - run: pnpm --filter @composio/ao-web exec vitest run --coverage + run: NODE_OPTIONS="--max-old-space-size=6144" pnpm --filter @composio/ao-web exec vitest run --coverage - name: Prepare and merge lcov reports run: | diff --git a/packages/web/src/hooks/__tests__/useSessionEvents.mux.test.ts b/packages/web/src/hooks/__tests__/useSessionEvents.mux.test.ts new file mode 100644 index 000000000..4f200c46f --- /dev/null +++ b/packages/web/src/hooks/__tests__/useSessionEvents.mux.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useSessionEvents } from "../useSessionEvents"; +import { makeSession } from "../../__tests__/helpers"; + +describe("useSessionEvents – mux session updates", () => { + beforeEach(() => { + const eventSourceConstructor = vi.fn(() => ({ + onmessage: null, + onopen: null, + onerror: null, + readyState: 0, + close: vi.fn(), + })); + global.EventSource = Object.assign(eventSourceConstructor, { + CONNECTING: 0, + OPEN: 1, + CLOSED: 2, + }) as unknown as typeof EventSource; + global.fetch = vi.fn(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const makePatch = (id: string) => ({ + id, + status: "working", + activity: "active" as string | null, + attentionLevel: "none", + lastActivityAt: new Date().toISOString(), + }); + + const makeSessions = (ids: string[]) => ids.map((id) => makeSession({ id })); + + it("sets connectionStatus to connected when muxSessions is provided", () => { + const patches = [makePatch("s1")]; + const { result } = renderHook(() => + useSessionEvents(makeSessions(["s1"]), undefined, patches), + ); + expect(result.current.connectionStatus).toBe("connected"); + }); + + it("dispatches snapshot from muxSessions", () => { + const sessions = makeSessions(["s1", "s2"]); + const patches = [ + { ...makePatch("s1"), status: "working", activity: "active" }, + { ...makePatch("s2"), status: "pr_open", activity: "idle" }, + ]; + const { result } = renderHook(() => + useSessionEvents(sessions, undefined, patches), + ); + expect(result.current.sessions[0].status).toBe("working"); + expect(result.current.sessions[1].status).toBe("pr_open"); + }); + + it("triggers scheduleRefresh when mux session membership changes", async () => { + vi.useFakeTimers(); + const sessions = makeSessions(["s1", "s2"]); + const initialPatches = [makePatch("s1"), makePatch("s2")]; + const newPatches = [makePatch("s1")]; + let currentPatches = initialPatches; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + sessions: [makeSession({ id: "s1" })], + }), + } as unknown as Response); + + const { rerender } = renderHook(() => + useSessionEvents(sessions, undefined, currentPatches), + ); + + currentPatches = newPatches; + rerender(); + + act(() => vi.advanceTimersByTime(200)); + await waitFor(() => vi.mocked(fetch).mock.calls.length > 0); + vi.useRealTimers(); + }); + + it("aborts in-flight request when muxSessions changes rapidly", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const sessions = makeSessions(["s1", "s2", "s3"]); + let resolveFirst!: () => void; + const firstFetch = new Promise((resolve) => { + resolveFirst = () => + resolve({ + ok: true, + json: async () => ({ sessions: [] }), + } as unknown as Response); + }); + + vi.mocked(fetch) + .mockReturnValueOnce(firstFetch) + .mockResolvedValue({ + ok: true, + json: async () => ({ sessions: [] }), + } as unknown as Response); + + let patches = [makePatch("s1")]; + const { rerender } = renderHook(() => + useSessionEvents(sessions, undefined, patches), + ); + + patches = [makePatch("s1"), makePatch("s2")]; + rerender(); + act(() => vi.advanceTimersByTime(200)); + + patches = [makePatch("s1"), makePatch("s2"), makePatch("s3")]; + rerender(); + + resolveFirst(); + vi.useRealTimers(); + expect(controller.signal).toBeDefined(); + }); + + it("skips SSE setup when muxActive is true", () => { + const patches = [makePatch("s1")]; + const EventSourceSpy = vi.fn(() => ({ + close: vi.fn(), + onmessage: null, + onerror: null, + onopen: null, + readyState: 1, + })); + global.EventSource = Object.assign(EventSourceSpy, { + CONNECTING: 0, OPEN: 1, CLOSED: 2, + }) as unknown as typeof EventSource; + + renderHook(() => useSessionEvents([], undefined, patches)); + + expect(EventSourceSpy).not.toHaveBeenCalled(); + }); + + it("mux-active cleanup aborts in-flight fetch and clears timer", async () => { + vi.useFakeTimers(); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ sessions: [] }), + } as unknown as Response); + + const sessions = makeSessions(["s1", "s2"]); + const patches = [makePatch("s1")]; + let mux: typeof patches | undefined = patches; + const { rerender, unmount } = renderHook(() => + useSessionEvents(sessions, undefined, mux), + ); + + mux = [makePatch("s1"), makePatch("s2")]; + rerender(); + + unmount(); + act(() => vi.advanceTimersByTime(500)); + vi.useRealTimers(); + }); +});