Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { installDom } from "../../../../tests/ui/dom";
import * as APIModule from "@/browser/contexts/API";
import type { APIClient, UseAPIResult } from "@/browser/contexts/API";
import * as WorkspaceHeartbeatHookModule from "@/browser/hooks/useWorkspaceHeartbeat";
import type { HeartbeatFormSettings } from "@/browser/hooks/useWorkspaceHeartbeat";
import {
Expand Down Expand Up @@ -33,6 +35,27 @@ let saveResult = true;
let hookError: string | null = null;
let hookIsLoading = false;
let hookIsSaving = false;
let useWorkspaceHeartbeatSpy: ReturnType<
typeof spyOn<typeof WorkspaceHeartbeatHookModule, "useWorkspaceHeartbeat">
>;

type ConnectedUseAPIResult = Extract<UseAPIResult, { status: "connected" }>;
interface WorkspaceHeartbeatTestAPI {
workspace: {
heartbeat: {
get: (input: { workspaceId: string }) => Promise<HeartbeatFormSettings | null>;
set: (
_input: unknown
) => Promise<{ success: true; data: void } | { success: false; error: string }>;
};
};
config: {
getConfig: () => Promise<{
heartbeatDefaultIntervalMs?: number;
heartbeatDefaultPrompt?: string;
}>;
};
}

function createHeartbeatSettings(
overrides: Partial<HeartbeatFormSettings> = {}
Expand All @@ -45,6 +68,16 @@ function createHeartbeatSettings(
};
}

function createConnectedUseAPIResult(api: WorkspaceHeartbeatTestAPI): ConnectedUseAPIResult {
return {
api: api as APIClient,
status: "connected",
error: null,
authenticate: () => undefined,
retry: () => undefined,
};
}

const LONG_HEARTBEAT_MESSAGE = "Review pending work and summarize next steps. ".repeat(30).trim();

describe("WorkspaceHeartbeatModal", () => {
Expand All @@ -57,7 +90,10 @@ describe("WorkspaceHeartbeatModal", () => {
hookIsLoading = false;
hookIsSaving = false;

spyOn(WorkspaceHeartbeatHookModule, "useWorkspaceHeartbeat").mockImplementation((params) => {
useWorkspaceHeartbeatSpy = spyOn(
WorkspaceHeartbeatHookModule,
"useWorkspaceHeartbeat"
).mockImplementation((params) => {
const workspaceId = params.workspaceId;
return {
settings:
Expand All @@ -67,6 +103,7 @@ describe("WorkspaceHeartbeatModal", () => {
isLoading: hookIsLoading,
isSaving: hookIsSaving,
error: hookError,
globalDefaultPrompt: undefined,
save: (next: HeartbeatFormSettings) => {
if (!workspaceId) {
return Promise.resolve(false);
Expand Down Expand Up @@ -136,6 +173,59 @@ describe("WorkspaceHeartbeatModal", () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});

test("loads global heartbeat defaults when the workspace has no saved heartbeat config", async () => {
useWorkspaceHeartbeatSpy.mockRestore();

const globalIntervalMs = 6 * 60_000;
const globalPrompt = "test";
const workspaceHeartbeatGetMock = mock(() => Promise.resolve(null));
const workspaceHeartbeatSetMock = mock(() =>
Promise.resolve({ success: true as const, data: undefined })
);
const getConfigMock = mock(() =>
Promise.resolve({
heartbeatDefaultIntervalMs: globalIntervalMs,
heartbeatDefaultPrompt: globalPrompt,
})
);
const mockApi: WorkspaceHeartbeatTestAPI = {
workspace: {
heartbeat: {
get: workspaceHeartbeatGetMock,
set: workspaceHeartbeatSetMock,
},
},
config: {
getConfig: getConfigMock,
},
};
spyOn(APIModule, "useAPI").mockImplementation(() => createConnectedUseAPIResult(mockApi));

const view = render(
<WorkspaceHeartbeatModal
workspaceId="ws-1"
open={true}
onOpenChange={mock((_open: boolean) => undefined)}
/>
);

const intervalField = (await waitFor(() =>
view.getByLabelText("Heartbeat interval in minutes")
)) as HTMLInputElement;
expect(intervalField.value).toBe("6");
expect(workspaceHeartbeatGetMock).toHaveBeenCalledWith({ workspaceId: "ws-1" });
expect(getConfigMock).toHaveBeenCalled();

fireEvent.click(view.getByRole("switch", { name: "Enable workspace heartbeats" }));

const messageField = (await waitFor(() =>
view.getByLabelText("Heartbeat message")
)) as HTMLTextAreaElement;
// Global prompt is not seeded into the form to avoid persisting it as a workspace
// override on save. The backend handles prompt fallback at execution time.
expect(messageField.value).toBe("");
});

test("saves the selected heartbeat context mode and updates helper copy", async () => {
settingsByWorkspaceId.set(
"ws-1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,11 @@ function getDraftMessageForSave(value: string): string {
}

export function WorkspaceHeartbeatModal(props: WorkspaceHeartbeatModalProps) {
const { settings, isLoading, isSaving, error, save } = useWorkspaceHeartbeat({
workspaceId: props.open ? props.workspaceId : null,
});
const { settings, isLoading, isSaving, error, save, globalDefaultPrompt } = useWorkspaceHeartbeat(
{
workspaceId: props.open ? props.workspaceId : null,
}
);
const settingsContextMode = settings.contextMode ?? HEARTBEAT_DEFAULT_CONTEXT_MODE;
const [draftEnabled, setDraftEnabled] = useState(false);
const [draftIntervalMinutes, setDraftIntervalMinutes] = useState(
Expand Down Expand Up @@ -351,7 +353,7 @@ export function WorkspaceHeartbeatModal(props: WorkspaceHeartbeatModalProps) {
}}
disabled={isSaving}
className="border-border-medium bg-background-secondary text-foreground focus:border-accent focus:ring-accent min-h-[120px] w-full resize-y rounded-md border p-3 text-sm leading-relaxed focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder={HEARTBEAT_DEFAULT_MESSAGE_BODY}
placeholder={globalDefaultPrompt ?? HEARTBEAT_DEFAULT_MESSAGE_BODY}
aria-label="Heartbeat message"
/>
</div>
Expand Down
196 changes: 196 additions & 0 deletions src/browser/features/Settings/Sections/HeartbeatSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import "../../../../../tests/ui/dom";

import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";

import { ThemeProvider } from "@/browser/contexts/ThemeContext";
import { HEARTBEAT_DEFAULT_MESSAGE_BODY } from "@/constants/heartbeat";
import { installDom } from "../../../../../tests/ui/dom";

interface MockConfig {
heartbeatDefaultPrompt?: string;
heartbeatDefaultIntervalMs?: number;
}

interface MockOptions {
getConfigError?: Error;
}

interface MockAPIClient {
config: {
getConfig: () => Promise<MockConfig>;
updateHeartbeatDefaultPrompt: (input: { defaultPrompt?: string | null }) => Promise<void>;
updateHeartbeatDefaultIntervalMs: (input: { intervalMs?: number | null }) => Promise<void>;
};
}

let mockApi: MockAPIClient;

void mock.module("@/browser/contexts/API", () => ({
useAPI: () => ({
api: mockApi,
status: "connected" as const,
error: null,
authenticate: () => undefined,
retry: () => undefined,
}),
}));

import { HeartbeatSection } from "./HeartbeatSection";

function createMockAPI(configOverrides: Partial<MockConfig> = {}, options: MockOptions = {}) {
const config: MockConfig = {
...configOverrides,
};

const updateHeartbeatDefaultPromptMock = mock(
({ defaultPrompt }: { defaultPrompt?: string | null }) => {
config.heartbeatDefaultPrompt = defaultPrompt?.trim() ? defaultPrompt.trim() : undefined;
return Promise.resolve();
}
);
const updateHeartbeatDefaultIntervalMsMock = mock(
({ intervalMs }: { intervalMs?: number | null }) => {
config.heartbeatDefaultIntervalMs = intervalMs ?? undefined;
return Promise.resolve();
}
);

return {
api: {
config: {
getConfig: mock(() =>
options.getConfigError
? Promise.reject(options.getConfigError)
: Promise.resolve({ ...config })
),
updateHeartbeatDefaultPrompt: updateHeartbeatDefaultPromptMock,
updateHeartbeatDefaultIntervalMs: updateHeartbeatDefaultIntervalMsMock,
},
},
updateHeartbeatDefaultPromptMock,
updateHeartbeatDefaultIntervalMsMock,
};
}

function renderHeartbeatSection(
configOverrides: Partial<MockConfig> = {},
options: MockOptions = {}
) {
const { api, updateHeartbeatDefaultPromptMock, updateHeartbeatDefaultIntervalMsMock } =
createMockAPI(configOverrides, options);
mockApi = api;

const view = render(
<ThemeProvider forcedTheme="dark">
<HeartbeatSection />
</ThemeProvider>
);

return { view, updateHeartbeatDefaultPromptMock, updateHeartbeatDefaultIntervalMsMock };
}

describe("HeartbeatSection", () => {
let cleanupDom: (() => void) | null = null;

beforeEach(() => {
cleanupDom = installDom();
});

afterEach(() => {
cleanup();
mock.restore();
cleanupDom?.();
cleanupDom = null;
});

test("renders the default heartbeat controls", async () => {
const { view } = renderHeartbeatSection();

const thresholdInput = (await waitFor(() =>
view.getByLabelText("Default heartbeat threshold in minutes")
)) as HTMLInputElement;

expect(thresholdInput.value).toBe("30");
const promptField = view.getByLabelText("Default heartbeat prompt") as HTMLTextAreaElement;
expect(promptField.placeholder).toBe(HEARTBEAT_DEFAULT_MESSAGE_BODY);
});

test("loads and saves the default heartbeat prompt", async () => {
const initialPrompt = "Review pending work before acting.";
const { view, updateHeartbeatDefaultPromptMock } = renderHeartbeatSection({
heartbeatDefaultPrompt: initialPrompt,
});

const promptField = (await waitFor(() =>
view.getByLabelText("Default heartbeat prompt")
)) as HTMLTextAreaElement;

expect(promptField.value).toBe(initialPrompt);

fireEvent.blur(promptField);

await waitFor(() => {
expect(updateHeartbeatDefaultPromptMock.mock.calls[0]?.[0]).toEqual({
defaultPrompt: initialPrompt,
});
});
});

test("skips saving a stale prompt after config reload fails until the user edits", async () => {
const initialPrompt = "Review pending work before acting.";
const { view } = renderHeartbeatSection({
heartbeatDefaultPrompt: initialPrompt,
});

const promptField = (await waitFor(() =>
view.getByLabelText("Default heartbeat prompt")
)) as HTMLTextAreaElement;

await waitFor(() => {
expect(promptField.value).toBe(initialPrompt);
});

const failedReload = createMockAPI({}, { getConfigError: new Error("load failed") });
mockApi = failedReload.api;
view.rerender(
<ThemeProvider forcedTheme="dark">
<HeartbeatSection />
</ThemeProvider>
);

await waitFor(() => {
expect(promptField.value).toBe(initialPrompt);
});

const failedReloadPromptField = view.getByLabelText(
"Default heartbeat prompt"
) as HTMLTextAreaElement;

fireEvent.blur(failedReloadPromptField);
await Promise.resolve();
await Promise.resolve();
expect(failedReload.updateHeartbeatDefaultPromptMock).not.toHaveBeenCalled();
});

test("loads and saves the default heartbeat threshold", async () => {
const initialIntervalMs = 45 * 60_000;
const { view, updateHeartbeatDefaultIntervalMsMock } = renderHeartbeatSection({
heartbeatDefaultIntervalMs: initialIntervalMs,
});

const thresholdInput = (await waitFor(() =>
view.getByLabelText("Default heartbeat threshold in minutes")
)) as HTMLInputElement;

expect(thresholdInput.value).toBe("45");

fireEvent.blur(thresholdInput);

await waitFor(() => {
expect(updateHeartbeatDefaultIntervalMsMock.mock.calls[0]?.[0]).toEqual({
intervalMs: initialIntervalMs,
});
});
});
});
Loading
Loading