## Summary Re-lands the completed Fusion board task FX-005 on current upstream `main`, stacked on #2374 (FX-004). - adds a narrowly authorized additive publication path for archived task documents - preserves archived task and mission state and keeps ordinary replacement/deletion writes rejected - exposes retained archived current/revision reads - requires project-scoped revision/hash CAS for publication - maps malformed, unauthorized, missing, inconsistent, and stale states safely - rebases preserved dashboard drafts explicitly after CAS conflicts ## Why Operators need to append a correction or evidence revision to an archived task without unarchiving it or weakening ordinary archived-task immutability. ## Dependency This branch contains #2374 plus the eight FX-005 commits because cross-fork PRs cannot target a fork-only base branch. After #2374 lands, this PR should be rebased or refreshed so its diff collapses to FX-005 only. ## Validation - PostgreSQL task-store and archived-default suites: 33/33 - dashboard route and editor suites: 321/321 - agent document tools: 22/22 - core, dashboard, and engine typechecks pass <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added optimistic concurrency controls for task document creation and editing using revisions and content hashes. * Added safe, authenticated append-only corrections for documents retained on archived tasks. * Archived documents and revision history remain available for direct reading. * Agent and dashboard tools now report conflicts clearly and support explicit draft rebasing. * **Bug Fixes** * Prevented stale updates from overwriting newer document content. * Preserved archived-task immutability while allowing controlled corrections. * **Documentation** * Updated CLI, dashboard, storage, task-management, and agent guidance for these workflows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: fusion-merge-train <merge-train@topkoli.local> Co-authored-by: Fusion <noreply@runfusion.ai> Co-authored-by: v <v@v.speedport.ip> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
420 lines
16 KiB
TypeScript
420 lines
16 KiB
TypeScript
/*
|
|
FNXC:DashboardTests 2026-07-11-00:00:
|
|
FN-7833 changed per-task Artifacts-tab document defaults. Keep multi-document coverage here so the component cannot regress to single-expanded or Plain-first rendering while the separate Documents view remains out of scope.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
|
import type { ArtifactWithTask, TaskDocument } from "@fusion/core";
|
|
import { TaskDocumentsTab } from "../TaskDocumentsTab";
|
|
import { artifactMediaUrlWithToken, fetchTaskDocuments, fetchTaskDocumentRevisions, putTaskDocument } from "../../api";
|
|
import { useArtifacts } from "../../hooks/useArtifacts";
|
|
|
|
vi.mock("../../api", () => ({
|
|
fetchTaskDocuments: vi.fn(),
|
|
fetchTaskDocument: vi.fn(),
|
|
fetchTaskDocumentRevisions: vi.fn(),
|
|
putTaskDocument: vi.fn(),
|
|
deleteTaskDocument: vi.fn(),
|
|
artifactMediaUrlWithToken: vi.fn((id: string) => `/api/artifacts/${id}/media?fn_token=daemon-token`),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useArtifacts", () => ({
|
|
useArtifacts: vi.fn(),
|
|
}));
|
|
|
|
const mockFetchTaskDocuments = vi.mocked(fetchTaskDocuments);
|
|
const mockFetchTaskDocumentRevisions = vi.mocked(fetchTaskDocumentRevisions);
|
|
const mockArtifactMediaUrlWithToken = vi.mocked(artifactMediaUrlWithToken);
|
|
const mockPutTaskDocument = vi.mocked(putTaskDocument);
|
|
const mockUseArtifacts = vi.mocked(useArtifacts);
|
|
|
|
function getDocumentCard(key: string): HTMLElement {
|
|
const keyElement = screen.getAllByText(key).find((element) => element.classList.contains("task-document-key"));
|
|
expect(keyElement).toBeDefined();
|
|
return keyElement!.closest(".task-document-card") as HTMLElement;
|
|
}
|
|
|
|
const mockArtifacts: ArtifactWithTask[] = [
|
|
{
|
|
id: "artifact-image",
|
|
type: "image",
|
|
title: "Image artifact",
|
|
description: "Screenshot from the agent",
|
|
authorId: "agent-image",
|
|
taskId: "KB-001",
|
|
createdAt: "2026-04-19T10:00:00.000Z",
|
|
sizeBytes: 2048,
|
|
},
|
|
{
|
|
id: "artifact-video",
|
|
type: "video",
|
|
title: "Video artifact",
|
|
authorId: "agent-video",
|
|
taskId: "KB-001",
|
|
createdAt: "2026-04-19T10:01:00.000Z",
|
|
},
|
|
{
|
|
id: "artifact-audio",
|
|
type: "audio",
|
|
title: "Audio artifact",
|
|
authorId: "agent-audio",
|
|
taskId: "KB-001",
|
|
createdAt: "2026-04-19T10:02:00.000Z",
|
|
},
|
|
{
|
|
id: "artifact-document",
|
|
type: "document",
|
|
title: "Document artifact",
|
|
content: "Inline document preview",
|
|
authorId: "agent-doc",
|
|
taskId: "KB-001",
|
|
createdAt: "2026-04-19T10:03:00.000Z",
|
|
},
|
|
{
|
|
id: "artifact-other",
|
|
type: "other",
|
|
title: "Other artifact",
|
|
authorId: "agent-other",
|
|
taskId: "KB-001",
|
|
createdAt: "2026-04-19T10:04:00.000Z",
|
|
},
|
|
];
|
|
|
|
const mockDocuments: TaskDocument[] = [
|
|
{
|
|
id: "doc-1",
|
|
taskId: "KB-001",
|
|
key: "plan",
|
|
content: "This is the **plan** content",
|
|
revision: 1,
|
|
contentHash: `sha256:${"a".repeat(64)}`,
|
|
author: "agent",
|
|
createdAt: "2026-04-19T10:00:00.000Z",
|
|
updatedAt: "2026-04-19T12:00:00.000Z",
|
|
},
|
|
{
|
|
id: "doc-2",
|
|
taskId: "KB-001",
|
|
key: "notes",
|
|
content: "# Notes\n\n- Item 1\n- Item 2",
|
|
revision: 2,
|
|
contentHash: `sha256:${"b".repeat(64)}`,
|
|
author: "user",
|
|
createdAt: "2026-04-19T09:00:00.000Z",
|
|
updatedAt: "2026-04-19T11:00:00.000Z",
|
|
},
|
|
];
|
|
|
|
describe("TaskDocumentsTab", () => {
|
|
const addToast = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
window.localStorage.clear();
|
|
mockFetchTaskDocuments.mockResolvedValue(mockDocuments);
|
|
mockFetchTaskDocumentRevisions.mockResolvedValue([]);
|
|
mockArtifactMediaUrlWithToken.mockImplementation((id: string) => `/api/artifacts/${id}/media?fn_token=daemon-token`);
|
|
mockUseArtifacts.mockReturnValue({
|
|
artifacts: [],
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
});
|
|
|
|
it("explicitly rebases a preserved conflict draft before saving with the refreshed baseline", async () => {
|
|
const refreshedDocuments = mockDocuments.map((doc) => doc.key === "plan" ? {
|
|
...doc,
|
|
content: "Concurrent server content",
|
|
revision: 2,
|
|
contentHash: `sha256:${"c".repeat(64)}`,
|
|
} : doc);
|
|
mockFetchTaskDocuments
|
|
.mockResolvedValueOnce(mockDocuments)
|
|
.mockResolvedValue(refreshedDocuments);
|
|
mockPutTaskDocument
|
|
.mockRejectedValueOnce(Object.assign(new Error("stale"), { status: 409 }))
|
|
.mockResolvedValueOnce(refreshedDocuments[0]);
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" canEdit />);
|
|
|
|
const card = await waitFor(() => getDocumentCard("plan"));
|
|
fireEvent.click(within(card).getByRole("button", { name: "Edit" }));
|
|
const editor = within(card).getByRole("textbox");
|
|
fireEvent.change(editor, { target: { value: "My preserved draft" } });
|
|
fireEvent.click(within(card).getByRole("button", { name: "Save" }));
|
|
|
|
const rebaseButton = await within(card).findByRole("button", { name: "Rebase draft" });
|
|
expect(editor).toHaveValue("My preserved draft");
|
|
expect(within(card).getByRole("button", { name: "Save" })).toBeDisabled();
|
|
fireEvent.click(rebaseButton);
|
|
fireEvent.click(within(card).getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => expect(mockPutTaskDocument).toHaveBeenNthCalledWith(2, "KB-001", "plan", "My preserved draft", {
|
|
expectedRevision: 2,
|
|
expectedContentHash: `sha256:${"c".repeat(64)}`,
|
|
}, "project-1"));
|
|
await waitFor(() => expect(within(card).queryByRole("textbox")).not.toBeInTheDocument());
|
|
});
|
|
|
|
it("renders the renamed Artifacts heading with document list", async () => {
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("heading", { name: "Artifacts" })).toBeInTheDocument();
|
|
expect(screen.getAllByText("plan").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
expect(screen.getByText("notes")).toBeInTheDocument();
|
|
expect(screen.getByRole("heading", { name: "Task documents" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows loading until documents and artifacts resolve", () => {
|
|
mockUseArtifacts.mockReturnValue({
|
|
artifacts: [],
|
|
loading: true,
|
|
error: null,
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
|
|
|
expect(screen.getByText("Loading documents and artifacts…")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows combined empty state when no documents or artifacts", async () => {
|
|
mockFetchTaskDocuments.mockResolvedValue([]);
|
|
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("No documents or artifacts yet.")).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByText("No task documents yet.")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders documents-only state", async () => {
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText("plan").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
expect(screen.queryByRole("heading", { name: "Media artifacts" })).not.toBeInTheDocument();
|
|
expect(screen.getByText("2 documents")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders artifacts-only state", async () => {
|
|
mockFetchTaskDocuments.mockResolvedValue([]);
|
|
mockUseArtifacts.mockReturnValue({
|
|
artifacts: mockArtifacts,
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("heading", { name: "Media artifacts" })).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText("5 artifacts")).toBeInTheDocument();
|
|
expect(screen.getByText("No task documents yet.")).toBeInTheDocument();
|
|
expect(screen.queryByText("No documents or artifacts yet.")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders both documents and all five media artifact paths", async () => {
|
|
mockUseArtifacts.mockReturnValue({
|
|
artifacts: mockArtifacts,
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText("plan").length).toBeGreaterThan(0);
|
|
expect(screen.getByRole("heading", { name: "Media artifacts" })).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
|
|
expect(screen.getByRole("button", { name: "Expand image artifact Image artifact" })).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: /Expand image artifact Video artifact/ })).not.toBeInTheDocument();
|
|
expect(screen.getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO");
|
|
expect(screen.getByLabelText("Audio artifact: Audio artifact").tagName).toBe("AUDIO");
|
|
expect(screen.getByTestId("artifact-document-preview")).toHaveTextContent("Inline document preview");
|
|
expect(screen.getByTestId("artifact-other-link")).toHaveAttribute("href", "/api/artifacts/artifact-other/media?fn_token=daemon-token");
|
|
expect(screen.getByText("agent-image")).toBeInTheDocument();
|
|
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
|
|
expect(document.querySelector(".documents-artifact-gallery--mobile")).not.toBeNull();
|
|
expect(mockUseArtifacts).toHaveBeenCalledWith({ projectId: "project-1", taskId: "KB-001" });
|
|
expect(mockArtifactMediaUrlWithToken).toHaveBeenCalledWith("artifact-image", "project-1");
|
|
});
|
|
|
|
it("opens image artifacts in a task-detail lightbox and restores focus on close", async () => {
|
|
mockFetchTaskDocuments.mockResolvedValue([]);
|
|
mockUseArtifacts.mockReturnValue({
|
|
artifacts: mockArtifacts,
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
|
|
|
|
const expandButton = await screen.findByRole("button", { name: "Expand image artifact Image artifact" });
|
|
expandButton.focus();
|
|
fireEvent.click(expandButton);
|
|
|
|
const dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
|
|
expect(dialog).toBeInTheDocument();
|
|
expect(screen.getAllByRole("img", { name: "Image artifact" })[1]).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Close artifact preview" }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument();
|
|
});
|
|
expect(expandButton).toHaveFocus();
|
|
});
|
|
|
|
it("closes the image artifact lightbox with Escape", async () => {
|
|
mockFetchTaskDocuments.mockResolvedValue([]);
|
|
mockUseArtifacts.mockReturnValue({
|
|
artifacts: mockArtifacts,
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: "Expand image artifact Image artifact" }));
|
|
expect(screen.getByRole("dialog", { name: "Artifact media preview" })).toBeInTheDocument();
|
|
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("traps keyboard focus inside the image artifact lightbox", async () => {
|
|
mockUseArtifacts.mockReturnValue({
|
|
artifacts: mockArtifacts,
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: "Expand image artifact Image artifact" }));
|
|
const closeButton = screen.getByRole("button", { name: "Close artifact preview" });
|
|
expect(closeButton).toHaveFocus();
|
|
|
|
fireEvent.keyDown(document, { key: "Tab" });
|
|
expect(closeButton).toHaveFocus();
|
|
|
|
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
|
|
expect(closeButton).toHaveFocus();
|
|
|
|
screen.getAllByRole("button", { name: "Collapse" })[0].focus();
|
|
fireEvent.keyDown(document, { key: "Tab" });
|
|
expect(closeButton).toHaveFocus();
|
|
});
|
|
|
|
it("surfaces artifact fetch errors", async () => {
|
|
mockUseArtifacts.mockReturnValue({
|
|
artifacts: [],
|
|
loading: false,
|
|
error: "Artifact fetch failed",
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("Artifact fetch failed", "error");
|
|
});
|
|
});
|
|
|
|
it("renders every task document expanded as markdown by default", async () => {
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
|
|
|
const planStrong = (await screen.findAllByText("plan")).find((element) => element.tagName === "STRONG");
|
|
expect(planStrong).toBeDefined();
|
|
expect(screen.getByRole("heading", { name: "Notes" })).toBeInTheDocument();
|
|
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
|
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
|
expect(document.querySelectorAll(".task-document-content-markdown .markdown-body")).toHaveLength(2);
|
|
expect(document.querySelector("pre.task-document-content-text")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("collapses only the selected document while other documents stay expanded", async () => {
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
|
|
|
await screen.findByText("Item 1");
|
|
const planCard = getDocumentCard("plan");
|
|
|
|
fireEvent.click(within(planCard).getByRole("button", { name: /collapse/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("This is the")).not.toBeInTheDocument();
|
|
});
|
|
expect(screen.getByRole("heading", { name: "Notes" })).toBeInTheDocument();
|
|
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
|
expect(within(planCard).getByRole("button", { name: /expand/i })).toBeInTheDocument();
|
|
});
|
|
|
|
describe("markdown toggle", () => {
|
|
it("defaults to markdown render mode", async () => {
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
|
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".task-document-content-markdown strong")?.textContent).toBe("plan");
|
|
});
|
|
expect(screen.getAllByRole("button", { name: /switch to plain text/i })[0]).toHaveAttribute("aria-pressed", "true");
|
|
expect(screen.queryByText(/\*\*plan\*\*/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("toggles to raw text mode", async () => {
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
|
|
|
const toggleBtn = (await screen.findAllByRole("button", { name: /switch to plain text/i }))[0];
|
|
fireEvent.click(toggleBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(document.querySelectorAll("pre.task-document-content-text")).toHaveLength(2);
|
|
});
|
|
expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
|
expect(screen.getByText(/# Notes/)).toBeInTheDocument();
|
|
expect(document.querySelector(".task-document-content-markdown")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("persists raw text preference across collapse and re-expand", async () => {
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
|
|
|
fireEvent.click((await screen.findAllByRole("button", { name: /switch to plain text/i }))[0]);
|
|
await waitFor(() => expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument());
|
|
|
|
const planCard = getDocumentCard("plan");
|
|
fireEvent.click(within(planCard).getByRole("button", { name: /collapse/i }));
|
|
fireEvent.click(within(planCard).getByRole("button", { name: /expand/i }));
|
|
|
|
expect(await screen.findByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
|
expect(within(planCard).getByRole("button", { name: /switch to markdown/i })).toHaveAttribute("aria-pressed", "false");
|
|
});
|
|
|
|
it("renders markdown list syntax without an extra toggle click", async () => {
|
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
|
|
|
const heading = await screen.findByRole("heading", { name: "Notes" });
|
|
expect(heading).toBeInTheDocument();
|
|
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
|
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|