Files
AgentHub/client/src/hooks/__tests__/useNotifications.test.ts
T
nntrivi2001 64017c6ca5 feat(run,notifications,profiles): stop sending prompts nobody typed, and fix notification + profile delivery
Run console:
- Picking a pipeline no longer pre-writes anything into the prompt box
  (`promptPrefill` in `LaneConsolePane.tsx` is gone — it wrote the driving
  slash command on a claude lane and a stage checklist on a codex one).
  Whatever sits in that box is sent as a real prompt the moment the run
  starts, so the box is the user's alone now.
- Resuming a session sends NO prompt at all (`handleStart` in `RunSetup.tsx`):
  the resumed session carries its own transcript, and leftover text in the box
  fired an extra turn into it.

Notifications:
- `client/src/lib/notify.ts` is now the ONE sender, used by both
  `useNotifications` and Settings' test button, and it reports where the
  notification landed: `POST /api/push/send` answering `{native:false,pushed:0}`
  means it went NOWHERE, so the client falls back to a local notification
  instead of counting the 200 as delivered.
- A notification carries the lane that raised it (`/workspace?lane=<id>`), and
  Workspace selects that lane on arrival.

Profiles:
- `server/lib/cc-profiles.js` reads and writes the macOS login Keychain item
  `Claude Code-credentials`, where the `claude` CLI actually keeps
  `claudeAiOauth` on darwin — a switch used to rewrite a file nothing reads.
  The file stays the fallback, is kept in step only when it already exists, and
  `AGENTHUB_CC_KEYCHAIN=0` pins it (tests set it).

Tests: npm run test:server (1820 pass), client vitest (553 pass).
2026-09-21 15:15:22 +07:00

116 lines
3.9 KiB
TypeScript

/**
* @file Unit tests for useNotifications — which websocket events raise a
* browser notification, and which preference gates each one. Covers issue #29:
* "Claude needs confirmation" and "Claude finished a turn" must fire out of the
* box once notifications are enabled, since those are the two moments the user
* is the blocker.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useNotifications } from "../useNotifications";
import { eventBus } from "../../lib/eventBus";
import { NOTIF_KEY, defaultNotifPrefs, type NotifPrefs } from "../../lib/notifPrefs";
import type { DashboardEvent } from "../../lib/types";
function enable(patch: Partial<NotifPrefs> = {}) {
localStorage.setItem(
NOTIF_KEY,
JSON.stringify({ ...defaultNotifPrefs, enabled: true, ...patch })
);
}
function emit(event_type: string, summary: string) {
eventBus.publish({
type: "new_event",
timestamp: new Date().toISOString(),
data: { event_type, summary } as DashboardEvent,
});
}
let fetchMock: ReturnType<typeof vi.fn>;
describe("useNotifications", () => {
beforeEach(() => {
localStorage.clear();
// notify() posts to /api/push/send first and only falls back to a local
// Notification when that throws, so the fetch spy is what we assert on.
fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: async () => ({ ok: true, native: true, pushed: 1 }) });
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal(
"Notification",
Object.assign(vi.fn(), { permission: "granted" }) as unknown as typeof Notification
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function sentTitles() {
return fetchMock.mock.calls
.filter((c) => c[0] === "/api/push/send")
.map((c) => JSON.parse((c[1] as RequestInit).body as string).title as string);
}
it("notifies on a confirmation request and on the end of a turn by default", async () => {
enable();
renderHook(() => useNotifications());
emit("Notification", "Claude needs your permission to use Bash");
emit("Stop", "Done");
await vi.waitFor(() => expect(sentTitles()).toHaveLength(2));
expect(sentTitles()[0]).toBe("Claude needs your confirmation");
expect(sentTitles()[1]).toBe("Claude Finished Responding");
});
it("respects each gate independently", async () => {
enable({ onAwaitingInput: false });
renderHook(() => useNotifications());
emit("Notification", "needs permission");
emit("Stop", "Done");
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
expect(sentTitles()[0]).toBe("Claude Finished Responding");
});
it("shows a local notification when the push landed nowhere", async () => {
// What a browser with no push subscription gets back: HTTP 200, zero
// deliveries. Before this, the 200 alone counted as success and the user
// saw nothing at all.
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ ok: true, native: false, pushed: 0, failed: 0 }),
});
enable();
renderHook(() => useNotifications());
emit("Stop", "Done");
await vi.waitFor(() => expect(Notification).toHaveBeenCalledTimes(1));
expect(vi.mocked(Notification).mock.calls[0]?.[0]).toBe("Claude Finished Responding");
});
it("does not double up when the push was delivered", async () => {
enable();
renderHook(() => useNotifications());
emit("Stop", "Done");
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
await new Promise((r) => setTimeout(r, 20));
expect(Notification).not.toHaveBeenCalled();
});
it("stays silent while notifications are disabled", async () => {
renderHook(() => useNotifications());
emit("Notification", "needs permission");
emit("Stop", "Done");
await new Promise((r) => setTimeout(r, 20));
expect(sentTitles()).toHaveLength(0);
});
});