Files
fusion/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx
gsxdsm 5ff7a20738 FN-7976: fix mailbox artifact open and view-task popups
Fix Mailbox/Artifacts media auth and ensure View task always opens a usable popup.

- Add artifactMediaUrlWithToken for authenticated img/video/audio/link loads while keeping artifactMediaUrl token-free for fetch and HTML previews
- Load script-capable HTML artifact previews via Authorization + revocable blob URL so tokens never reach allow-scripts iframes
- Keep non-board/list task popups (Mailbox, Documents) visible even when board/list-only popup gating is enabled
- Upgrade duplicate popOut entries so reopening a task refreshes snapshot and origin
- Document the behavior and add a patch changeset

Files changed:
 .changeset/fn-7976-mailbox-artifact-fixes.md       |  7 +++
 docs/dashboard-guide.md                            |  2 +-
 packages/dashboard/app/App.tsx                     | 15 +++--
 .../app/__tests__/App.taskPopupViewGating.test.tsx | 10 ++-
 .../dashboard/app/__tests__/api-artifacts.test.ts  | 12 +++-
 .../api/__tests__/legacy-artifact-media.test.ts    | 27 ++++++++
 packages/dashboard/app/api/legacy.ts               | 21 +++++--
 .../dashboard/app/components/ArtifactsGallery.tsx  | 72 ++++++++++++++++++----
 .../dashboard/app/components/DocumentsView.tsx     |  4 +-
 .../app/components/MailboxArtifactAttachment.tsx   |  6 +-
 .../dashboard/app/components/TaskDocumentsTab.tsx  |  6 +-
 .../components/__tests__/DocumentsView.test.tsx    | 31 ++++++----
 .../__tests__/MailboxArtifactAttachment.test.tsx   | 24 ++++----
 .../app/components/__tests__/MailboxView.test.tsx  |  8 +--
 .../components/__tests__/TaskDocumentsTab.test.tsx | 16 ++---
 .../app/hooks/__tests__/usePoppedOutTasks.test.ts  |  9 ++-
 packages/dashboard/app/hooks/usePoppedOutTasks.ts  | 17 +++--
 17 files changed, 206 insertions(+), 81 deletions(-)

Fusion-Task-Id: FN-7976

Fusion-Task-Lineage: 4c25b3a6-5836-4629-b33e-647f213e3261

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-15 15:30:55 -07:00

383 lines
15 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 } 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 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,
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,
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("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();
});
});
});