Skip to content
Open
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
76 changes: 73 additions & 3 deletions __tests__/components/editor/HealthCheckPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ vi.mock("@/lib/api/lint", () => ({
getLintConfig: vi.fn(),
getLevels: vi.fn(),
},
createLintWebSocket: vi.fn(() => ({ close: vi.fn() })),
createLintWebSocket: vi.fn(() => ({ close: vi.fn(), addEventListener: vi.fn() })),
}));

vi.mock("@/lib/api/quality", () => ({
Expand Down Expand Up @@ -133,7 +133,7 @@ describe("HealthCheckPanel", () => {
checked_at: "",
duration_ms: 0,
});
mockCreateLintWebSocket.mockReturnValue({ close: vi.fn() } as unknown as WebSocket);
mockCreateLintWebSocket.mockReturnValue({ close: vi.fn(), addEventListener: vi.fn() } as unknown as WebSocket);
mockQualityApi.getLatestDuplicates.mockResolvedValue({
clusters: [],
threshold: 0.85,
Expand Down Expand Up @@ -1809,7 +1809,7 @@ describe("HealthCheckPanel", () => {
mockCreateLintWebSocket.mockImplementation(
(_projectId: string, onMessage: (msg: LintWebSocketMessage) => void) => {
capturedLintOnMessage = onMessage;
return { close: vi.fn() } as unknown as WebSocket;
return { close: vi.fn(), addEventListener: vi.fn() } as unknown as WebSocket;
}
);

Expand Down Expand Up @@ -2077,6 +2077,76 @@ describe("HealthCheckPanel", () => {
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { cb(0); return 0; });
});

describe("onWsStatusChange callbacks", () => {
it("calls onWsStatusChange('disconnected') immediately when isOpen is false", () => {
const onWsStatusChange = vi.fn();
setup({ isOpen: false, onWsStatusChange });
expect(onWsStatusChange).toHaveBeenCalledWith("disconnected");
});

it("calls onWsStatusChange('connecting') then 'connected' when WS opens", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const onWsStatusChange = vi.fn();
const captured: { openListener: (() => void) | null } = { openListener: null };
mockCreateLintWebSocket.mockImplementation(
(_projectId: string, _onMessage: (msg: LintWebSocketMessage) => void) => {
const ws = { close: vi.fn(), addEventListener: vi.fn((event: string, cb: () => void) => {
if (event === "open") captured.openListener = cb;
}) };
return ws as unknown as WebSocket;
}
);

setup({ onWsStatusChange });
expect(onWsStatusChange).toHaveBeenCalledWith("connecting");

await vi.advanceTimersByTimeAsync(110);
captured.openListener?.();
expect(onWsStatusChange).toHaveBeenCalledWith("connected");
} finally {
vi.useRealTimers();
}
});

it("calls onWsStatusChange('disconnected') when WS closes", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const onWsStatusChange = vi.fn();
const captured: { closeListener: (() => void) | null } = { closeListener: null };
mockCreateLintWebSocket.mockImplementation(
(_projectId: string, _onMessage: (msg: LintWebSocketMessage) => void) => {
const ws = { close: vi.fn(), addEventListener: vi.fn((event: string, cb: () => void) => {
if (event === "close") captured.closeListener = cb;
}) };
return ws as unknown as WebSocket;
}
);

setup({ onWsStatusChange });
await vi.advanceTimersByTimeAsync(110);
captured.closeListener?.();
expect(onWsStatusChange).toHaveBeenCalledWith("disconnected");
} finally {
vi.useRealTimers();
}
});

it("calls onWsStatusChange('disconnected') on cleanup", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const onWsStatusChange = vi.fn();
const { unmount } = setup({ onWsStatusChange });
await vi.advanceTimersByTimeAsync(110);
onWsStatusChange.mockClear();
unmount();
expect(onWsStatusChange).toHaveBeenCalledWith("disconnected");
} finally {
vi.useRealTimers();
}
});
});

it("does not reopen the lint WebSocket when the issue filter changes", async () => {
// The fetchDataRef pattern keeps `filter` out of the WS effect's deps so
// changing filters re-issues HTTP for filtered issues without tearing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1349,9 +1349,6 @@ describe("DeveloperEditorLayout", () => {
});

it("preserves the selection store on unmount so side-page Back-to-project links can read it", () => {
// Regression: editor used to clear the store on unmount, racing with
// side-page navigation — by the time settings/PRs/etc rendered, the
// store was empty and useProjectHomeHref dropped the selection.
const { unmount } = render(
<DeveloperEditorLayout
{...defaultProps({ selectedIri: "http://example.org/Foo" })}
Expand All @@ -1362,4 +1359,22 @@ describe("DeveloperEditorLayout", () => {
expect(useSelectionStore.getState().type).toBe("class");
});
});

describe("HealthCheckPanel integration", () => {
it("does not render HealthCheckPanel by default", () => {
render(<DeveloperEditorLayout {...defaultProps()} />);
expect(screen.queryByTestId("health-check-panel")).toBeNull();
});

it("renders HealthCheckPanel when showHealthCheck is true", () => {
render(<DeveloperEditorLayout {...defaultProps({ showHealthCheck: true })} />);
expect(screen.getByTestId("health-check-panel")).toBeDefined();
});

it("passes onLintWsStatusChange through to HealthCheckPanel", () => {
const onLintWsStatusChange = vi.fn();
render(<DeveloperEditorLayout {...defaultProps({ showHealthCheck: true, onLintWsStatusChange })} />);
expect(screen.getByTestId("health-check-panel")).toBeDefined();
});
});
});
143 changes: 10 additions & 133 deletions __tests__/lib/hooks/useCollaborationStatus.test.ts
Original file line number Diff line number Diff line change
@@ -1,153 +1,30 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import { useCollaborationStatus } from "@/lib/hooks/useCollaborationStatus";

// Mock WebSocket
class MockWebSocket {
static instances: MockWebSocket[] = [];
url: string;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
close = vi.fn(() => {
this.onclose?.();
});

constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
}

beforeEach(() => {
vi.useFakeTimers();
MockWebSocket.instances = [];
vi.stubGlobal("WebSocket", MockWebSocket);
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

describe("useCollaborationStatus", () => {
it("returns disconnected status when not enabled", () => {
it("returns disabled status (stub — collab endpoint not yet implemented)", () => {
const { result } = renderHook(() =>
useCollaborationStatus({ projectId: "proj-1", enabled: false }),
useCollaborationStatus({ projectId: "proj-1" }),
);

expect(result.current.status).toBe("disconnected");
expect(result.current.status).toBe("disabled");
expect(result.current.isConnected).toBe(false);
});

it("returns the correct endpoint path", () => {
it("returns the collab endpoint path", () => {
const { result } = renderHook(() =>
useCollaborationStatus({ projectId: "proj-1", enabled: false }),
useCollaborationStatus({ projectId: "proj-1" }),
);

expect(result.current.endpoint).toBe("/api/v1/projects/proj-1/lint/ws");
expect(result.current.endpoint).toBe("/api/v1/collab/ws");
});

it("returns the purpose description", () => {
const { result } = renderHook(() =>
useCollaborationStatus({ projectId: "proj-1", enabled: false }),
);

expect(result.current.purpose).toBe("Real-time lint status updates");
});

it("constructs correct WebSocket URL", () => {
renderHook(() =>
useCollaborationStatus({ projectId: "proj-1", enabled: true }),
useCollaborationStatus({ projectId: "proj-1" }),
);

// The hook has a 100ms delay before connecting
act(() => {
vi.advanceTimersByTime(100);
});

expect(MockWebSocket.instances.length).toBeGreaterThan(0);
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
expect(ws.url).toBe("ws://localhost:8000/api/v1/projects/proj-1/lint/ws");
});

it("transitions to connecting then connected on open", () => {
const { result } = renderHook(() =>
useCollaborationStatus({ projectId: "proj-1", enabled: true }),
);

act(() => {
vi.advanceTimersByTime(100);
});

// Should be connecting after timer
expect(result.current.status).toBe("connecting");

const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
act(() => {
ws.onopen?.();
});

expect(result.current.status).toBe("connected");
expect(result.current.isConnected).toBe(true);
});

it("transitions to disconnected on close", () => {
const { result } = renderHook(() =>
useCollaborationStatus({ projectId: "proj-1", enabled: true }),
);

act(() => {
vi.advanceTimersByTime(100);
});

const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
act(() => {
ws.onopen?.();
});
expect(result.current.status).toBe("connected");

// Simulate a close (not initiated by disconnect())
act(() => {
ws.onclose?.();
});

expect(result.current.status).toBe("disconnected");
});

it("cleans up WebSocket on unmount", () => {
const { unmount } = renderHook(() =>
useCollaborationStatus({ projectId: "proj-1", enabled: true }),
);

act(() => {
vi.advanceTimersByTime(100);
});

const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
act(() => {
ws.onopen?.();
});

unmount();

expect(ws.close).toHaveBeenCalled();
});

it("does not create WebSocket when projectId is empty", () => {
renderHook(() =>
useCollaborationStatus({ projectId: "", enabled: true }),
);

act(() => {
vi.advanceTimersByTime(200);
});

// No WebSocket should have been created (or only from cleanup)
const connectedInstances = MockWebSocket.instances.filter(
(ws) => ws.url.includes("/lint/ws"),
);
expect(connectedInstances).toHaveLength(0);
expect(result.current.purpose).toBe("Real-time collaboration (coming soon)");
});
});
20 changes: 0 additions & 20 deletions __tests__/lib/hooks/useProjectViewer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,26 +414,6 @@ describe("useProjectViewer", () => {
expect(result.current.treeError).toBeNull();
});

it("exposes collaboration status", () => {
mockUseProject.mockReturnValue({
project: makeProject(),
isLoading: false,
error: null,
errorKind: null,
});

const { result } = renderHook(() =>
useProjectViewer({
projectId: "p1",
accessToken: "tok",
sessionStatus: "authenticated",
activeBranch: "main",
})
);

expect(result.current.connectionStatus).toBe("disconnected");
});

it("exposes source state with defaults", () => {
mockUseProject.mockReturnValue({
project: makeProject(),
Expand Down
Loading
Loading