d0535af626
/workspace?lane=<id> opens on the workspace's home (WorkspaceHome): a prompt box whose Start opens a new session in the first free pane slot, Recents (a live session attaches its pane, any other opens its transcript, nothing resumes), and Instructions / Memory / Context / Folder. Instructions live in lanes.instructions, extra folders and uploaded files in the new lane_sources table (copies under <data dir>/workspace-context/<lane id>/); spawnRun reads them from the lane's rows and hands Claude --add-dir + an appended system prompt file, Codex --add-dir + developer_instructions. Memory is read-only. Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run records which session the pane holds (a Claude pane follows /clear and /resume but not a nested claude -p), so the sidebar and notifications open the right pane (&run=). Settings loses its Projects group; Worktrees now decides which worktrees Add folder suggests.
185 lines
6.4 KiB
TypeScript
185 lines
6.4 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. Also: a shown notification plays its type's sound, else the
|
|
* default one, and "none" plays nothing.
|
|
* @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("plays the type's own sound, else the default, and nothing for none", async () => {
|
|
const played: string[] = [];
|
|
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (
|
|
this: HTMLMediaElement
|
|
) {
|
|
played.push(this.src.split("/").pop()!);
|
|
return Promise.resolve();
|
|
});
|
|
// The hook's own lane list must read as a list, or a later event finds none.
|
|
fetchMock.mockImplementation(async (url: string) => ({
|
|
ok: true,
|
|
json: async () =>
|
|
url === "/api/push/send"
|
|
? { ok: true, native: true, pushed: 1 }
|
|
: { lanes: [], counts: {} },
|
|
}));
|
|
enable({ sound: "chime", soundByType: { onTurnComplete: "pop", onAwaitingInput: "none" } });
|
|
renderHook(() => useNotifications());
|
|
|
|
emit("Stop", "Done");
|
|
await vi.waitFor(() => expect(played).toEqual(["pop.wav"]));
|
|
emit("Notification", "needs permission");
|
|
await vi.waitFor(() => expect(sentTitles()).toHaveLength(2));
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(played).toEqual(["pop.wav"]);
|
|
|
|
localStorage.setItem(
|
|
NOTIF_KEY,
|
|
JSON.stringify({
|
|
...defaultNotifPrefs,
|
|
enabled: true,
|
|
sound: "chime",
|
|
onSessionComplete: true,
|
|
})
|
|
);
|
|
emit("SessionEnd", "Closed");
|
|
await vi.waitFor(() => expect(played).toEqual(["pop.wav", "chime.wav"]));
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("links a notification to the pane that holds the session, in any slot", async () => {
|
|
fetchMock.mockImplementation(async (url: string) => ({
|
|
ok: true,
|
|
json: async () =>
|
|
url === "/api/push/send"
|
|
? { ok: true, native: true, pushed: 1 }
|
|
: url === "/api/run"
|
|
? {
|
|
items: [
|
|
{ id: "agenthub-lane-4-2", laneId: 4, status: "running", sessionId: "s-7" },
|
|
],
|
|
}
|
|
: { lanes: [], counts: {} },
|
|
}));
|
|
enable();
|
|
renderHook(() => useNotifications());
|
|
eventBus.publish({
|
|
type: "new_event",
|
|
timestamp: new Date().toISOString(),
|
|
data: { event_type: "Stop", summary: "Done", session_id: "s-7" } as DashboardEvent,
|
|
});
|
|
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
|
|
const push = fetchMock.mock.calls.find((c) => c[0] === "/api/push/send")!;
|
|
expect(JSON.parse((push[1] as RequestInit).body as string).url).toBe(
|
|
"/workspace?lane=4&run=agenthub-lane-4-2"
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|