Files
fusion/packages/dashboard/app/components/__tests__/MemoryView.test.tsx
gsxdsm dad726c777 FN-9038: add Knowledge Graph tab to Memory view
Expose knowledge graph data in the Memory view through a localized, resilient dashboard panel.

- Add protected knowledge graph API routes and access helpers.
- Add a Memory view tab, graph panel, loading/error states, and localized labels.
- Document graph navigation and cover access, route, hook, and component behavior.

Files changed:
 .changeset/fn-9038-knowledge-graph-tab.md          |   7 +
 docs/dashboard-guide.md                            |   2 +
 docs/knowledge-graph.md                            |  10 ++
 packages/dashboard/app/api/legacy.ts               |  17 ++-
 packages/dashboard/app/api/system/index.ts         |   1 +
 .../dashboard/app/api/system/knowledge-graph.ts    |  23 +++
 .../app/components/KnowledgeGraphPanel.css         |  35 +++++
 .../app/components/KnowledgeGraphPanel.tsx         |  68 +++++++++
 packages/dashboard/app/components/MemoryView.tsx   |  15 +-
 .../__tests__/KnowledgeGraphPanel.test.tsx         |  49 +++++++
 .../app/components/__tests__/MemoryView.test.tsx   |  13 ++
 .../app/hooks/__tests__/useKnowledgeGraph.test.ts  |  66 +++++++++
 packages/dashboard/app/hooks/useKnowledgeGraph.ts  |  94 ++++++++++++
 .../src/__tests__/knowledge-graph-access.test.ts   |  88 ++++++++++++
 packages/dashboard/src/knowledge-graph-access.ts   | 160 +++++++++++++++++++++
 .../register-knowledge-graph-routes.test.ts        |  75 ++++++++++
 .../src/routes/register-knowledge-routes.ts        | 120 ++++++++++++++++
 packages/i18n/locales/en/app.json                  |  15 +-
 packages/i18n/locales/es/app.json                  |  15 +-
 packages/i18n/locales/fr/app.json                  |  15 +-
 packages/i18n/locales/ko/app.json                  |  15 +-
 packages/i18n/locales/pt-BR/app.json               |  15 +-
 packages/i18n/locales/zh-CN/app.json               |  15 +-
 packages/i18n/locales/zh-TW/app.json               |  15 +-
 24 files changed, 939 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-9038

Fusion-Task-Lineage: 4200e214-ad22-4f92-b980-c9bcb8ffd3b9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-13 16:48:01 -07:00

446 lines
16 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryView } from "../MemoryView";
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
const { capturedFileEditorProps } = vi.hoisted(() => ({
capturedFileEditorProps: [] as Array<{ filePath: string; onSendSelectionToTask?: (description: string) => void }>,
}));
const mockUseMemoryData = vi.fn();
vi.mock("../../hooks/useMemoryData", () => ({
useMemoryData: (...args: unknown[]) => mockUseMemoryData(...args),
}));
vi.mock("../KnowledgeGraphPanel", () => ({
KnowledgeGraphPanel: () => <div data-testid="knowledge-graph-panel" />,
}));
vi.mock("../FileEditor", () => ({
FileEditor: (props: { filePath: string; onSendSelectionToTask?: (description: string) => void }) => {
capturedFileEditorProps.push(props);
return <div aria-label={`Editor for ${props.filePath}`} />;
},
}));
vi.mock("lucide-react", () => ({
Loader2: () => <span data-testid="loader-icon" />,
// Brain backs the shared ViewHeader icon for the Memory view header (FNXC:Navigation 2026-06-22-12:00).
Brain: () => <span data-testid="icon-brain" />,
}));
function createMemoryData(overrides: Record<string, unknown> = {}) {
return {
insightsContent: null,
insightsLoading: false,
insightsExists: false,
saveInsights: vi.fn(),
memorySettings: {
memoryEnabled: true,
memoryAutoSummarizeEnabled: false,
memoryAutoSummarizeThresholdChars: 50000,
memoryAutoSummarizeSchedule: "0 3 * * *",
memoryDreamsEnabled: false,
memoryDreamsSchedule: "0 4 * * *",
},
settingsLoading: false,
saveMemorySettings: vi.fn(),
savingMemorySettings: false,
backendStatus: {
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun install -g @tobilu/qmd",
},
backendLoading: false,
extractInsights: vi.fn(),
extracting: false,
auditReport: {
health: "healthy",
workingMemory: { size: 120, sectionCount: 2 },
insightsMemory: { size: 80, insightCount: 3 },
extraction: { success: true, summary: "ok", insightCount: 3 },
pruning: { applied: false, reason: "" },
checks: [],
},
auditLoading: false,
refreshAudit: vi.fn(),
compactMemory: vi.fn(),
compacting: false,
installQmdAction: vi.fn(),
installingQmd: false,
testRetrieval: vi.fn(),
memoryFiles: [
{
path: ".fusion/memory/MEMORY.md",
label: "Long-term memory",
layer: "long-term",
size: 12,
updatedAt: "2026-04-17T12:00:00.000Z",
},
],
memoryFilesLoading: false,
selectedFilePath: ".fusion/memory/MEMORY.md",
selectedFileContent: "hello",
selectedFileLoading: false,
selectedFileDirty: false,
setSelectedFileContent: vi.fn(),
selectFile: vi.fn(),
saveSelectedFile: vi.fn(),
savingSelectedFile: false,
reloadMemoryFiles: vi.fn(),
triggerDreamNow: vi.fn(),
dreamRunning: false,
...overrides,
};
}
describe("MemoryView", () => {
it("adds the lazy Knowledge Graph tab without mounting it initially", async () => {
render(<MemoryView addToast={vi.fn()} />);
expect(screen.getByTestId("memory-tab-graph")).toBeInTheDocument();
expect(screen.queryByTestId("knowledge-graph-panel")).not.toBeInTheDocument();
await userEvent.click(screen.getByTestId("memory-tab-graph"));
expect(screen.getByTestId("knowledge-graph-panel")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("memory-tab-working"));
expect(screen.queryByTestId("knowledge-graph-panel")).not.toBeInTheDocument();
});
beforeEach(() => {
vi.clearAllMocks();
capturedFileEditorProps.length = 0;
mockUseMemoryData.mockReturnValue(createMemoryData());
});
it("does not show read-only warning while backend status is still loading", () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
backendStatus: null,
backendLoading: true,
}),
);
render(<MemoryView addToast={vi.fn()} />);
expect(screen.queryByText("This memory backend is read-only. Changes cannot be saved.")).not.toBeInTheDocument();
});
it("passes selection-to-task callback to the memory file editor", () => {
const onSendSelectionToTask = vi.fn();
render(<MemoryView addToast={vi.fn()} onSendSelectionToTask={onSendSelectionToTask} />);
expect(capturedFileEditorProps).toEqual(
expect.arrayContaining([
expect.objectContaining({
filePath: ".fusion/memory/MEMORY.md",
onSendSelectionToTask,
}),
]),
);
});
it("passes selection-to-task callback to the raw insights editor", async () => {
const onSendSelectionToTask = vi.fn();
mockUseMemoryData.mockReturnValue(
createMemoryData({
insightsExists: true,
insightsContent: "## Patterns\n- Keep useful notes",
}),
);
render(<MemoryView addToast={vi.fn()} onSendSelectionToTask={onSendSelectionToTask} />);
await userEvent.click(screen.getByRole("tab", { name: "Insights" }));
await userEvent.click(screen.getByRole("button", { name: "Edit Raw" }));
expect(capturedFileEditorProps).toEqual(
expect.arrayContaining([
expect.objectContaining({
filePath: ".fusion/memory/memory-insights.md",
onSendSelectionToTask,
}),
]),
);
});
/*
FNXC:MemoryView 2026-07-11-01:00:
PR #2003 regression tests for insight parsing: bullets must parse into individual items
(the pre-fix parser stripped "- " before filtering for it, collapsing categories into one
blob), and a bullet's continuation lines must stay attached to that bullet instead of
being dropped.
*/
it("parses each markdown bullet into its own insight item with the correct count", async () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
insightsExists: true,
insightsContent: "## Patterns\n<!-- extraction marker -->\n- First insight\n- Second insight\n\n## Pitfalls\n- Third insight",
}),
);
render(<MemoryView addToast={vi.fn()} />);
await userEvent.click(screen.getByRole("tab", { name: "Insights" }));
expect(screen.getByText("First insight")).toBeInTheDocument();
expect(screen.getByText("Second insight")).toBeInTheDocument();
expect(screen.getByText("Third insight")).toBeInTheDocument();
// Total Insights stat counts individual bullets, and extraction-marker comments are excluded.
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.queryByText(/extraction marker/)).not.toBeInTheDocument();
});
it("keeps continuation lines attached to their bullet for multiline insights", async () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
insightsExists: true,
insightsContent: "## Patterns\n- Multiline insight head\n continuation detail line\n- Plain insight",
}),
);
render(<MemoryView addToast={vi.fn()} />);
await userEvent.click(screen.getByRole("tab", { name: "Insights" }));
expect(screen.getByText(/Multiline insight head/)).toBeInTheDocument();
expect(screen.getByText(/continuation detail line/)).toBeInTheDocument();
expect(screen.getByText("Plain insight")).toBeInTheDocument();
// The continuation belongs to the first bullet: still exactly 2 insights in the Total stat.
expect(document.querySelector(".memory-stat-value")?.textContent).toBe("2");
});
it("keeps indented sub-bullets inside their parent insight instead of splitting them", async () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
insightsExists: true,
insightsContent: "## Patterns\n- Parent insight\n - detail A\n - detail B\n- Sibling insight",
}),
);
render(<MemoryView addToast={vi.fn()} />);
await userEvent.click(screen.getByRole("tab", { name: "Insights" }));
// Sub-bullets render within the parent card, and only top-level bullets are counted.
expect(screen.getByText(/Parent insight/)).toBeInTheDocument();
expect(screen.getByText(/detail A/)).toBeInTheDocument();
expect(screen.getByText(/detail B/)).toBeInTheDocument();
expect(screen.getByText("Sibling insight")).toBeInTheDocument();
expect(document.querySelector(".memory-stat-value")?.textContent).toBe("2");
});
it("shows read-only warning after backend resolves as non-writable", () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
backendStatus: {
currentBackend: "readonly",
capabilities: {
readable: true,
writable: false,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: false,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
},
backendLoading: false,
}),
);
render(<MemoryView addToast={vi.fn()} />);
expect(screen.getByText("This memory backend is read-only. Changes cannot be saved.")).toBeInTheDocument();
});
it("does not show qmd-missing prompt before backend qmd availability resolves", async () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
backendStatus: null,
backendLoading: false,
auditLoading: false,
}),
);
render(<MemoryView addToast={vi.fn()} />);
await userEvent.click(screen.getByRole("tab", { name: "Engines" }));
expect(screen.queryByText(/qmd is not installed/i)).not.toBeInTheDocument();
expect(screen.getByText("Checking qmd availability…")).toBeInTheDocument();
});
it("shows qmd install prompt only when backend resolves qmd unavailable", async () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
backendStatus: {
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: false,
qmdInstallCommand: "bun install -g @tobilu/qmd",
},
}),
);
render(<MemoryView addToast={vi.fn()} />);
await userEvent.click(screen.getByRole("tab", { name: "Engines" }));
expect(screen.getByText(/qmd is not installed\. Search will use local files\./i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Install qmd" })).toBeInTheDocument();
});
it("shows qmd installed state when backend resolves qmd as available", async () => {
render(<MemoryView addToast={vi.fn()} />);
await userEvent.click(screen.getByRole("tab", { name: "Engines" }));
expect(screen.getByText("Installed")).toBeInTheDocument();
expect(screen.getByText("qmd is available on PATH.")).toBeInTheDocument();
});
it("shows Dream Now button when dreams are enabled", () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
memorySettings: {
memoryEnabled: true,
memoryAutoSummarizeEnabled: false,
memoryAutoSummarizeThresholdChars: 50000,
memoryAutoSummarizeSchedule: "0 3 * * *",
memoryDreamsEnabled: true,
memoryDreamsSchedule: "0 4 * * *",
},
}),
);
render(<MemoryView addToast={vi.fn()} />);
expect(screen.getByRole("button", { name: "Dream Now" })).toBeInTheDocument();
});
it("clicking Dream Now triggers processing API once and refreshes memory files", async () => {
const triggerDreamNow = vi.fn().mockResolvedValue({});
const reloadMemoryFiles = vi.fn().mockResolvedValue(undefined);
const addToast = vi.fn();
mockUseMemoryData.mockReturnValue(
createMemoryData({
triggerDreamNow,
reloadMemoryFiles,
memorySettings: {
memoryEnabled: true,
memoryAutoSummarizeEnabled: false,
memoryAutoSummarizeThresholdChars: 50000,
memoryAutoSummarizeSchedule: "0 3 * * *",
memoryDreamsEnabled: true,
memoryDreamsSchedule: "0 4 * * *",
},
}),
);
render(<MemoryView addToast={addToast} />);
await userEvent.click(screen.getByRole("button", { name: "Dream Now" }));
await waitFor(() => {
expect(triggerDreamNow).toHaveBeenCalledTimes(1);
expect(reloadMemoryFiles).toHaveBeenCalledTimes(1);
});
expect(addToast).toHaveBeenCalledWith("Dream processing completed", "success");
});
it("shows error toast when Dream Now processing fails", async () => {
const triggerDreamNow = vi.fn().mockRejectedValue(new Error("dream failed"));
const addToast = vi.fn();
mockUseMemoryData.mockReturnValue(
createMemoryData({
triggerDreamNow,
memorySettings: {
memoryEnabled: true,
memoryAutoSummarizeEnabled: false,
memoryAutoSummarizeThresholdChars: 50000,
memoryAutoSummarizeSchedule: "0 3 * * *",
memoryDreamsEnabled: true,
memoryDreamsSchedule: "0 4 * * *",
},
}),
);
render(<MemoryView addToast={addToast} />);
await userEvent.click(screen.getByRole("button", { name: "Dream Now" }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("dream failed", "error");
});
});
it("shows Dreaming loading state while processing is running", () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
dreamRunning: true,
memorySettings: {
memoryEnabled: true,
memoryAutoSummarizeEnabled: false,
memoryAutoSummarizeThresholdChars: 50000,
memoryAutoSummarizeSchedule: "0 3 * * *",
memoryDreamsEnabled: true,
memoryDreamsSchedule: "0 4 * * *",
},
}),
);
render(<MemoryView addToast={vi.fn()} />);
const button = screen.getByRole("button", { name: /Dreaming…/i });
expect(button).toBeDisabled();
});
it("truncates long memory file option labels to avoid wide native dropdown expansion", () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
memoryFiles: [
{
path: ".fusion/memory/very/deep/path/that/keeps/growing/until/the/browser/native/select/dropdown/can-overflow-on-the-right-edge.md",
label: "Long-term memory",
layer: "long-term",
size: 12,
updatedAt: "2026-04-17T12:00:00.000Z",
},
],
selectedFilePath: ".fusion/memory/very/deep/path/that/keeps/growing/until/the/browser/native/select/dropdown/can-overflow-on-the-right-edge.md",
}),
);
render(<MemoryView addToast={vi.fn()} />);
const option = screen.getByRole("option", { name: /Long-term memory/ });
expect(option.textContent).toContain("…");
expect(option.textContent).not.toContain("dropdown/can-overflow-on-the-right-edge.md");
});
it("hides Dream Now button when dreams are disabled", () => {
render(<MemoryView addToast={vi.fn()} />);
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
});
});
describe("MemoryView CSS contract", () => {
it("uses flex sizing for the memory editor and keeps flex-chain min-height guards", async () => {
const css = await loadAllAppCssBaseOnly();
expect(css).not.toMatch(/\.memory-editor-container\s*\{[^}]*height\s*:\s*calc\(100vh/i);
expect(css).toMatch(/\.memory-editor-container\s*\{[^}]*flex\s*:\s*1\s+1\s+auto;[^}]*min-height\s*:/);
expect(css).toMatch(/\.memory-view-content\s*\{[^}]*min-height\s*:\s*0\s*;/);
});
});