diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 11:44:27 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 11:44:27 +0900 |
| commit | 0a5eea4c06371df756aea40f53bb6dbe71df664a (patch) | |
| tree | 443e454e1edf1814f1a5c8e77507f63812739122 /packages/core/tests | |
| parent | 00922f6136ff0c6e047bb4a6165682f236971450 (diff) | |
| parent | 03e58f69e77b7a27e235210158f3f8e499a817c3 (diff) | |
| download | dispatch-0a5eea4c06371df756aea40f53bb6dbe71df664a.tar.gz dispatch-0a5eea4c06371df756aea40f53bb6dbe71df664a.zip | |
merge: dev into r1/claude-reset-fix
Brings in the n2/ntfy-notifications feature (ntfy.sh push notifications
with per-event toggles, subagent-suppression flag, topic-only input,
Settings UI, dispatcher + transport + config modules, 12+ new tests),
the header declutter (theme picker + Debug panel moved into Settings /
sidebar), the shared theme boot-apply module, and an a11y label for the
remove-panel button.
No code changes from this branch were touched by the merge — the
overlap was purely textual.
Conflict resolution:
1. HANDOFF.md (add/add conflict). Both branches independently put a
single-purpose HANDOFF.md at the repo root for their respective
in-flight feature, matching the existing convention (c351719 did
the same for this branch; 29bdd00 did the same for ntfy). After
this merge both features ship, so neither is in-flight anymore.
Archive both into notes/:
- notes/wake-schedule-handoff.md (this branch — git tracks as a
rename from HANDOFF.md)
- notes/ntfy-notifications-handoff.md (dev — recovered from
MERGE_HEAD before deletion)
The root HANDOFF.md is intentionally absent post-merge; the next
in-flight branch will create its own.
2. packages/api/tests/routes.test.ts (auto-merged). dev appended ntfy
stubs to the vi.mock('@dispatch/core', ...) factory; this branch
appended a 'Wake schedule routes' describe block at the bottom.
The two regions don't overlap and the textual auto-merge is correct
(verified: 6 describe blocks, both mock-stub regions and the new
describe present, no conflict markers).
Verification on the merge commit:
bun run test → 31 files, 495 / 495 passing
(was 431 on the branch + 64 from dev)
bun run check → biome clean, 156 files
bun run --cwd packages/frontend typecheck
→ svelte-check 0 errors, 0 warnings
dev can now fast-forward to this commit:
git checkout dev && git merge --ff-only r1/claude-reset-fix
Diffstat (limited to 'packages/core/tests')
| -rw-r--r-- | packages/core/tests/notifications/config.test.ts | 158 | ||||
| -rw-r--r-- | packages/core/tests/notifications/dispatcher.test.ts | 461 | ||||
| -rw-r--r-- | packages/core/tests/notifications/ntfy.test.ts | 204 |
3 files changed, 823 insertions, 0 deletions
diff --git a/packages/core/tests/notifications/config.test.ts b/packages/core/tests/notifications/config.test.ts new file mode 100644 index 0000000..71dc00c --- /dev/null +++ b/packages/core/tests/notifications/config.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// In-memory fake for the settings table — mounted before the module under +// test is imported (vi.mock is hoisted). +const fakeSettings = new Map<string, string>(); + +vi.mock("../../src/db/settings.js", () => ({ + getSetting: vi.fn((key: string) => fakeSettings.get(key) ?? null), + setSetting: vi.fn((key: string, value: string) => { + fakeSettings.set(key, value); + }), + deleteSetting: vi.fn((key: string) => { + fakeSettings.delete(key); + }), +})); + +const { + clearNtfyConfig, + defaultNtfyConfig, + loadNtfyConfig, + normalizeNtfyConfig, + NTFY_CONFIG_KEY, + redactNtfyConfig, + saveNtfyConfig, +} = await import("../../src/notifications/config.js"); + +describe("defaultNtfyConfig", () => { + it("disables notifications and ships sane per-event defaults", () => { + const cfg = defaultNtfyConfig(); + expect(cfg.enabled).toBe(false); + expect(cfg.topic).toBe(""); + expect(cfg.authToken).toBe(""); + expect(cfg.events["turn-completed"]).toBe(true); + expect(cfg.events["turn-error"]).toBe(true); + expect(cfg.events["permission-required"]).toBe(true); + expect(cfg.events["agent-spawned"]).toBe(false); + expect(cfg.notifySubagents).toBe(false); + }); +}); + +describe("normalizeNtfyConfig", () => { + it("returns defaults for non-object input", () => { + expect(normalizeNtfyConfig(null)).toEqual(defaultNtfyConfig()); + expect(normalizeNtfyConfig(undefined)).toEqual(defaultNtfyConfig()); + expect(normalizeNtfyConfig(42)).toEqual(defaultNtfyConfig()); + }); + + it("fills in missing event toggles with defaults (newly-added types default OFF)", () => { + const normalized = normalizeNtfyConfig({ + enabled: true, + topic: "https://ntfy.sh/x", + events: { "turn-completed": false }, + }); + expect(normalized.events["turn-completed"]).toBe(false); + // Defaults preserved for fields the persisted blob doesn't have. + expect(normalized.events["turn-error"]).toBe(true); + expect(normalized.events["agent-spawned"]).toBe(false); + }); + + it("ignores extraneous fields and wrong-typed values", () => { + const normalized = normalizeNtfyConfig({ + enabled: "yes", // wrong type ⇒ default + topic: 42, // wrong type ⇒ default + authToken: null, // wrong type ⇒ default + events: { "turn-completed": "no", bogus: true }, + extra: "ignored", + }); + expect(normalized.enabled).toBe(false); + expect(normalized.topic).toBe(""); + expect(normalized.authToken).toBe(""); + expect(normalized.events["turn-completed"]).toBe(true); // default kept + expect((normalized.events as Record<string, boolean>).bogus).toBeUndefined(); + }); +}); + +describe("normalizeNtfyConfig — notifySubagents", () => { + it("defaults notifySubagents to false when absent", () => { + const normalized = normalizeNtfyConfig({ + enabled: true, + topic: "https://ntfy.sh/x", + }); + expect(normalized.notifySubagents).toBe(false); + }); + + it("respects an explicit notifySubagents=true", () => { + const normalized = normalizeNtfyConfig({ + enabled: true, + topic: "https://ntfy.sh/x", + notifySubagents: true, + }); + expect(normalized.notifySubagents).toBe(true); + }); + + it("falls back to default when notifySubagents is wrong-typed", () => { + const normalized = normalizeNtfyConfig({ + enabled: true, + topic: "https://ntfy.sh/x", + notifySubagents: "yes" as unknown, + }); + expect(normalized.notifySubagents).toBe(false); + }); +}); + +describe("load/save round-trip", () => { + beforeEach(() => { + fakeSettings.clear(); + }); + + it("returns defaults when nothing is persisted", () => { + expect(loadNtfyConfig()).toEqual(defaultNtfyConfig()); + }); + + it("round-trips a complete config", () => { + const cfg = { + enabled: true, + topic: "https://ntfy.sh/team", + authToken: "tk_abc", + events: { + "turn-completed": false, + "turn-error": true, + "permission-required": true, + "agent-spawned": true, + }, + notifySubagents: true, + } as const; + saveNtfyConfig({ ...cfg }); + const loaded = loadNtfyConfig(); + expect(loaded).toEqual(cfg); + // Persisted as a JSON string under the documented key. + expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true); + }); + + it("returns defaults when stored JSON is corrupt", () => { + fakeSettings.set(NTFY_CONFIG_KEY, "{ not json"); + expect(loadNtfyConfig()).toEqual(defaultNtfyConfig()); + }); + + it("clearNtfyConfig removes the persisted entry", () => { + saveNtfyConfig({ ...defaultNtfyConfig(), enabled: true, topic: "https://ntfy.sh/x" }); + expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true); + clearNtfyConfig(); + expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(false); + }); +}); + +describe("redactNtfyConfig", () => { + it("strips authToken and surfaces a hasAuthToken flag", () => { + const cfg = { ...defaultNtfyConfig(), authToken: "tk_secret" }; + const redacted = redactNtfyConfig(cfg); + expect(redacted.authToken).toBe(""); + expect(redacted.hasAuthToken).toBe(true); + }); + + it("hasAuthToken is false for blank tokens", () => { + expect(redactNtfyConfig({ ...defaultNtfyConfig(), authToken: "" }).hasAuthToken).toBe(false); + expect(redactNtfyConfig({ ...defaultNtfyConfig(), authToken: " " }).hasAuthToken).toBe(false); + }); +}); diff --git a/packages/core/tests/notifications/dispatcher.test.ts b/packages/core/tests/notifications/dispatcher.test.ts new file mode 100644 index 0000000..c2faba6 --- /dev/null +++ b/packages/core/tests/notifications/dispatcher.test.ts @@ -0,0 +1,461 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js"; + +// The dispatcher imports `loadNtfyConfig` from config.ts, which transitively +// pulls in `db/index.js` (bun:sqlite). Stub the DB so vitest under Node can +// load this file. All tests inject `loadConfig` explicitly, so the real +// settings table is never read. +vi.mock("../../src/db/index.js", () => ({ + getDatabase: vi.fn(() => ({ + query: () => ({ get: () => null, run: () => {} }), + run: () => {}, + })), +})); + +const { NotificationDispatcher } = await import("../../src/notifications/dispatcher.js"); + +function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig { + return { + enabled: true, + topic: "test-topic", + authToken: "", + events: { + "turn-completed": true, + "turn-error": true, + "permission-required": true, + "agent-spawned": true, + }, + // Default to true in the test config so existing tests (which never + // configure a getTabParentId lookup) keep firing for tab-1 / tab-2 / etc. + // Tests of the new subagent gating override this explicitly. + notifySubagents: true, + ...overrides, + }; +} + +interface FakeAgentSource { + onEvent( + listener: (event: { type: string; tabId: string; [k: string]: unknown }) => void, + ): () => void; + emit(event: { type: string; tabId: string; [k: string]: unknown }): void; +} + +function makeAgentSource(): FakeAgentSource { + let l: ((event: { type: string; tabId: string; [k: string]: unknown }) => void) | null = null; + return { + onEvent(listener) { + l = listener; + return () => { + l = null; + }; + }, + emit(event) { + l?.(event); + }, + }; +} + +interface FakePermissionSource { + onPromptAdded( + listener: (prompt: { id: string; permission: string; description: string }) => void, + ): () => void; + emit(prompt: { id: string; permission: string; description: string }): void; +} + +function makePermissionSource(): FakePermissionSource { + let l: ((prompt: { id: string; permission: string; description: string }) => void) | null = null; + return { + onPromptAdded(listener) { + l = listener; + return () => { + l = null; + }; + }, + emit(p) { + l?.(p); + }, + }; +} + +// Microtask flush so the dispatcher's `void Promise.resolve(...).catch(...)` +// has a chance to settle before assertions. +async function flush(): Promise<void> { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("NotificationDispatcher.notify", () => { + let warnSpy: ReturnType<typeof vi.spyOn>; + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("does not send when master switch is disabled", async () => { + const send = vi.fn(async () => ({ ok: true })); + const d = new NotificationDispatcher({ + loadConfig: () => makeConfig({ enabled: false }), + send, + }); + d.notify({ type: "turn-completed", title: "x", message: "y" }); + await flush(); + expect(send).not.toHaveBeenCalled(); + }); + + it("does not send when per-event-type toggle is off", async () => { + const send = vi.fn(async () => ({ ok: true })); + const d = new NotificationDispatcher({ + loadConfig: () => + makeConfig({ + events: { + "turn-completed": false, + "turn-error": true, + "permission-required": true, + "agent-spawned": false, + }, + }), + send, + }); + d.notify({ type: "turn-completed", title: "x", message: "y" }); + await flush(); + expect(send).not.toHaveBeenCalled(); + }); + + it("sends when enabled and toggle is on", async () => { + const send = vi.fn(async () => ({ ok: true })); + const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); + d.notify({ type: "turn-completed", title: "x", message: "y" }); + await flush(); + expect(send).toHaveBeenCalledTimes(1); + }); + + it("does not throw or block when the transport rejects", async () => { + const send = vi.fn(async () => { + throw new Error("boom"); + }); + const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); + expect(() => d.notify({ type: "turn-completed", title: "x", message: "y" })).not.toThrow(); + await flush(); + expect(send).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalled(); + }); + + it("dedupes events with the same dedupeKey within the window", async () => { + const send = vi.fn(async () => ({ ok: true })); + const d = new NotificationDispatcher({ + loadConfig: () => makeConfig(), + send, + dedupeWindowMs: 1000, + }); + const event: NotificationEvent = { + type: "permission-required", + title: "p", + message: "p", + dedupeKey: "permission:42", + }; + d.notify(event); + d.notify(event); + d.notify(event); + await flush(); + expect(send).toHaveBeenCalledTimes(1); + }); + + it("does not dedupe events without a dedupeKey", async () => { + const send = vi.fn(async () => ({ ok: true })); + const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); + d.notify({ type: "turn-completed", title: "x", message: "y" }); + d.notify({ type: "turn-completed", title: "x", message: "y" }); + await flush(); + expect(send).toHaveBeenCalledTimes(2); + }); +}); + +describe("NotificationDispatcher.attachToAgentManager", () => { + let warnSpy: ReturnType<typeof vi.spyOn>; + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("maps `done` → turn-completed (with tab title in the body)", async () => { + const send = vi.fn(async () => ({ ok: true })); + const source = makeAgentSource(); + const d = new NotificationDispatcher({ + loadConfig: () => makeConfig(), + send, + getTabTitle: (id) => (id === "tab-1" ? "My chat" : null), + }); + d.attachToAgentManager(source); + source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } }); + await flush(); + expect(send).toHaveBeenCalledTimes(1); + const event = send.mock.calls[0][1] as NotificationEvent; + expect(event.type).toBe("turn-completed"); + expect(event.title).toContain("My chat"); + expect(event.tabId).toBe("tab-1"); + }); + + it("maps `error` → turn-error and includes the error text", async () => { + const send = vi.fn(async () => ({ ok: true })); + const source = makeAgentSource(); + const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); + d.attachToAgentManager(source); + source.emit({ type: "error", tabId: "tab-1", error: "Rate limit", statusCode: 429 }); + await flush(); + expect(send).toHaveBeenCalledTimes(1); + const event = send.mock.calls[0][1] as NotificationEvent; + expect(event.type).toBe("turn-error"); + expect(event.message).toContain("Rate limit"); + expect(event.message).toContain("429"); + }); + + it("ignores `status` events (would spam every transition)", async () => { + const send = vi.fn(async () => ({ ok: true })); + const source = makeAgentSource(); + const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); + d.attachToAgentManager(source); + source.emit({ type: "status", tabId: "tab-1", status: "running" }); + source.emit({ type: "status", tabId: "tab-1", status: "idle" }); + await flush(); + expect(send).not.toHaveBeenCalled(); + }); + + it("maps `tab-created` to agent-spawned only for top-level user agents (parentTabId=null AND agentSlug set)", async () => { + const send = vi.fn(async () => ({ ok: true })); + const source = makeAgentSource(); + const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); + d.attachToAgentManager(source); + + // Manual "new tab" with no agent slug ⇒ no notification. + source.emit({ + type: "tab-created", + tabId: "tab-1", + id: "tab-1", + title: "New Tab", + parentTabId: null, + agentSlug: null, + }); + // Subagent (has a parent) ⇒ no notification. + source.emit({ + type: "tab-created", + tabId: "tab-2", + id: "tab-2", + title: "Subagent", + parentTabId: "tab-1", + agentSlug: "researcher", + }); + // Top-level user agent ⇒ notify. + source.emit({ + type: "tab-created", + tabId: "tab-3", + id: "tab-3", + title: "Refactor auth code", + parentTabId: null, + agentSlug: "engineer", + }); + await flush(); + expect(send).toHaveBeenCalledTimes(1); + const event = send.mock.calls[0][1] as NotificationEvent; + expect(event.type).toBe("agent-spawned"); + expect(event.message).toBe("Refactor auth code"); + expect(event.title).toContain("engineer"); + }); + + it("respects the per-event-type toggle (turn-completed off ⇒ silent)", async () => { + const send = vi.fn(async () => ({ ok: true })); + const source = makeAgentSource(); + const d = new NotificationDispatcher({ + loadConfig: () => + makeConfig({ + events: { + "turn-completed": false, + "turn-error": true, + "permission-required": true, + "agent-spawned": false, + }, + }), + send, + }); + d.attachToAgentManager(source); + source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } }); + await flush(); + expect(send).not.toHaveBeenCalled(); + }); +}); + +describe("NotificationDispatcher.attachToPermissionManager", () => { + let warnSpy: ReturnType<typeof vi.spyOn>; + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("notifies once per unique prompt id (dedupes re-emits)", async () => { + const send = vi.fn(async () => ({ ok: true })); + const source = makePermissionSource(); + const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); + d.attachToPermissionManager(source); + + source.emit({ id: "1", permission: "bash", description: "Run git status" }); + source.emit({ id: "1", permission: "bash", description: "Run git status" }); + source.emit({ id: "2", permission: "read", description: "Read /etc/hosts" }); + await flush(); + expect(send).toHaveBeenCalledTimes(2); + const events = send.mock.calls.map((c) => c[1] as NotificationEvent); + expect(events.map((e) => e.type)).toEqual(["permission-required", "permission-required"]); + expect(events.every((e) => e.dedupeKey?.startsWith("permission:"))).toBe(true); + }); +}); + +describe("NotificationDispatcher.dispose", () => { + it("releases attached subscriptions", async () => { + const send = vi.fn(async () => ({ ok: true })); + const source = makeAgentSource(); + const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); + d.attachToAgentManager(source); + d.dispose(); + source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } }); + await flush(); + expect(send).not.toHaveBeenCalled(); + }); +}); + +describe("NotificationDispatcher subagent suppression (notifySubagents flag)", () => { + let warnSpy: ReturnType<typeof vi.spyOn>; + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + }); + + const parents = new Map<string, string | null>([ + ["top-level", null], + ["subagent", "top-level"], + ]); + const getTabParentId = (id: string): string | null | undefined => parents.get(id); + + it("suppresses turn-completed from subagent tabs when notifySubagents=false (default)", async () => { + const send = vi.fn(async () => ({ ok: true })); + const source = makeAgentSource(); + const d = new NotificationDispatcher({ + loadConfig: () => makeConfig({ notifySubagents: false }), + send, + getTabParentId, + }); + d.attachToAgentManager(source); + + source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } }); + source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } }); + await flush(); + + expect(send).toHaveBeenCalledTimes(1); + expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level"); + }); + + it("suppresses turn-error from subagent tabs when notifySubagents=false", async () => { + const send = vi.fn(async () => ({ ok: true })); + const source = makeAgentSource(); + const d = new NotificationDispatcher({ + loadConfig: () => makeConfig({ notifySubagents: false }), + send, + getTabParentId, + }); + d.attachToAgentManager(source); + + source.emit({ type: "error", tabId: "subagent", error: "boom" }); + source.emit({ type: "error", tabId: "top-level", error: "boom" }); + await flush(); + + expect(send).toHaveBeenCalledTimes(1); + expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level"); + }); + + it("still notifies subagents when notifySubagents=true", async () => { + const send = vi.fn(async () => ({ ok: true })); + const source = makeAgentSource(); + const d = new NotificationDispatcher({ + loadConfig: () => makeConfig({ notifySubagents: true }), + send, + getTabParentId, + }); + d.attachToAgentManager(source); + + source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } }); + source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } }); + await flush(); + + expect(send).toHaveBeenCalledTimes(2); + }); + + it("does NOT gate permission-required (subagents must still get human input)", async () => { + const send = vi.fn(async () => ({ ok: true })); + const psource = makePermissionSource(); + const d = new NotificationDispatcher({ + loadConfig: () => makeConfig({ notifySubagents: false }), + send, + getTabParentId, + }); + d.attachToPermissionManager(psource); + + psource.emit({ id: "p1", permission: "bash", description: "git status" }); + await flush(); + + expect(send).toHaveBeenCalledTimes(1); + expect((send.mock.calls[0][1] as NotificationEvent).type).toBe("permission-required"); + }); + + it("falls back to notifying when getTabParentId is not provided (treat as top-level)", async () => { + const send = vi.fn(async () => ({ ok: true })); + const source = makeAgentSource(); + const d = new NotificationDispatcher({ + loadConfig: () => makeConfig({ notifySubagents: false }), + send, + // intentionally NO getTabParentId + }); + d.attachToAgentManager(source); + + source.emit({ type: "done", tabId: "anything", message: { role: "assistant", chunks: [] } }); + await flush(); + + // Without a lookup, the dispatcher can't prove this is a subagent; it + // must err on the side of notifying so legitimate top-level events + // aren't silently dropped. + expect(send).toHaveBeenCalledTimes(1); + }); + + it("falls back to notifying when getTabParentId throws or returns undefined", async () => { + const send = vi.fn(async () => ({ ok: true })); + const source = makeAgentSource(); + const d = new NotificationDispatcher({ + loadConfig: () => makeConfig({ notifySubagents: false }), + send, + getTabParentId: () => { + throw new Error("db unavailable"); + }, + }); + d.attachToAgentManager(source); + + source.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } }); + await flush(); + expect(send).toHaveBeenCalledTimes(1); + + const send2 = vi.fn(async () => ({ ok: true })); + const source2 = makeAgentSource(); + const d2 = new NotificationDispatcher({ + loadConfig: () => makeConfig({ notifySubagents: false }), + send: send2, + getTabParentId: () => undefined, + }); + d2.attachToAgentManager(source2); + source2.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } }); + await flush(); + expect(send2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/tests/notifications/ntfy.test.ts b/packages/core/tests/notifications/ntfy.test.ts new file mode 100644 index 0000000..5f14a60 --- /dev/null +++ b/packages/core/tests/notifications/ntfy.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildNtfyUrl, NTFY_BASE_URL, sendNtfy } from "../../src/notifications/ntfy.js"; +import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js"; + +function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig { + return { + enabled: true, + topic: "my-topic", + authToken: "", + events: { + "turn-completed": true, + "turn-error": true, + "permission-required": true, + "agent-spawned": true, + }, + notifySubagents: false, + ...overrides, + }; +} + +function makeEvent(overrides: Partial<NotificationEvent> = {}): NotificationEvent { + return { + type: "turn-completed", + title: "Done", + message: "all good", + ...overrides, + }; +} + +function makeFetch( + response: Partial<{ ok: boolean; status: number; statusText: string; body: string }> = {}, +) { + const fetchImpl = vi.fn(async () => ({ + ok: response.ok ?? true, + status: response.status ?? 200, + statusText: response.statusText ?? "OK", + text: async () => response.body ?? "", + })); + return fetchImpl; +} + +describe("buildNtfyUrl", () => { + it("prefixes the public ntfy.sh host", () => { + expect(buildNtfyUrl("my-topic")).toBe(`${NTFY_BASE_URL}/my-topic`); + }); + + it("trims surrounding whitespace", () => { + expect(buildNtfyUrl(" hello ")).toBe(`${NTFY_BASE_URL}/hello`); + }); + + it("URL-encodes the topic so any string yields a valid URL", () => { + // Spaces, slashes, unicode — all preserved as encoded bytes; the ntfy + // server is the final authority on what it accepts. + expect(buildNtfyUrl("has space")).toBe(`${NTFY_BASE_URL}/has%20space`); + expect(buildNtfyUrl("a/b")).toBe(`${NTFY_BASE_URL}/a%2Fb`); + expect(buildNtfyUrl("日本語")).toBe(`${NTFY_BASE_URL}/${encodeURIComponent("日本語")}`); + }); +}); + +describe("sendNtfy", () => { + it("POSTs to https://ntfy.sh/<topic> with Title/Priority/Tags/Content-Type headers and body", async () => { + const fetchImpl = makeFetch(); + const result = await sendNtfy( + makeConfig(), + makeEvent({ title: "Hello", message: "World", tags: ["bell"], priority: 4 }), + fetchImpl, + ); + expect(result.ok).toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe(`${NTFY_BASE_URL}/my-topic`); + expect(init.method).toBe("POST"); + expect(init.headers.Title).toBe("Hello"); + expect(init.headers.Priority).toBe("4"); + expect(init.headers.Tags).toBe("bell"); + expect(init.headers["Content-Type"]).toMatch(/text\/plain/); + expect(init.body).toBe("World"); + }); + + it("accepts arbitrary topic strings without a client-side pattern check", async () => { + const fetchImpl = makeFetch(); + // Things the old validator would have rejected — dots, spaces, unicode, + // a single-word "any topic". All should POST and let ntfy decide. + await sendNtfy(makeConfig({ topic: "release.notes" }), makeEvent(), fetchImpl); + await sendNtfy(makeConfig({ topic: "with space" }), makeEvent(), fetchImpl); + await sendNtfy(makeConfig({ topic: "Any Topic Whatsoever" }), makeEvent(), fetchImpl); + await sendNtfy(makeConfig({ topic: "日本語" }), makeEvent(), fetchImpl); + expect(fetchImpl).toHaveBeenCalledTimes(4); + expect(fetchImpl.mock.calls[0][0]).toBe(`${NTFY_BASE_URL}/release.notes`); + expect(fetchImpl.mock.calls[1][0]).toBe(`${NTFY_BASE_URL}/with%20space`); + expect(fetchImpl.mock.calls[2][0]).toBe(`${NTFY_BASE_URL}/Any%20Topic%20Whatsoever`); + expect(fetchImpl.mock.calls[3][0]).toBe(`${NTFY_BASE_URL}/${encodeURIComponent("日本語")}`); + }); + + it("uses per-event-type defaults for priority and tags", async () => { + const fetchImpl = makeFetch(); + await sendNtfy(makeConfig(), makeEvent({ type: "turn-error" }), fetchImpl); + const init = fetchImpl.mock.calls[0][1]; + expect(init.headers.Priority).toBe("4"); // NTFY_DEFAULT_PRIORITIES["turn-error"] + expect(init.headers.Tags).toBe("rotating_light"); + }); + + it("attaches Authorization header with Bearer prefix when authToken is a bare token", async () => { + const fetchImpl = makeFetch(); + await sendNtfy(makeConfig({ authToken: "tk_secret " }), makeEvent(), fetchImpl); + const init = fetchImpl.mock.calls[0][1]; + expect(init.headers.Authorization).toBe("Bearer tk_secret"); + }); + + it("passes a pre-prefixed Authorization value (Basic, custom schemes) through verbatim", async () => { + const fetchImpl = makeFetch(); + await sendNtfy(makeConfig({ authToken: "Basic dXNlcjpwYXNz" }), makeEvent(), fetchImpl); + expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe("Basic dXNlcjpwYXNz"); + + const fetchImpl2 = makeFetch(); + await sendNtfy(makeConfig({ authToken: "Bearer already_prefixed" }), makeEvent(), fetchImpl2); + expect(fetchImpl2.mock.calls[0][1].headers.Authorization).toBe("Bearer already_prefixed"); + }); + + it("omits Authorization when authToken is blank", async () => { + const fetchImpl = makeFetch(); + await sendNtfy(makeConfig({ authToken: " " }), makeEvent(), fetchImpl); + const init = fetchImpl.mock.calls[0][1]; + expect(init.headers.Authorization).toBeUndefined(); + }); + + it("attaches Click header when clickUrl is set", async () => { + const fetchImpl = makeFetch(); + await sendNtfy(makeConfig(), makeEvent({ clickUrl: "https://example.com/tab/abc" }), fetchImpl); + const init = fetchImpl.mock.calls[0][1]; + expect(init.headers.Click).toBe("https://example.com/tab/abc"); + }); + + it("sanitizes Click header (CR/LF injection guard)", async () => { + const fetchImpl = makeFetch(); + await sendNtfy( + makeConfig(), + makeEvent({ clickUrl: "https://example.com/\r\nInjected: yes" }), + fetchImpl, + ); + const v = fetchImpl.mock.calls[0][1].headers.Click; + expect(v).not.toContain("\n"); + expect(v).not.toContain("\r"); + }); + + it("appends short tab tag when tabId is set", async () => { + const fetchImpl = makeFetch(); + await sendNtfy( + makeConfig(), + makeEvent({ tabId: "abcdef0123456789", tags: ["bell"] }), + fetchImpl, + ); + const init = fetchImpl.mock.calls[0][1]; + expect(init.headers.Tags).toBe("bell,tab-abcdef01"); + }); + + it("strips CR/LF/control chars from header values (injection guard)", async () => { + const fetchImpl = makeFetch(); + await sendNtfy(makeConfig(), makeEvent({ title: "line1\r\nInjected: yes" }), fetchImpl); + const init = fetchImpl.mock.calls[0][1]; + expect(init.headers.Title).not.toContain("\n"); + expect(init.headers.Title).not.toContain("\r"); + expect(init.headers.Title).toBe("line1 Injected: yes"); + }); + + it("returns ok:false when notifications are disabled", async () => { + const fetchImpl = makeFetch(); + const result = await sendNtfy(makeConfig({ enabled: false }), makeEvent(), fetchImpl); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/disabled/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("returns ok:false when topic is empty / whitespace, without calling fetch", async () => { + const fetchImpl = makeFetch(); + const empty = await sendNtfy(makeConfig({ topic: "" }), makeEvent(), fetchImpl); + expect(empty.ok).toBe(false); + expect(empty.error).toMatch(/required/i); + + const ws = await sendNtfy(makeConfig({ topic: " " }), makeEvent(), fetchImpl); + expect(ws.ok).toBe(false); + expect(ws.error).toMatch(/required/i); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("returns ok:false with status on non-2xx response", async () => { + const fetchImpl = makeFetch({ ok: false, status: 403, statusText: "Forbidden", body: "nope" }); + const result = await sendNtfy(makeConfig(), makeEvent(), fetchImpl); + expect(result.ok).toBe(false); + expect(result.status).toBe(403); + expect(result.error).toMatch(/403/); + expect(result.error).toMatch(/nope/); + }); + + it("returns ok:false with error message on fetch throwing", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }); + const result = await sendNtfy(makeConfig(), makeEvent(), fetchImpl); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/ECONNREFUSED/); + }); +}); |
