## Summary Restores green merge-gate and package-default suites after repeated `origin/main` merges brought workflow-graph ownership cutover drift into CI. - Align engine/dashboard/core tests with post-cutover contracts (`moveTaskIf`/`deleteTaskIf`, graph handoff, worktree-pool reclaim via `removeWorktree` + `RemovalReason`, multi-step RESUMING parse, soft-pause merge requester, graph-terminal failure surfaces). - Small product fixes needed for real regressions uncovered by the suite: soft-delete refuse before graph routing, skip DUPLICATE step-heading withhold when an explicit marker is present, PG schema applier guards, and related bookkeeping (research promote tool inventory / migration seed, stop shell `psql` in PG admin DDL). - Quarantine/ledger hygiene only where required by standing rules; no timeout/worker appeasement. ## Verification - `pnpm test:gate` ×2 green - `@fusion/engine` full package suite green (~9083 tests) - Targeted core/dashboard clusters green (schema applier, agent-runs UI, settings descriptions, mobile close) ## Test plan - [x] `pnpm test:gate` (twice) - [x] `pnpm --filter @fusion/engine test` - [ ] CI full suite / PR checks on this branch - [ ] Confirm no unrelated product behavior changes beyond the listed regression fixes <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for `roadmap-item` native structure kinds, including native structure embeds and metadata validation. * Added Stable and Beta release channel options in General settings. * Added per-action reporting target configuration with clearer “unset” guidance. * **Bug Fixes** * Improved heartbeat/prompt behavior when patrol is disabled. * Prevented deleted tasks from continuing through execution. * Made recovery for explicit duplicate redirects more permissive. * Hardened database migration and test database cleanup to reduce flaky failures. * **Documentation** * Updated settings text for release channels, reporting targets, and inheritance/unset behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
264 lines
12 KiB
TypeScript
264 lines
12 KiB
TypeScript
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { NativeStructurePreviewResult } from "@fusion/core";
|
|
import type { ChatMessageInfo } from "../../hooks/chatTypes";
|
|
import { attachChatStream, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchNativeStructurePreview, fetchTaskPlannerChatSession } from "../../api";
|
|
import { StandardChatMessageItem, StandardStreamingMessage } from "../StandardChatSurface";
|
|
import { ChatView } from "../ChatView";
|
|
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
|
|
import { activeSessionFixture, installChatViewEnv, renderWithAct, setupMockChat, setupMockRooms } from "./ChatView.test-harness";
|
|
|
|
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() }) };
|
|
});
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return { ...actual, useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }) };
|
|
});
|
|
vi.mock("../../api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../api")>();
|
|
return {
|
|
...actual,
|
|
fetchNativeStructurePreview: vi.fn(),
|
|
fetchSettings: vi.fn().mockResolvedValue({}),
|
|
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [], defaultProvider: null, defaultModelId: null }),
|
|
fetchAgents: vi.fn().mockResolvedValue([]),
|
|
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
|
fetchTasks: vi.fn().mockResolvedValue([]),
|
|
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
|
ensureTaskPlannerChatSession: vi.fn(),
|
|
fetchTaskPlannerChatSession: vi.fn(),
|
|
fetchChatSession: vi.fn(),
|
|
fetchChatMessages: vi.fn(),
|
|
attachChatStream: vi.fn(),
|
|
};
|
|
});
|
|
|
|
installChatViewEnv();
|
|
|
|
const fetchPreview = vi.mocked(fetchNativeStructurePreview);
|
|
const ensurePlannerSession = vi.mocked(ensureTaskPlannerChatSession);
|
|
const fetchPlannerSession = vi.mocked(fetchTaskPlannerChatSession);
|
|
const fetchSession = vi.mocked(fetchChatSession);
|
|
const fetchMessages = vi.mocked(fetchChatMessages);
|
|
const attachStream = vi.mocked(attachChatStream);
|
|
const available: NativeStructurePreviewResult = {
|
|
available: true,
|
|
kind: "mission",
|
|
kindLabel: "Mission",
|
|
title: "Inline mission",
|
|
excerpt: "A preview rendered in chat.",
|
|
openTarget: { view: "missions", id: "M-001" },
|
|
};
|
|
|
|
function message(overrides: Partial<ChatMessageInfo>): ChatMessageInfo {
|
|
return {
|
|
id: "message-1",
|
|
sessionId: "session-1",
|
|
role: "assistant",
|
|
content: "",
|
|
createdAt: "2026-07-19T00:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function renderMessage(overrides: Partial<ChatMessageInfo>, forcePlain = false) {
|
|
return render(
|
|
<StandardChatMessageItem
|
|
message={message(overrides)}
|
|
forcePlain={forcePlain}
|
|
agentName="Assistant"
|
|
hideAssistantIdentity={false}
|
|
showAssistantModelTag={false}
|
|
activeModelTag={null}
|
|
activeModelProvider={null}
|
|
activeSessionId="session-1"
|
|
/>,
|
|
);
|
|
}
|
|
|
|
async function expectPreview() {
|
|
await waitFor(() => expect(screen.getByTestId("native-structure-preview")).toHaveAttribute("data-kind", "mission"));
|
|
}
|
|
|
|
describe("StandardChatSurface native structure embeds", () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
fetchPreview.mockReset();
|
|
ensurePlannerSession.mockReset();
|
|
fetchPlannerSession.mockReset();
|
|
fetchSession.mockReset();
|
|
fetchMessages.mockReset();
|
|
attachStream.mockReset();
|
|
});
|
|
|
|
it.each([
|
|
["settled Markdown link", "[Mission](fusion://mission/M-001)"],
|
|
["settled bare assistant token", "Open fusion://mission/M-001 now."],
|
|
])("renders a preview for %s", async (_name, content) => {
|
|
fetchPreview.mockResolvedValue(available);
|
|
renderMessage({ content });
|
|
await expectPreview();
|
|
expect(screen.queryByText("fusion://mission/M-001")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it.each([
|
|
["Markdown link", "[Mission](fusion://mission/M-001)"],
|
|
["bare token", "fusion://mission/M-001"],
|
|
])("renders a preview while streaming a %s", async (_name, streamingText) => {
|
|
fetchPreview.mockResolvedValue(available);
|
|
render(
|
|
<StandardStreamingMessage
|
|
streamingText={streamingText}
|
|
forcePlain={false}
|
|
agentName="Assistant"
|
|
hideAssistantIdentity={false}
|
|
showAssistantModelTag={false}
|
|
activeModelTag={null}
|
|
activeModelProvider={null}
|
|
/>,
|
|
);
|
|
await expectPreview();
|
|
});
|
|
|
|
it("renders a user bare token through the raw message tokenizer", async () => {
|
|
fetchPreview.mockResolvedValue(available);
|
|
renderMessage({ role: "user", content: "Please open fusion://mission/M-001." });
|
|
await expectPreview();
|
|
expect(screen.getByTestId("chat-message-message-1").textContent).toContain("Open.");
|
|
});
|
|
|
|
it("lifts a paragraph preview outside phrasing-only content", async () => {
|
|
fetchPreview.mockResolvedValue(available);
|
|
renderMessage({ content: "Before fusion://mission/M-001 after" });
|
|
await expectPreview();
|
|
expect(screen.getByTestId("native-structure-preview").closest("p, h1, h2, h3, h4, h5, h6")).toBeNull();
|
|
expect(screen.getByText("Before").closest("p")).not.toBeNull();
|
|
expect(screen.getByText("after").closest("p")).not.toBeNull();
|
|
});
|
|
|
|
it("lifts a heading preview into a sibling block without an empty heading", async () => {
|
|
fetchPreview.mockResolvedValue(available);
|
|
renderMessage({ content: "## fusion://mission/M-001" });
|
|
await expectPreview();
|
|
expect(screen.getByTestId("native-structure-preview").closest("h1, h2, h3, h4, h5, h6")).toBeNull();
|
|
expect(document.querySelector("h2")).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
["formatted list", "- **fusion://mission/M-001**", "li"],
|
|
["formatted table cell", "| Structure |\n| --- |\n| **fusion://mission/M-001** |", "td, th, table"],
|
|
])("lifts a preview from a %s instead of nesting its block root", async (_surface, content, forbiddenAncestor) => {
|
|
fetchPreview.mockResolvedValue(available);
|
|
renderMessage({ content });
|
|
await expectPreview();
|
|
expect(screen.getByTestId("native-structure-preview").closest(forbiddenAncestor)).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
["bare token", "**Before fusion://mission/M-001 after**"],
|
|
["Markdown link", "**Before [Mission](fusion://mission/M-001) after**"],
|
|
])("lifts a formatted %s outside its phrasing wrapper", async (_form, content) => {
|
|
fetchPreview.mockResolvedValue(available);
|
|
renderMessage({ content });
|
|
await expectPreview();
|
|
expect(screen.getByTestId("native-structure-preview").closest("strong, em, p, h1, h2, h3, h4, h5, h6")).toBeNull();
|
|
expect(screen.getByText("Before").closest("strong")).not.toBeNull();
|
|
expect(screen.getByText("after").closest("strong")).not.toBeNull();
|
|
});
|
|
|
|
it("uses the shared unavailable placeholder without crashing", async () => {
|
|
fetchPreview.mockResolvedValue({ available: false, kind: "mission", id: "M-404", reason: "soft-deleted" });
|
|
renderMessage({ content: "fusion://mission/M-404" });
|
|
await waitFor(() => expect(screen.getByTestId("native-structure-preview-unavailable")).toHaveAttribute("data-reason", "soft-deleted"));
|
|
});
|
|
|
|
it("routes roadmap references to the shared unavailable placeholder", async () => {
|
|
/*
|
|
FNXC:DashboardTests 2026-07-20-23:45:
|
|
roadmap-item previews share the unavailable placeholder path; mock the preview
|
|
fetch so the embed does not crash on an undefined Promise from the API stub.
|
|
*/
|
|
fetchPreview.mockResolvedValue({ available: false, kind: "roadmap-item", id: "R-001", reason: "missing" });
|
|
renderMessage({ content: "fusion://roadmap-item/R-001" });
|
|
await waitFor(() => expect(screen.getByTestId("native-structure-preview-unavailable")).toHaveAttribute("data-reason", "missing"));
|
|
});
|
|
|
|
it("preserves malformed references as text and blocks unsafe Markdown URLs", () => {
|
|
renderMessage({ content: "fusion://mission/M-001?query [unsafe](javascript:alert(1))" });
|
|
expect(screen.queryByTestId("native-structure-preview")).not.toBeInTheDocument();
|
|
expect(screen.getByText("fusion://mission/M-001?query")).toBeInTheDocument();
|
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not transform native-looking Markdown link labels or inline code", () => {
|
|
renderMessage({ content: "[fusion://mission/M-001](https://example.com) and `fusion://mission/M-002`" });
|
|
expect(screen.queryByTestId("native-structure-preview")).not.toBeInTheDocument();
|
|
expect(screen.getByRole("link", { name: "fusion://mission/M-001" })).toHaveAttribute("href", "https://example.com");
|
|
expect(screen.getByText("fusion://mission/M-002").tagName).toBe("CODE");
|
|
});
|
|
|
|
it.each(["[Mission](fusion://mission/M-001)", "fusion://mission/M-001"])("keeps %s raw in forcePlain mode", (content) => {
|
|
renderMessage({ content }, true);
|
|
expect(screen.queryByTestId("native-structure-preview")).not.toBeInTheDocument();
|
|
expect(screen.getByText(content)).toBeInTheDocument();
|
|
});
|
|
|
|
function setupPlannerMessages(messages: Array<Record<string, unknown>>, sessionOverrides: Record<string, unknown> = {}) {
|
|
const session = { id: "planner-session", agentId: "task-planner:FN-1", title: null, status: "active", projectId: "project-1", modelProvider: "anthropic", modelId: "claude", createdAt: "2026-07-19T00:00:00.000Z", updatedAt: "2026-07-19T00:00:00.000Z", cliSessionFile: null, cliExecutorAdapterId: null, inFlightGeneration: null, ...sessionOverrides };
|
|
fetchPlannerSession.mockResolvedValue({ session });
|
|
ensurePlannerSession.mockResolvedValue({ session });
|
|
fetchSession.mockResolvedValue({ session });
|
|
fetchMessages.mockResolvedValue({ messages: messages as never });
|
|
return session;
|
|
}
|
|
|
|
function renderPlannerChat() {
|
|
return render(<TaskPlannerChatTab task={{ id: "FN-1", description: "Task", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: "2026-07-19T00:00:00.000Z", updatedAt: "2026-07-19T00:00:00.000Z" } as never} active planningModel={{ provider: "anthropic", modelId: "claude" }} projectId="project-1" addToast={vi.fn()} />);
|
|
}
|
|
|
|
it.each([
|
|
["settled assistant", { id: "planner-message", role: "assistant", content: "fusion://mission/M-001" }],
|
|
["settled user", { id: "planner-user-message", role: "user", content: "fusion://mission/M-001" }],
|
|
])("renders the shared preview in task-bound %s chat", async (_surface, plannerMessage) => {
|
|
fetchPreview.mockResolvedValue(available);
|
|
setupPlannerMessages([{ ...plannerMessage, sessionId: "planner-session", createdAt: "2026-07-19T00:00:00.000Z", thinkingOutput: null, metadata: null }]);
|
|
renderPlannerChat();
|
|
await expectPreview();
|
|
});
|
|
|
|
it("renders a reattached task-bound streaming preview from the in-flight session snapshot", async () => {
|
|
fetchPreview.mockResolvedValue(available);
|
|
attachStream.mockReturnValue({ close: vi.fn(), isConnected: () => true } as never);
|
|
setupPlannerMessages([], {
|
|
isGenerating: true,
|
|
inFlightGeneration: { status: "generating", streamingText: "**fusion://mission/M-001**", streamingThinking: "", toolCalls: [] },
|
|
});
|
|
renderPlannerChat();
|
|
await expectPreview();
|
|
expect(document.querySelector(".chat-message--streaming")).toBeInTheDocument();
|
|
expect(screen.getByTestId("native-structure-preview").closest("strong")).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
["desktop assistant room", {}, "assistant"],
|
|
["floating narrow assistant room", { floating: true, compactLayout: true }, "assistant"],
|
|
["desktop user room", {}, "user"],
|
|
])("renders the shared preview in ChatView %s", async (_surface, layout, role) => {
|
|
fetchPreview.mockResolvedValue(available);
|
|
setupMockRooms();
|
|
setupMockChat({
|
|
sessions: [activeSessionFixture],
|
|
filteredSessions: [activeSessionFixture],
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "room-message", sessionId: activeSessionFixture.id, role, content: "fusion://mission/M-001", createdAt: "2026-07-19T00:00:00.000Z" } as never],
|
|
});
|
|
await renderWithAct(<ChatView projectId="project-1" addToast={vi.fn()} {...layout} />);
|
|
await expectPreview();
|
|
});
|
|
});
|