summaryrefslogtreecommitdiffhomepage
path: root/src/features/heartbeat/ui/HeartbeatView.test.ts
blob: 89eeee315b38f37c91bea319c3e7d2d05d7a0c0d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { LoadSystemPrompt, LoadSystemPromptVariables } from "../../system-prompt";
import type {
  HeartbeatConfig,
  HeartbeatConfigPatch,
  HeartbeatConfigResult,
  HeartbeatNextRunResult,
  HeartbeatStopResult,
  LoadHeartbeatConfig,
  LoadHeartbeatNextRun,
  LoadHeartbeatRuns,
  SaveHeartbeatConfig,
  StopHeartbeatRun,
} from "../logic/types";
import HeartbeatView from "./HeartbeatView.svelte";

// ── Fakes for the injected ports ─────────────────────────────────────────────
// Only the OUTERMOST edges are faked (the save/load ports); no sibling module is
// mocked. Mirrors the PromptEditor test's fake-port pattern.

function makeConfig(over: Partial<HeartbeatConfig> = {}): HeartbeatConfig {
  return {
    enabled: false,
    inactiveOnly: true,
    systemPrompt: "",
    taskPrompt: "",
    intervalMinutes: 30,
    model: "",
    reasoningEffort: null,
    ...over,
  };
}

/** A capturing saveConfig that echoes a merged config (so the form re-seeds). */
function fakeSaveConfig(initial: HeartbeatConfig): {
  calls: HeartbeatConfigPatch[];
  impl: SaveHeartbeatConfig;
} {
  const calls: HeartbeatConfigPatch[] = [];
  let current = initial;
  const impl: SaveHeartbeatConfig = async (patch) => {
    calls.push(patch);
    // Echo the merged config so the component re-seeds from the server response.
    current = { ...current, ...patch };
    return { ok: true, config: current } satisfies HeartbeatConfigResult;
  };
  return { calls, impl };
}

function fakeLoadConfig(config: HeartbeatConfig): LoadHeartbeatConfig {
  return vi.fn(async () => ({ ok: true, config }) as const);
}

function fakeLoadRuns(): LoadHeartbeatRuns {
  return vi.fn(async () => ({ ok: true, runs: [] }) as const);
}

function fakeStopRun(): StopHeartbeatRun {
  return vi.fn(async () => ({ ok: true }) as const satisfies HeartbeatStopResult);
}

function fakeLoadNextRun(): LoadHeartbeatNextRun {
  // No scheduled run (heartbeat disabled in the default config) → no countdown.
  return vi.fn(
    async () => ({ ok: true, nextRunAt: null }) as const satisfies HeartbeatNextRunResult,
  );
}

function fakeLoadVariables(): LoadSystemPromptVariables {
  return vi.fn(async () => ({ ok: true, variables: [] }) as const);
}

function fakeLoadDefaultPrompt(): LoadSystemPrompt {
  return vi.fn(async () => ({ ok: true, template: "" }) as const);
}

const baseProps = (overrides: Record<string, unknown> = {}) => ({
  models: [] as readonly string[],
  loadConfig: fakeLoadConfig(makeConfig()),
  saveConfig: fakeSaveConfig(makeConfig()).impl,
  loadVariables: fakeLoadVariables(),
  loadDefaultPrompt: fakeLoadDefaultPrompt(),
  loadRuns: fakeLoadRuns(),
  stopRun: fakeStopRun(),
  loadNextRun: fakeLoadNextRun(),
  onOpenRun: vi.fn(),
  ...overrides,
});

// HeartbeatView sets up polling intervals (runs + next-run + clock) on mount.
// Clear any stray timers between tests so a later test never hangs on a leaked
// interval (the $effect cleanup clears them on unmount; this is belt+suspenders).
afterEach(() => {
  vi.clearAllTimers();
});

describe("HeartbeatView — inactive-only checkbox", () => {
  it("renders checked when the loaded config has inactiveOnly: true (the default)", async () => {
    const loadConfig = fakeLoadConfig(makeConfig({ inactiveOnly: true }));
    render(HeartbeatView, {
      props: baseProps({ loadConfig }),
    });

    const checkbox = await screen.findByLabelText(
      "Only run the heartbeat when the workspace is idle",
    );
    expect(checkbox).toBeChecked();
  });

  it("renders unchecked when the loaded config has inactiveOnly: false", async () => {
    const loadConfig = fakeLoadConfig(makeConfig({ inactiveOnly: false }));
    render(HeartbeatView, {
      props: baseProps({ loadConfig }),
    });

    const checkbox = await screen.findByLabelText(
      "Only run the heartbeat when the workspace is idle",
    );
    expect(checkbox).not.toBeChecked();
  });

  it("toggling the checkbox persists a PARTIAL patch { inactiveOnly } and re-seeds", async () => {
    const user = userEvent.setup();
    const initial = makeConfig({ inactiveOnly: true });
    const save = fakeSaveConfig(initial);
    const loadConfig = fakeLoadConfig(initial);
    render(HeartbeatView, {
      props: baseProps({ loadConfig, saveConfig: save.impl }),
    });

    const checkbox = await screen.findByLabelText(
      "Only run the heartbeat when the workspace is idle",
    );
    expect(checkbox).toBeChecked();

    await user.click(checkbox);

    // The save port was called with ONLY { inactiveOnly: false } — a partial
    // update, not the whole config (mirrors the enable toggle's partial PUT).
    await vi.waitFor(() => {
      expect(save.calls).toHaveLength(1);
    });
    expect(save.calls[0]).toEqual({ inactiveOnly: false });

    // After the save resolves, the checkbox reflects the server response (unchecked).
    await vi.waitFor(() => {
      expect(checkbox).not.toBeChecked();
    });
  });

  it("toggling back on sends { inactiveOnly: true }", async () => {
    const user = userEvent.setup();
    const initial = makeConfig({ inactiveOnly: false });
    const save = fakeSaveConfig(initial);
    const loadConfig = fakeLoadConfig(initial);
    render(HeartbeatView, {
      props: baseProps({ loadConfig, saveConfig: save.impl }),
    });

    const checkbox = await screen.findByLabelText(
      "Only run the heartbeat when the workspace is idle",
    );
    expect(checkbox).not.toBeChecked();

    await user.click(checkbox);

    await vi.waitFor(() => {
      expect(save.calls).toHaveLength(1);
    });
    expect(save.calls[0]).toEqual({ inactiveOnly: true });
    await vi.waitFor(() => {
      expect(checkbox).toBeChecked();
    });
  });

  it("a failed save reverts the checkbox to the last-known state", async () => {
    const user = userEvent.setup();
    const initial = makeConfig({ inactiveOnly: true });
    const failingSave: SaveHeartbeatConfig = async () => ({
      ok: false,
      error: "boom",
    });
    const loadConfig = fakeLoadConfig(initial);
    render(HeartbeatView, {
      props: baseProps({ loadConfig, saveConfig: failingSave }),
    });

    const checkbox = await screen.findByLabelText(
      "Only run the heartbeat when the workspace is idle",
    );
    expect(checkbox).toBeChecked();

    await user.click(checkbox);

    // The failed save surfaces the error AND reverts the checkbox (stays checked).
    await vi.waitFor(() => {
      expect(screen.getByText("boom")).toBeInTheDocument();
    });
    expect(checkbox).toBeChecked();
  });
});