64017c6ca5
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).
116 lines
3.9 KiB
TypeScript
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);
|
|
});
|
|
});
|