FN-7035: split oversized test suites
Split oversized ChatView and notifier suites while updating the line-count baseline. - Move ChatView core contract and interaction coverage into focused sibling test files. - Move notifier runtime coverage into its own suite and share setup through a test harness. - Document the line-count guard decision and ratchet baseline entries for existing growth. Files changed: .../__tests__/ChatView.core-contracts.test.tsx | 623 ++++++++ .../__tests__/ChatView.core-interactions.test.tsx | 1261 +++++++++++++++ .../components/__tests__/ChatView.core.test.tsx | 1652 +------------------- .../engine/src/__tests__/notifier.runtime.test.ts | 810 ++++++++++ .../engine/src/__tests__/notifier.test-harness.ts | 71 + packages/engine/src/__tests__/notifier.test.ts | 847 +--------- scripts/check-file-line-count.mjs | 3 + scripts/line-count-baseline.json | 12 +- 8 files changed, 2778 insertions(+), 2501 deletions(-) Fusion-Task-Id: FN-7035 Fusion-Task-Lineage: 14cebb57-925b-41c7-9c8b-472f34b76fe2
This commit is contained in:
@@ -0,0 +1,623 @@
|
||||
/*
|
||||
FNXC:DashboardTests 2026-06-25-17:44:
|
||||
ChatView suite split 5/5 (model/delete/css contracts) extracts model-tag, session-delete, and CSS-contract describes from ChatView.core.test.tsx so the cap-crosser is split into focused siblings rather than grandfathered. Shares ChatView.test-harness; vi.mock factories stay inline and self-contained per the harness TDZ warning.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { ChatView } from "../ChatView";
|
||||
import type { DiscoveredSkill } from "@fusion/dashboard";
|
||||
import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||
import { SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache";
|
||||
import {
|
||||
renderWithAct,
|
||||
setupMockChat,
|
||||
setupMockRooms,
|
||||
mockViewportMode,
|
||||
activeSessionFixture,
|
||||
createMockSkill,
|
||||
defaultChatState,
|
||||
defaultModelsResponse,
|
||||
mockUseChat,
|
||||
mockFetchModels,
|
||||
mockFetchDiscoveredSkills,
|
||||
mockCreateObjectURL,
|
||||
mockRevokeObjectURL,
|
||||
mockClipboardWriteText,
|
||||
installChatViewEnv,
|
||||
} from "./ChatView.test-harness";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("../../hooks/useChat");
|
||||
vi.mock("../../hooks/useChatRooms");
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock lucide-react icons - spread actual module and override specific icons
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("lucide-react")>();
|
||||
return {
|
||||
...actual,
|
||||
MessageSquare: ({ "data-testid": testId, ...props }: any) => (
|
||||
<svg data-testid={testId || "icon-message-square"} {...props} />
|
||||
),
|
||||
Send: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-send"} {...props} />,
|
||||
Plus: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-plus"} {...props} />,
|
||||
Search: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-search"} {...props} />,
|
||||
Trash2: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-trash"} {...props} />,
|
||||
Archive: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-archive"} {...props} />,
|
||||
Pencil: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-pencil"} {...props} />,
|
||||
ChevronLeft: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-chevron-left"} {...props} />,
|
||||
Bot: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-bot"} {...props} />,
|
||||
Square: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-square"} {...props} />,
|
||||
Eye: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye"} {...props} />,
|
||||
EyeOff: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye-off"} {...props} />,
|
||||
Paperclip: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-paperclip"} {...props} />,
|
||||
File: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-file"} {...props} />,
|
||||
Copy: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-copy"} {...props} />,
|
||||
Check: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-check"} {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock CustomModelDropdown - no longer used but kept for other tests
|
||||
vi.mock("../CustomModelDropdown", () => ({
|
||||
CustomModelDropdown: ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
}) => (
|
||||
<select
|
||||
data-testid="mock-model-dropdown"
|
||||
aria-label={label}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">Use default</option>
|
||||
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
|
||||
<option value="openai/gpt-4o">GPT-4o</option>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock fetchAgents for new chat dialog
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue({
|
||||
models: [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
],
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
}),
|
||||
fetchAgents: vi.fn().mockResolvedValue([
|
||||
{ id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
|
||||
{ id: "agent-002", name: "Beta", role: "reviewer", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
|
||||
]),
|
||||
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
||||
fetchTasks: vi.fn().mockResolvedValue([]),
|
||||
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||
}));
|
||||
|
||||
installChatViewEnv();
|
||||
|
||||
|
||||
describe("formatModelTag helper function", () => {
|
||||
// Import the function for testing - we'll test it via the UI behavior instead
|
||||
// The function is not exported, so we test it indirectly through the component
|
||||
|
||||
it("formats claude-sonnet-4-5 model ID correctly", async () => {
|
||||
setupMockChat({
|
||||
activeSession: {
|
||||
id: "session-001",
|
||||
agentId: "agent-001",
|
||||
status: "active",
|
||||
title: "Test",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
messages: [
|
||||
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
|
||||
expect(modelTag?.textContent).toContain("Claude Sonnet");
|
||||
});
|
||||
|
||||
it("formats gpt-4o model ID correctly", async () => {
|
||||
setupMockChat({
|
||||
activeSession: {
|
||||
id: "session-001",
|
||||
agentId: "agent-001",
|
||||
status: "active",
|
||||
title: "Test",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
messages: [
|
||||
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
|
||||
expect(modelTag?.textContent).toContain("GPT-4o");
|
||||
});
|
||||
|
||||
it("formats gemini-2.5-pro model ID correctly", async () => {
|
||||
setupMockChat({
|
||||
activeSession: {
|
||||
id: "session-001",
|
||||
agentId: "agent-001",
|
||||
status: "active",
|
||||
title: "Test",
|
||||
modelProvider: "google",
|
||||
modelId: "gemini-2.5-pro",
|
||||
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
messages: [
|
||||
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
|
||||
expect(modelTag?.textContent).toContain("Gemini");
|
||||
});
|
||||
|
||||
it("returns null when modelId is missing", async () => {
|
||||
setupMockChat({
|
||||
activeSession: {
|
||||
id: "session-001",
|
||||
agentId: "__fn_agent__",
|
||||
status: "active",
|
||||
title: "Test",
|
||||
modelProvider: "anthropic",
|
||||
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
messages: [
|
||||
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
|
||||
expect(modelTag).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns null when provider is missing", async () => {
|
||||
setupMockChat({
|
||||
activeSession: {
|
||||
id: "session-001",
|
||||
agentId: "__fn_agent__",
|
||||
status: "active",
|
||||
title: "Test",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
messages: [
|
||||
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
|
||||
expect(modelTag).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat Session Delete Button", () => {
|
||||
it("renders delete button on each session item", async () => {
|
||||
setupMockChat({
|
||||
sessions: [
|
||||
{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat 1", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
{ id: "session-002", agentId: "agent-002", status: "active", title: "Test Chat 2", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
filteredSessions: [
|
||||
{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat 1", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
{ id: "session-002", agentId: "agent-002", status: "active", title: "Test Chat 2", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const deleteButtons = screen.getAllByTestId("chat-session-delete-btn");
|
||||
expect(deleteButtons.length).toBe(2);
|
||||
});
|
||||
|
||||
it("clicking delete button shows confirmation dialog", async () => {
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const deleteButton = screen.getByTestId("chat-session-delete-btn");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Dialog should be open
|
||||
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking delete button does not select the session", async () => {
|
||||
const selectSession = vi.fn();
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
selectSession,
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const deleteButton = screen.getByTestId("chat-session-delete-btn");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
expect(selectSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renames from the desktop context menu with the current title prefilled", async () => {
|
||||
const renameSession = vi.fn().mockResolvedValue(undefined);
|
||||
const renamedSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Renamed Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" };
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
renameSession,
|
||||
});
|
||||
|
||||
const view = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId("chat-session-session-001"));
|
||||
expect(screen.getByTestId("chat-context-rename")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId("chat-context-rename"));
|
||||
|
||||
const input = screen.getByTestId("chat-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Test Chat");
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "Renamed Chat");
|
||||
await userEvent.click(screen.getByTestId("chat-rename-save"));
|
||||
|
||||
expect(renameSession).toHaveBeenCalledWith("session-001", "Renamed Chat");
|
||||
|
||||
setupMockChat({
|
||||
activeSession: renamedSession,
|
||||
sessions: [renamedSession],
|
||||
filteredSessions: [renamedSession],
|
||||
renameSession,
|
||||
});
|
||||
await act(async () => {
|
||||
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("chat-session-session-001")).toHaveTextContent("Renamed Chat");
|
||||
const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null;
|
||||
expect(headerTitle).toHaveTextContent("Renamed Chat");
|
||||
});
|
||||
|
||||
it("prefills rename as empty for an untitled session and names it", async () => {
|
||||
const renameSession = vi.fn().mockResolvedValue(undefined);
|
||||
const untitledSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: null, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" };
|
||||
setupMockChat({
|
||||
activeSession: untitledSession,
|
||||
sessions: [untitledSession],
|
||||
filteredSessions: [untitledSession],
|
||||
renameSession,
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId("chat-session-session-001"));
|
||||
await userEvent.click(screen.getByTestId("chat-context-rename"));
|
||||
|
||||
const input = screen.getByTestId("chat-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("");
|
||||
await userEvent.type(input, "Named from Untitled");
|
||||
await userEvent.click(screen.getByTestId("chat-rename-save"));
|
||||
|
||||
expect(renameSession).toHaveBeenCalledWith("session-001", "Named from Untitled");
|
||||
});
|
||||
|
||||
it("renames from the mobile session switcher and preserves the active header title surface", async () => {
|
||||
const restoreMatchMedia = mockViewportMode("mobile");
|
||||
const renameSession = vi.fn().mockResolvedValue(undefined);
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
renameSession,
|
||||
});
|
||||
|
||||
const view = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Chat");
|
||||
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
|
||||
await userEvent.click(screen.getByTestId("chat-mobile-session-rename-session-001"));
|
||||
|
||||
const input = screen.getByTestId("chat-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Mobile Chat");
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "Mobile Renamed");
|
||||
await userEvent.click(screen.getByTestId("chat-rename-save"));
|
||||
|
||||
expect(renameSession).toHaveBeenCalledWith("session-001", "Mobile Renamed");
|
||||
|
||||
const renamedSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Renamed", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" };
|
||||
setupMockChat({
|
||||
activeSession: renamedSession,
|
||||
sessions: [renamedSession],
|
||||
filteredSessions: [renamedSession],
|
||||
renameSession,
|
||||
});
|
||||
await act(async () => {
|
||||
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Renamed");
|
||||
const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null;
|
||||
expect(headerTitle).toHaveTextContent("Mobile Renamed");
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("confirming delete calls deleteSession", async () => {
|
||||
const deleteSession = vi.fn();
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
deleteSession,
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const deleteButton = screen.getByTestId("chat-session-delete-btn");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Click confirm in dialog
|
||||
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
|
||||
await userEvent.click(within(dialog!).getByText("Delete"));
|
||||
|
||||
expect(deleteSession).toHaveBeenCalledWith("session-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView CSS — failure bubble contracts", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
it("uses shared error surface tokens for failure bubbles and detail affordances", async () => {
|
||||
const bubbleMatch = css.match(/\.chat-message--failure\s*\{([^}]*)\}/);
|
||||
const badgeMatch = css.match(/\.chat-message-failure-badge\s*\{([^}]*)\}/);
|
||||
const detailsMatch = css.match(/\.chat-message-failure-details\s*\{([^}]*)\}/);
|
||||
const linkMatch = css.match(/\.chat-message-failure-reference-link\s*\{([^}]*)\}/);
|
||||
|
||||
expect(bubbleMatch?.[1]).toContain("background: var(--status-error-bg)");
|
||||
expect(bubbleMatch?.[1]).toContain("border: var(--btn-border-width) solid var(--status-error-bg-deep)");
|
||||
expect(badgeMatch?.[1]).toContain("background: var(--status-error-bg-deep)");
|
||||
expect(detailsMatch?.[1]).toContain("background: var(--status-error-bg-deep)");
|
||||
expect(linkMatch?.[1]).toContain("background: var(--status-error-bg-deep)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView CSS — tablet assistant bubble width", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
it("widens assistant, streaming, and failure bubbles on tablet containers while preserving user and mobile caps", async () => {
|
||||
const baseMessageRule = css.match(/\.chat-message\s*\{([^}]*)\}/);
|
||||
const userRule = css.match(/\.chat-message--user\s*\{([^}]*)\}/);
|
||||
const tabletRule = css.match(
|
||||
/@container\s+chat-view\s+\(min-width:\s*48\.0625rem\)\s+and\s+\(max-width:\s*64rem\)\s*\{([\s\S]*?)\n\}/,
|
||||
);
|
||||
|
||||
expect(baseMessageRule?.[1]).toContain("max-width: 75%");
|
||||
expect(userRule?.[1]).toContain("align-self: flex-end");
|
||||
expect(userRule?.[1]).not.toContain("max-width");
|
||||
expect(tabletRule?.[1]).toMatch(
|
||||
/\.chat-message--assistant,\s*\.chat-message--streaming,\s*\.chat-message--failure\s*\{[^}]*max-width:\s*88%/,
|
||||
);
|
||||
expect(tabletRule?.[1]).not.toMatch(/\.chat-message--user\s*\{[^}]*max-width/);
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[^}]*max-width:\s*90%/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView CSS — active state edge highlights", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
function findRule(selector: string): string {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
|
||||
expect(match).toBeTruthy();
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
function mobileRuleContains(selector: string, propertyPattern: RegExp): boolean {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const mobileRegex = /@media[^{}]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g;
|
||||
let match;
|
||||
while ((match = mobileRegex.exec(css)) !== null) {
|
||||
const ruleMatch = match[1].match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
|
||||
if (ruleMatch && propertyPattern.test(ruleMatch[1])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
it("keeps scope-tab active tint without the removed bottom underline", async () => {
|
||||
const activeScopeRule = findRule(".chat-sidebar-scope-btn--active");
|
||||
|
||||
expect(activeScopeRule).toContain("background: var(--card)");
|
||||
expect(activeScopeRule).toContain("color: var(--text)");
|
||||
expect(activeScopeRule).not.toContain("box-shadow");
|
||||
expect(activeScopeRule).not.toContain("inset");
|
||||
});
|
||||
|
||||
it("renders the header Direct/Rooms toggle with visible borders", async () => {
|
||||
const headerScopeRule = findRule(".chat-view-header-scope-toggle");
|
||||
const headerScopeButtonRule = findRule(".chat-view-header-scope-toggle .chat-sidebar-scope-btn");
|
||||
const headerActiveScopeRule = findRule(".chat-view-header-scope-toggle .chat-sidebar-scope-btn--active");
|
||||
|
||||
expect(headerScopeRule).toContain("border: 1px solid var(--border)");
|
||||
expect(headerScopeRule).toContain("height: var(--view-header-content-row, 28px)");
|
||||
expect(headerScopeButtonRule).toContain("border: 1px solid transparent");
|
||||
expect(headerScopeButtonRule).toContain("height: 100%");
|
||||
expect(headerActiveScopeRule).toContain("border-color: var(--todo)");
|
||||
});
|
||||
|
||||
it("collapses header Direct/Rooms labels to icons at very narrow widths", async () => {
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*460px\)[\s\S]*?\.chat-view-header-scope-toggle\s*\{[^}]*width:\s*72px/);
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*460px\)[\s\S]*?\.chat-view-header-scope-toggle \.chat-sidebar-scope-btn span\s*\{[^}]*clip:\s*rect\(0 0 0 0\)/);
|
||||
});
|
||||
|
||||
it("keeps active chat-row background without the removed left edge or offset", async () => {
|
||||
const activeSessionRule = findRule(".chat-session-item--active");
|
||||
|
||||
expect(activeSessionRule).toContain("background: color-mix(in srgb, var(--todo) 12%, transparent)");
|
||||
expect(activeSessionRule).not.toContain("border-left");
|
||||
expect(activeSessionRule).not.toContain("padding-left: calc(var(--space-md) - (var(--btn-border-width) * 3))");
|
||||
});
|
||||
|
||||
it("does not reintroduce either removed highlight in mobile rules", async () => {
|
||||
expect(mobileRuleContains(".chat-sidebar-scope-btn--active", /box-shadow\s*:\s*inset/)).toBe(false);
|
||||
expect(mobileRuleContains(".chat-session-item--active", /border-left\s*:/)).toBe(false);
|
||||
expect(mobileRuleContains(".chat-session-item--active", /padding-left\s*:\s*calc\(var\(--space-md\)\s*-\s*\(var\(--btn-border-width\)\s*\*\s*3\)\)/)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FN-3911 chat session list layout", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
it("reserves right padding on title and preview rows so text clears the delete button", async () => {
|
||||
const titleMatch = css.match(/\.chat-session-title\s*\{([^}]*)\}/);
|
||||
const previewMatch = css.match(/\.chat-session-preview\s*\{([^}]*)\}/);
|
||||
expect(titleMatch).toBeTruthy();
|
||||
expect(previewMatch).toBeTruthy();
|
||||
expect(titleMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/);
|
||||
expect(previewMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/);
|
||||
});
|
||||
|
||||
it("FN-4385: keeps mobile title/preview clearance matched to compact delete button", async () => {
|
||||
expect(css).toMatch(
|
||||
/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-session-title,\s*\.chat-session-preview\s*\{\s*padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\);\s*\}/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat Session Delete Button CSS", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
it(".chat-session-delete-btn exists with opacity: 0", async () => {
|
||||
const match = css.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/);
|
||||
expect(match).toBeTruthy();
|
||||
expect(match![1]).toContain("opacity: 0");
|
||||
});
|
||||
|
||||
it(".chat-session-item:hover .chat-session-delete-btn has opacity: 1", async () => {
|
||||
const match = css.match(/\.chat-session-item:hover\s*\.chat-session-delete-btn\s*\{([^}]*)\}/);
|
||||
expect(match).toBeTruthy();
|
||||
expect(match![1]).toContain("opacity: 1");
|
||||
});
|
||||
|
||||
it("FN-4352: mobile delete button stays visible without min-size inflation", async () => {
|
||||
const mobileRegex = /@media[^{]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g;
|
||||
let match;
|
||||
let deleteRule = "";
|
||||
while ((match = mobileRegex.exec(css)) !== null) {
|
||||
const mediaContent = match[1];
|
||||
if (mediaContent.includes(".chat-session-delete-btn")) {
|
||||
deleteRule = mediaContent.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
if (deleteRule) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(deleteRule).toContain("opacity: 1");
|
||||
expect(deleteRule).not.toContain("min-width:");
|
||||
expect(deleteRule).not.toContain("min-height:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView CSS — mobile thread switcher", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
it("includes mobile session switcher trigger and dropdown tokenized contracts", async () => {
|
||||
const triggerMatch = css.match(/\.chat-mobile-session-trigger\s*\{([^}]*)\}/);
|
||||
const triggerIconMatch = css.match(/\.chat-mobile-session-trigger\s*>\s*svg\s*\{([^}]*)\}/);
|
||||
const dropdownMatch = css.match(/\.chat-mobile-session-dropdown\s*\{([^}]*)\}/);
|
||||
const optionMatch = css.match(/\.chat-mobile-session-option\s*\{([^}]*)\}/);
|
||||
const optionTitleMatch = css.match(/\.chat-mobile-session-option-title\s*\{([^}]*)\}/);
|
||||
expect(triggerMatch).toBeTruthy();
|
||||
expect(triggerIconMatch).toBeTruthy();
|
||||
expect(dropdownMatch).toBeTruthy();
|
||||
expect(optionMatch).toBeTruthy();
|
||||
expect(optionTitleMatch).toBeTruthy();
|
||||
expect(triggerMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2 + var(--space-xs))");
|
||||
expect(triggerMatch?.[1]).toContain("min-width: 0");
|
||||
expect(triggerMatch?.[1]).toContain("padding: var(--space-xs) var(--space-sm)");
|
||||
expect(triggerMatch?.[1]).toContain("font: inherit");
|
||||
expect(triggerMatch?.[1]).toContain("line-height: normal");
|
||||
expect(triggerMatch?.[1]).toContain("text-align: left");
|
||||
expect(triggerIconMatch?.[1]).toContain("width: var(--icon-size-md)");
|
||||
expect(triggerIconMatch?.[1]).toContain("height: var(--icon-size-md)");
|
||||
expect(dropdownMatch?.[1]).toContain("background: var(--surface)");
|
||||
expect(dropdownMatch?.[1]).toContain("border: 1px solid var(--border)");
|
||||
expect(optionMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2.25)");
|
||||
expect(optionMatch?.[1]).toContain("align-items: flex-start");
|
||||
expect(optionMatch?.[1]).toContain("line-height: normal");
|
||||
expect(optionTitleMatch?.[1]).toContain("display: block");
|
||||
expect(optionTitleMatch?.[1]).toContain("line-height: normal");
|
||||
expect(optionTitleMatch?.[1]).toContain("white-space: normal");
|
||||
expect(optionTitleMatch?.[1]).toContain("overflow-wrap: anywhere");
|
||||
});
|
||||
|
||||
it("keeps mobile override for header identity overflow visible so dropdown can render", async () => {
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-identity\s*\{[^}]*overflow:\s*visible;/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView CSS — nested flexbox scrolling fix", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
it(".chat-session-list has min-height: 0 for proper vertical scrolling", async () => {
|
||||
const match = css.match(/\.chat-session-list\s*\{([^}]*)\}/);
|
||||
expect(match).toBeTruthy();
|
||||
expect(match![1]).toContain("min-height: 0");
|
||||
});
|
||||
|
||||
it(".chat-thread has min-height: 0 for proper vertical scrolling", async () => {
|
||||
const match = css.match(/\.chat-thread\s*\{([^}]*)\}/);
|
||||
expect(match).toBeTruthy();
|
||||
expect(match![1]).toContain("min-height: 0");
|
||||
});
|
||||
|
||||
it(".chat-messages has min-height: 0 for proper vertical scrolling", async () => {
|
||||
const match = css.match(/\.chat-messages\s*\{([^}]*)\}/);
|
||||
expect(match).toBeTruthy();
|
||||
expect(match![1]).toContain("min-height: 0");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
810
packages/engine/src/__tests__/notifier.runtime.test.ts
Normal file
810
packages/engine/src/__tests__/notifier.runtime.test.ts
Normal file
@@ -0,0 +1,810 @@
|
||||
/*
|
||||
FNXC:EngineTests 2026-06-25-17:44:
|
||||
Notifier runtime suite split extracts the later NtfyNotifier reconfiguration, error, deduplication, runtime wiring, URL, stop, edge-case, and event-filtering describe blocks from notifier.test.ts so both sibling suites stay under MAX_LINES without weakening assertions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { MergeResult } from "@fusion/core";
|
||||
import { NtfyNotifier, notifyFallbackUsed } from "../notifier.js";
|
||||
import { NotificationService } from "../notification/notification-service.js";
|
||||
import { MockTaskStore, createTask, flushAsyncWork } from "./notifier.test-harness.js";
|
||||
|
||||
vi.mock("../logger.js", () => ({
|
||||
schedulerLog: { log: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("NtfyNotifier runtime behaviors", () => {
|
||||
let store: MockTaskStore;
|
||||
let notifier: NtfyNotifier;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = new MockTaskStore();
|
||||
fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (notifier) {
|
||||
notifier.stop();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("runtime reconfiguration", () => {
|
||||
it("starts sending notifications when enabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially disabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
// Enable at runtime
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
store.setSettings({ ntfyEnabled: true });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops sending notifications when disabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially enabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Disable at runtime
|
||||
store.setSettings({ ntfyEnabled: false });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls
|
||||
});
|
||||
|
||||
it("uses updated topic when changed at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "old-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object));
|
||||
|
||||
// Change topic
|
||||
store.setSettings({ ntfyTopic: "new-topic" });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("catches and logs fetch errors without throwing", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Should not throw
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles HTTP error responses without throwing", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Should not throw
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deduplication", () => {
|
||||
beforeEach(() => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("prevents duplicate notifications for the same event type", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
|
||||
// Multiple in-review events for the same task
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should only send one notification due to deduplication
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prevents duplicate awaiting-approval notifications for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-004", "Approval Task", "awaiting-approval");
|
||||
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Plan needs approval for FN-004",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prevents duplicate awaiting-user-review notifications for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-005", "User Review Task", "awaiting-user-review");
|
||||
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Title": "User review needed for FN-005",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows different event types for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
|
||||
// First: in-review notification
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second: merged notification (different event type - should be allowed)
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should have two notifications now
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("allows awaiting-approval alongside other event types for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-005", "Approval + Failure");
|
||||
|
||||
store.triggerTaskUpdated({ ...task, status: "awaiting-approval" });
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.triggerTaskUpdated({ ...task, status: "failed" });
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("sends notification only once on merge when task:moved and task:merged both fire", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
// completeTask() emits task:moved to done before task:merged
|
||||
store.triggerTaskMoved(task, "in-review", "done");
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Task FN-001 merged",
|
||||
"Priority": "default",
|
||||
}),
|
||||
body: 'Task "Test Task" has been merged to main',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("prevents duplicate task:merged events for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
// Multiple merged events for the same task
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should only send one notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits a single merged notification when notifier shares the same already-started NotificationService (ProjectEngine wiring)", async () => {
|
||||
const sharedService = new NotificationService(store, { projectId: "proj-1" });
|
||||
await sharedService.start();
|
||||
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj-1" }, sharedService);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-777", "Single Merge Notification");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-777",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Title: "Task FN-777 merged",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await sharedService.stop();
|
||||
});
|
||||
|
||||
it("dispatches and deduplicates fallback-used notifications", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
await notifyFallbackUsed({
|
||||
primaryModel: "anthropic/claude-sonnet-4-5",
|
||||
fallbackModel: "openai/gpt-4o",
|
||||
triggerPoint: "session-creation",
|
||||
taskId: "FN-900",
|
||||
taskTitle: "Fallback task",
|
||||
});
|
||||
await notifyFallbackUsed({
|
||||
primaryModel: "anthropic/claude-sonnet-4-5",
|
||||
fallbackModel: "openai/gpt-4o",
|
||||
triggerPoint: "session-creation",
|
||||
taskId: "FN-900",
|
||||
taskTitle: "Fallback task",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining("switched from anthropic/claude-sonnet-4-5 to openai/gpt-4o"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows notifications for different tasks independently", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task1 = createTask("FN-001", "Test Task 1");
|
||||
const task2 = createTask("FN-002", "Test Task 2");
|
||||
|
||||
store.triggerTaskMoved(task1, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task2, "in-progress", "in-review");
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
// Different tasks should each get their own notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard runtime wiring", () => {
|
||||
/**
|
||||
* These tests simulate the pattern used in packages/cli/src/commands/dashboard.ts
|
||||
* where the NtfyNotifier is constructed with an optional projectId resolved
|
||||
* from the central project registry. When a registered project is found,
|
||||
* deep links include ?project=...&task=...; when no project is registered
|
||||
* (legacy / single-project mode), links fall back to ?task=... only.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("produces project-aware deep links when constructed with registered project ID", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
|
||||
// Simulates: const notifier = new NtfyNotifier(store, { projectId: registered.id });
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj_abc123" });
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?project=proj_abc123&task=FN-001",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("produces task-only deep links when no project ID is available (legacy mode)", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
|
||||
// Simulates: const notifier = new NtfyNotifier(store); // no projectId
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?task=FN-001",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Verify no "project=" in the URL
|
||||
const callArgs = fetchMock.mock.calls[0][1] as { headers: Record<string, string> };
|
||||
expect(callArgs.headers["Click"]).not.toContain("project=");
|
||||
});
|
||||
|
||||
it("produces project-aware deep links for all notification event types", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj_xyz" });
|
||||
await notifier.start();
|
||||
|
||||
// in-review event
|
||||
store.triggerTaskMoved(createTask("FN-001", "Task A"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// merged event
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-001", "Task A"),
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Verify both calls include project
|
||||
const calls = fetchMock.mock.calls;
|
||||
for (const call of calls) {
|
||||
const headers = call[1].headers as Record<string, string>;
|
||||
expect(headers["Click"]).toContain("project=proj_xyz");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom base URL", () => {
|
||||
it("uses custom ntfy base URL when provided in notifier options", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store, { ntfyBaseUrl: "https://my-ntfy.example.com" });
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://my-ntfy.example.com/test-topic",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("uses ntfyBaseUrl from settings when configured", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyBaseUrl: "https://ntfy.internal.example///",
|
||||
});
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-101", "Configured URL Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.internal.example/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default ntfy.sh when settings ntfyBaseUrl is blank", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyBaseUrl: " ",
|
||||
});
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-102", "Blank URL Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies updated ntfyBaseUrl from settings changes at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-103", "Before Update"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/test-topic", expect.any(Object));
|
||||
|
||||
store.setSettings({ ntfyBaseUrl: "https://ntfy.changed.example" });
|
||||
store.triggerTaskMoved(createTask("FN-104", "After Update"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
"https://ntfy.changed.example/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop()", () => {
|
||||
it("stops listening to events after stop() is called", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
notifier.stop();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should not increase after stop
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("allows in-review and failed notifications for the same task", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
|
||||
// First: in-review notification
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second: failed notification (different event type - should be allowed)
|
||||
const failedTask = { ...task, status: "failed" };
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should have two notifications
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not notify on task:moved to columns other than in-review", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Move to todo - should not notify
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "triage", "todo");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Move to in-progress - should not notify
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "todo", "in-progress");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Move to done - should not notify (merged notification comes from task:merged)
|
||||
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-review", "done");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not notify on task:updated when status is neither failed nor awaiting-approval", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task", "in-progress");
|
||||
store.triggerTaskUpdated(task);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles empty topic gracefully", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Empty topic should be treated as no topic
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("event filtering", () => {
|
||||
beforeEach(() => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("does not send in-review notification when 'in-review' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["merged", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send merged notification when 'merged' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-001", "Test Task"),
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send failed notification when 'failed' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const failedTask = createTask("FN-001", "Test Task", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send awaiting-approval notification when 'awaiting-approval' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const awaitingApprovalTask = createTask("FN-006", "Approval Task", "awaiting-approval");
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send awaiting-user-review notification when 'awaiting-user-review' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-007", "User Review Task", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends notification for enabled events while others are disabled", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// in-review - should send
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// merged - should NOT send
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-002", "Test Task 2"),
|
||||
branch: "fusion/fn-002",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// failed - should NOT send
|
||||
const failedTask = createTask("FN-003", "Test Task 3", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// awaiting-approval - should NOT send
|
||||
const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval");
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// awaiting-user-review - should NOT send
|
||||
const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
});
|
||||
|
||||
it("defaults to all events when ntfyEvents is undefined", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: undefined });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-002", "Test Task 2"),
|
||||
branch: "fusion/fn-002",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const failedTask = createTask("FN-003", "Test Task 3", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval");
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("updates notifications when ntfyEvents changes at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially all events enabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Disable in-review
|
||||
store.setSettings({ ntfyEvents: ["merged", "failed", "awaiting-approval", "awaiting-user-review"] });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // No new call for in-review
|
||||
|
||||
// Enable in-review again
|
||||
store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2); // New call for in-review
|
||||
});
|
||||
});
|
||||
});
|
||||
71
packages/engine/src/__tests__/notifier.test-harness.ts
Normal file
71
packages/engine/src/__tests__/notifier.test-harness.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { expect, vi } from "vitest";
|
||||
import type { Task, Column, MergeResult, Settings } from "@fusion/core";
|
||||
|
||||
/*
|
||||
FNXC:EngineTests 2026-06-25-17:44:
|
||||
Shared notifier test harness for the FN-7035 suite split. MockTaskStore, createTask, and flushAsyncWork stay in one helper so notifier.test.ts and notifier.runtime.test.ts can split whole describe blocks under the line-count cap without duplicating event-store behavior.
|
||||
*/
|
||||
|
||||
interface MockTaskStoreEvents {
|
||||
"task:moved": [{ task: Task; from: Column; to: Column }];
|
||||
"task:updated": [Task];
|
||||
"task:merged": [MergeResult];
|
||||
"settings:updated": [{ settings: Settings; previous: Settings }];
|
||||
}
|
||||
|
||||
export async function flushAsyncWork(): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
export class MockTaskStore extends EventEmitter<MockTaskStoreEvents> {
|
||||
private settings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
failureNotificationMode: "all",
|
||||
failureNotificationDelayMs: 0,
|
||||
};
|
||||
|
||||
getSettings(): Settings {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
setSettings(settings: Partial<Settings>): void {
|
||||
const previous = { ...this.settings };
|
||||
this.settings = { ...this.settings, ...settings };
|
||||
this.emit("settings:updated", { settings: this.settings, previous });
|
||||
}
|
||||
|
||||
triggerTaskMoved(task: Task, from: Column, to: Column): void {
|
||||
this.emit("task:moved", { task, from, to });
|
||||
}
|
||||
|
||||
triggerTaskUpdated(task: Task): void {
|
||||
this.emit("task:updated", task);
|
||||
}
|
||||
|
||||
triggerTaskMerged(result: MergeResult): void {
|
||||
this.emit("task:merged", result);
|
||||
}
|
||||
}
|
||||
|
||||
export const createTask = (id: string, title?: string, status?: string): Task => ({
|
||||
id,
|
||||
title,
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
status,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
log: [],
|
||||
});
|
||||
@@ -1,73 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Task, Column, MergeResult, Settings } from "@fusion/core";
|
||||
import type { MergeResult } from "@fusion/core";
|
||||
import {
|
||||
NtfyNotifier,
|
||||
DEFAULT_NTFY_EVENTS,
|
||||
buildNtfyClickUrl,
|
||||
isNtfyEventEnabled,
|
||||
resolveNtfyEvents,
|
||||
notifyFallbackUsed,
|
||||
sendNtfyNotificationWithResult,
|
||||
} from "../notifier.js";
|
||||
import { NotificationService } from "../notification/notification-service.js";
|
||||
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
|
||||
import { MockTaskStore, createTask, flushAsyncWork } from "./notifier.test-harness.js";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("../logger.js", () => ({
|
||||
schedulerLog: { log: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
interface MockTaskStoreEvents {
|
||||
"task:moved": [{ task: Task; from: Column; to: Column }];
|
||||
"task:updated": [Task];
|
||||
"task:merged": [MergeResult];
|
||||
"settings:updated": [{ settings: Settings; previous: Settings }];
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
class MockTaskStore extends EventEmitter<MockTaskStoreEvents> {
|
||||
private settings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
failureNotificationMode: "all",
|
||||
failureNotificationDelayMs: 0,
|
||||
};
|
||||
|
||||
getSettings(): Settings {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
setSettings(settings: Partial<Settings>): void {
|
||||
const previous = { ...this.settings };
|
||||
this.settings = { ...this.settings, ...settings };
|
||||
this.emit("settings:updated", { settings: this.settings, previous });
|
||||
}
|
||||
|
||||
// Helper to trigger events
|
||||
triggerTaskMoved(task: Task, from: Column, to: Column): void {
|
||||
this.emit("task:moved", { task, from, to });
|
||||
}
|
||||
|
||||
triggerTaskUpdated(task: Task): void {
|
||||
this.emit("task:updated", task);
|
||||
}
|
||||
|
||||
triggerTaskMerged(result: MergeResult): void {
|
||||
this.emit("task:merged", result);
|
||||
}
|
||||
}
|
||||
|
||||
describe("Ntfy notifier helpers", () => {
|
||||
it("includes mailbox message events in default events", () => {
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input");
|
||||
@@ -589,20 +536,6 @@ describe("NtfyNotifier", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createTask = (id: string, title?: string, status?: string): Task => ({
|
||||
id,
|
||||
title,
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
status,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
log: [],
|
||||
});
|
||||
|
||||
describe("when disabled", () => {
|
||||
it("does not send any notifications when ntfyEnabled is false", async () => {
|
||||
store.setSettings({ ntfyEnabled: false, ntfyTopic: "my-topic" });
|
||||
@@ -1325,780 +1258,4 @@ describe("NtfyNotifier", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime reconfiguration", () => {
|
||||
it("starts sending notifications when enabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially disabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
// Enable at runtime
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
store.setSettings({ ntfyEnabled: true });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops sending notifications when disabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially enabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Disable at runtime
|
||||
store.setSettings({ ntfyEnabled: false });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls
|
||||
});
|
||||
|
||||
it("uses updated topic when changed at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "old-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object));
|
||||
|
||||
// Change topic
|
||||
store.setSettings({ ntfyTopic: "new-topic" });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("catches and logs fetch errors without throwing", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Should not throw
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles HTTP error responses without throwing", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Should not throw
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deduplication", () => {
|
||||
beforeEach(() => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("prevents duplicate notifications for the same event type", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
|
||||
// Multiple in-review events for the same task
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should only send one notification due to deduplication
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prevents duplicate awaiting-approval notifications for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-004", "Approval Task", "awaiting-approval");
|
||||
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Plan needs approval for FN-004",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prevents duplicate awaiting-user-review notifications for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-005", "User Review Task", "awaiting-user-review");
|
||||
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Title": "User review needed for FN-005",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows different event types for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
|
||||
// First: in-review notification
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second: merged notification (different event type - should be allowed)
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should have two notifications now
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("allows awaiting-approval alongside other event types for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-005", "Approval + Failure");
|
||||
|
||||
store.triggerTaskUpdated({ ...task, status: "awaiting-approval" });
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.triggerTaskUpdated({ ...task, status: "failed" });
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("sends notification only once on merge when task:moved and task:merged both fire", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
// completeTask() emits task:moved to done before task:merged
|
||||
store.triggerTaskMoved(task, "in-review", "done");
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Task FN-001 merged",
|
||||
"Priority": "default",
|
||||
}),
|
||||
body: 'Task "Test Task" has been merged to main',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("prevents duplicate task:merged events for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
// Multiple merged events for the same task
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should only send one notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits a single merged notification when notifier shares the same already-started NotificationService (ProjectEngine wiring)", async () => {
|
||||
const sharedService = new NotificationService(store, { projectId: "proj-1" });
|
||||
await sharedService.start();
|
||||
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj-1" }, sharedService);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-777", "Single Merge Notification");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-777",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Title: "Task FN-777 merged",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await sharedService.stop();
|
||||
});
|
||||
|
||||
it("dispatches and deduplicates fallback-used notifications", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
await notifyFallbackUsed({
|
||||
primaryModel: "anthropic/claude-sonnet-4-5",
|
||||
fallbackModel: "openai/gpt-4o",
|
||||
triggerPoint: "session-creation",
|
||||
taskId: "FN-900",
|
||||
taskTitle: "Fallback task",
|
||||
});
|
||||
await notifyFallbackUsed({
|
||||
primaryModel: "anthropic/claude-sonnet-4-5",
|
||||
fallbackModel: "openai/gpt-4o",
|
||||
triggerPoint: "session-creation",
|
||||
taskId: "FN-900",
|
||||
taskTitle: "Fallback task",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining("switched from anthropic/claude-sonnet-4-5 to openai/gpt-4o"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows notifications for different tasks independently", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task1 = createTask("FN-001", "Test Task 1");
|
||||
const task2 = createTask("FN-002", "Test Task 2");
|
||||
|
||||
store.triggerTaskMoved(task1, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task2, "in-progress", "in-review");
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
// Different tasks should each get their own notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard runtime wiring", () => {
|
||||
/**
|
||||
* These tests simulate the pattern used in packages/cli/src/commands/dashboard.ts
|
||||
* where the NtfyNotifier is constructed with an optional projectId resolved
|
||||
* from the central project registry. When a registered project is found,
|
||||
* deep links include ?project=...&task=...; when no project is registered
|
||||
* (legacy / single-project mode), links fall back to ?task=... only.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("produces project-aware deep links when constructed with registered project ID", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
|
||||
// Simulates: const notifier = new NtfyNotifier(store, { projectId: registered.id });
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj_abc123" });
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?project=proj_abc123&task=FN-001",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("produces task-only deep links when no project ID is available (legacy mode)", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
|
||||
// Simulates: const notifier = new NtfyNotifier(store); // no projectId
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?task=FN-001",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Verify no "project=" in the URL
|
||||
const callArgs = fetchMock.mock.calls[0][1] as { headers: Record<string, string> };
|
||||
expect(callArgs.headers["Click"]).not.toContain("project=");
|
||||
});
|
||||
|
||||
it("produces project-aware deep links for all notification event types", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj_xyz" });
|
||||
await notifier.start();
|
||||
|
||||
// in-review event
|
||||
store.triggerTaskMoved(createTask("FN-001", "Task A"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// merged event
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-001", "Task A"),
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Verify both calls include project
|
||||
const calls = fetchMock.mock.calls;
|
||||
for (const call of calls) {
|
||||
const headers = call[1].headers as Record<string, string>;
|
||||
expect(headers["Click"]).toContain("project=proj_xyz");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom base URL", () => {
|
||||
it("uses custom ntfy base URL when provided in notifier options", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store, { ntfyBaseUrl: "https://my-ntfy.example.com" });
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://my-ntfy.example.com/test-topic",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("uses ntfyBaseUrl from settings when configured", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyBaseUrl: "https://ntfy.internal.example///",
|
||||
});
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-101", "Configured URL Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.internal.example/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default ntfy.sh when settings ntfyBaseUrl is blank", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyBaseUrl: " ",
|
||||
});
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-102", "Blank URL Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies updated ntfyBaseUrl from settings changes at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-103", "Before Update"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/test-topic", expect.any(Object));
|
||||
|
||||
store.setSettings({ ntfyBaseUrl: "https://ntfy.changed.example" });
|
||||
store.triggerTaskMoved(createTask("FN-104", "After Update"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
"https://ntfy.changed.example/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop()", () => {
|
||||
it("stops listening to events after stop() is called", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
notifier.stop();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should not increase after stop
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("allows in-review and failed notifications for the same task", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
|
||||
// First: in-review notification
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second: failed notification (different event type - should be allowed)
|
||||
const failedTask = { ...task, status: "failed" };
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should have two notifications
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not notify on task:moved to columns other than in-review", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Move to todo - should not notify
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "triage", "todo");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Move to in-progress - should not notify
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "todo", "in-progress");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Move to done - should not notify (merged notification comes from task:merged)
|
||||
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-review", "done");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not notify on task:updated when status is neither failed nor awaiting-approval", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task", "in-progress");
|
||||
store.triggerTaskUpdated(task);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles empty topic gracefully", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Empty topic should be treated as no topic
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("event filtering", () => {
|
||||
beforeEach(() => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("does not send in-review notification when 'in-review' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["merged", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send merged notification when 'merged' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-001", "Test Task"),
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send failed notification when 'failed' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const failedTask = createTask("FN-001", "Test Task", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send awaiting-approval notification when 'awaiting-approval' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const awaitingApprovalTask = createTask("FN-006", "Approval Task", "awaiting-approval");
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send awaiting-user-review notification when 'awaiting-user-review' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-007", "User Review Task", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends notification for enabled events while others are disabled", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// in-review - should send
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// merged - should NOT send
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-002", "Test Task 2"),
|
||||
branch: "fusion/fn-002",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// failed - should NOT send
|
||||
const failedTask = createTask("FN-003", "Test Task 3", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// awaiting-approval - should NOT send
|
||||
const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval");
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// awaiting-user-review - should NOT send
|
||||
const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
});
|
||||
|
||||
it("defaults to all events when ntfyEvents is undefined", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: undefined });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-002", "Test Task 2"),
|
||||
branch: "fusion/fn-002",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const failedTask = createTask("FN-003", "Test Task 3", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval");
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("updates notifications when ntfyEvents changes at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially all events enabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Disable in-review
|
||||
store.setSettings({ ntfyEvents: ["merged", "failed", "awaiting-approval", "awaiting-user-review"] });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // No new call for in-review
|
||||
|
||||
// Enable in-review again
|
||||
store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2); // New call for in-review
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,9 @@ FN-6917 re-confirms the `pnpm test`-blocking premise is stale because FN-5048 le
|
||||
|
||||
FNXC:CI 2026-06-25-00:00:
|
||||
FN-7013 re-confirms the `pnpm test`-blocking premise is stale: FN-5048 removed this guard from pretest and left it opt-in under `check:line-count` only. Sixty-one current violations were re-ratcheted after organic feature/test growth and eight stale baseline entries were tightened or pruned. `AgentLogViewer.test.tsx` and `merger-ai.ts` were temporarily grandfathered after crossing the hard cap as long-existing files, with focused split follow-ups FN-7028 and FN-7029. Wholesale god-file shrink/refactor remains the long-term direction and stays deferred to dedicated follow-ups.
|
||||
|
||||
FNXC:CI 2026-06-25-17:44:
|
||||
FN-7035 split the two new hard-cap crossers (`ChatView.core.test.tsx` and `notifier.test.ts`) into focused sibling suites rather than grandfathering them. Six existing grandfathered entries were re-ratcheted to current counts after organic test and feature growth; `store.ts` and `types.ts` drift was left out of scope for a follow-up. Wholesale god-file shrink remains long-term deferred work for dedicated refactors.
|
||||
*/
|
||||
// Repo-wide guard: hand-written source files may not exceed a hard line-count
|
||||
// cap (MAX_LINES). This stops the next god-file from being born while leaving
|
||||
|
||||
@@ -7,19 +7,19 @@
|
||||
"packages/cli/src/commands/dashboard-tui/app.tsx": 4681,
|
||||
"packages/cli/src/commands/dashboard.ts": 3000,
|
||||
"packages/cli/src/extension.ts": 4704,
|
||||
"packages/core/src/__tests__/agent-store.test.ts": 2997,
|
||||
"packages/core/src/__tests__/agent-store.test.ts": 3003,
|
||||
"packages/core/src/__tests__/central-core.test.ts": 3263,
|
||||
"packages/core/src/__tests__/db.test.ts": 3606,
|
||||
"packages/core/src/__tests__/mission-store.test.ts": 4519,
|
||||
"packages/core/src/__tests__/mission-store.test.ts": 4525,
|
||||
"packages/core/src/__tests__/plugin-loader.test.ts": 2783,
|
||||
"packages/core/src/__tests__/store-settings.test.ts": 2249,
|
||||
"packages/core/src/agent-store.ts": 2946,
|
||||
"packages/core/src/central-core.ts": 3854,
|
||||
"packages/core/src/db.ts": 5888,
|
||||
"packages/core/src/db.ts": 5924,
|
||||
"packages/core/src/mission-store.ts": 4390,
|
||||
"packages/core/src/store.ts": 17358,
|
||||
"packages/core/src/types.ts": 7415,
|
||||
"packages/dashboard/app/api/legacy.ts": 10821,
|
||||
"packages/dashboard/app/api/legacy.ts": 10865,
|
||||
"packages/dashboard/app/components/AgentDetailView.tsx": 5400,
|
||||
"packages/dashboard/app/components/AgentsView.tsx": 2147,
|
||||
"packages/dashboard/app/components/ChatView.tsx": 4075,
|
||||
@@ -28,7 +28,7 @@
|
||||
"packages/dashboard/app/components/MissionManager.tsx": 5042,
|
||||
"packages/dashboard/app/components/ModelOnboardingModal.tsx": 3212,
|
||||
"packages/dashboard/app/components/PlanningModeModal.tsx": 3531,
|
||||
"packages/dashboard/app/components/QuickEntryBox.tsx": 2229,
|
||||
"packages/dashboard/app/components/QuickEntryBox.tsx": 2288,
|
||||
"packages/dashboard/app/components/SettingsModal.tsx": 3505,
|
||||
"packages/dashboard/app/components/TaskCard.tsx": 2544,
|
||||
"packages/dashboard/app/components/TaskDetailModal.tsx": 4636,
|
||||
@@ -42,7 +42,7 @@
|
||||
"packages/dashboard/app/components/__tests__/MailboxView.test.tsx": 2202,
|
||||
"packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx": 4679,
|
||||
"packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx": 3002,
|
||||
"packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4707,
|
||||
"packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4850,
|
||||
"packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 5121,
|
||||
"packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2558,
|
||||
"packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx": 2917,
|
||||
|
||||
Reference in New Issue
Block a user