summaryrefslogtreecommitdiffhomepage
path: root/src/features/heartbeat/ui/PromptEditor.test.ts
blob: c4bd3b831ee3ce8e662a5b2958e8eca91b9f6e61 (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
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type {
  HeartbeatConfigPatch,
  HeartbeatConfigResult,
  SaveHeartbeatConfig,
} from "../logic/types";
import PromptEditor from "./PromptEditor.svelte";

// Fakes for the injected ports.

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

function fakeLoadDefaultPrompt(template = "You are a helpful assistant.") {
  return vi.fn(async () => ({ ok: true, template }) as const);
}

/** A capturing saveConfig that resolves ok, echoing the merged config shape. */
function fakeSaveConfig(): {
  calls: HeartbeatConfigPatch[];
  impl: SaveHeartbeatConfig;
} {
  const calls: HeartbeatConfigPatch[] = [];
  const impl: SaveHeartbeatConfig = async (patch) => {
    calls.push(patch);
    // Echo a config that reflects the persisted patch (so onSaved sync is realistic).
    const config = {
      enabled: false,
      inactiveOnly: true,
      systemPrompt: patch.systemPrompt ?? "",
      taskPrompt: patch.taskPrompt ?? "",
      intervalMinutes: 30,
      model: "openai/gpt-4o",
      reasoningEffort: null,
    };
    return { ok: true, config } satisfies HeartbeatConfigResult;
  };
  return { calls, impl };
}

const baseProps = (overrides: Record<string, unknown> = {}) => ({
  systemPrompt: "",
  taskPrompt: "",
  loadVariables: fakeLoadVariables(),
  loadDefaultPrompt: fakeLoadDefaultPrompt(),
  saveConfig: fakeSaveConfig().impl,
  onSaved: vi.fn(),
  onClose: vi.fn(),
  ...overrides,
});

describe("PromptEditor save flow", () => {
  it("persists an edited system prompt and clears the unsaved state (regression: save flickered + reverted)", async () => {
    const user = userEvent.setup();
    const save = fakeSaveConfig();
    const onSaved = vi.fn();
    render(PromptEditor, {
      props: baseProps({
        // Start inheriting (empty override); the default pre-fills.
        systemPrompt: "",
        saveConfig: save.impl,
        onSaved,
      }),
    });

    // Wait for the default to load + pre-fill the system textarea.
    const systemBox = await screen.findByLabelText("Heartbeat system prompt");
    expect(systemBox).toHaveValue("You are a helpful assistant.");

    // Save is disabled while it matches the default (no explicit edit).
    expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();

    // Edit the system prompt → an override.
    await user.clear(systemBox);
    await user.type(systemBox, "custom override");

    // Save is now enabled.
    const saveBtn = screen.getByRole("button", { name: "Save" });
    expect(saveBtn).toBeEnabled();
    await user.click(saveBtn);

    // The save port was called with the override persisted verbatim.
    expect(save.calls).toHaveLength(1);
    expect(save.calls[0]?.systemPrompt).toBe("custom override");
    expect(onSaved).toHaveBeenCalledWith("custom override", "");

    // THE REGRESSION: after save, hasChanges must clear (Save disabled again)
    // and the "Saved." confirmation shows — NOT "Unsaved changes".
    await vi.waitFor(() => {
      expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
    });
    expect(screen.getByText("Saved.")).toBeInTheDocument();
    expect(screen.queryByText(/Unsaved changes/i)).not.toBeInTheDocument();
  });

  it("persisting text that matches the default sends '' (inherit) and clears unsaved state", async () => {
    const user = userEvent.setup();
    const save = fakeSaveConfig();
    render(PromptEditor, {
      props: baseProps({
        // Start with an override.
        systemPrompt: "old override",
        saveConfig: save.impl,
      }),
    });

    const systemBox = await screen.findByLabelText("Heartbeat system prompt");
    expect(systemBox).toHaveValue("old override");

    // Reset to default → text matches the default → saving inherits ("").
    await user.click(screen.getByRole("button", { name: "Reset to default" }));
    expect(systemBox).toHaveValue("You are a helpful assistant.");

    const saveBtn = screen.getByRole("button", { name: "Save" });
    expect(saveBtn).toBeEnabled();
    await user.click(saveBtn);

    expect(save.calls).toHaveLength(1);
    expect(save.calls[0]?.systemPrompt).toBe(""); // inherit
    await vi.waitFor(() => {
      expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
    });
    expect(screen.getByText("Saved.")).toBeInTheDocument();
  });

  it("editing the task prompt saves + clears unsaved state", async () => {
    const user = userEvent.setup();
    const save = fakeSaveConfig();
    render(PromptEditor, {
      props: baseProps({ saveConfig: save.impl }),
    });

    const taskBox = await screen.findByLabelText("Heartbeat task prompt");
    await user.type(taskBox, "do the thing");

    const saveBtn = screen.getByRole("button", { name: "Save" });
    expect(saveBtn).toBeEnabled();
    await user.click(saveBtn);

    expect(save.calls[0]?.taskPrompt).toBe("do the thing");
    await vi.waitFor(() => {
      expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
    });
    expect(screen.getByText("Saved.")).toBeInTheDocument();
  });

  it("a failed save surfaces the error and keeps the edit unsaved", async () => {
    const user = userEvent.setup();
    const failingSave: SaveHeartbeatConfig = async () => ({ ok: false, error: "boom" });
    render(PromptEditor, {
      props: baseProps({ saveConfig: failingSave }),
    });

    const systemBox = await screen.findByLabelText("Heartbeat system prompt");
    await user.clear(systemBox);
    await user.type(systemBox, "custom");

    await user.click(screen.getByRole("button", { name: "Save" }));

    expect(screen.getByText("boom")).toBeInTheDocument();
    // Still unsaved (Save stays enabled), no success badge.
    expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
    expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
  });
});