Files
fusion/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx
gsxdsm 7137e36ccb FN-7073: serve file previews inline
File viewer previews now request inline-safe responses while downloads remain attachments.

- Add an inline preview query option to file download URL helpers and preview consumers.
- Serve known media and PDF preview extensions with real MIME types, inline disposition, nosniff, and sandbox CSP headers.
- Keep explicit downloads and unknown file types on attachment/octet-stream behavior.
- Cover preview and download header behavior with dashboard route and component tests.

Files changed:
 .changeset/fn-7073-file-viewer-inline-preview.md   |   7 +
 docs/dashboard-guide.md                            |   2 +-
 packages/dashboard/app/api/legacy.ts               |   9 +-
 .../dashboard/app/components/DockFilesView.tsx     |   2 +-
 .../dashboard/app/components/FileBrowserModal.tsx  |   2 +-
 .../components/__tests__/DockFilesView.test.tsx    |  14 +-
 .../components/__tests__/FileBrowserModal.test.tsx |  12 +-
 .../register-file-workspace-routes.test.ts         | 141 +++++++++++++++++++++
 .../src/routes/register-file-workspace-routes.ts   | 111 +++++++++++-----
 9 files changed, 260 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-7073
Fusion-Task-Lineage: 922b4aa4-ac6d-4a59-b94f-bab4eeb65530
2026-06-26 14:33:58 -07:00

339 lines
14 KiB
TypeScript

import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { DockFilesView } from "../DockFilesView";
import { getScopedItem, scopedKey } from "../../utils/projectStorage";
import type { FileNode } from "../../api";
/*
FNXC:RightDockFiles 2026-06-22-23:30:
Proves the current-file path is shared between the dock instance and the popped-out (expand) instance via scoped storage: selecting a file in the dock persists it, and a freshly mounted expand instance reads it on mount and opens the SAME file in its viewer pane.
*/
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string, options?: Record<string, string>) => {
const value = fallback ?? _key;
return options?.file ? value.replace("{{file}}", options.file) : value;
},
}),
}));
let entries: FileNode[] = [];
const defaultEntries: FileNode[] = [
{ name: "readme.md", type: "file", size: 10, mtime: "2026-01-15T10:30:00Z" },
{ name: "changed.txt", type: "file", size: 12, mtime: "2026-01-15T10:30:00Z" },
{ name: "assets/Logo.PNG", type: "file", size: 1024, mtime: "2026-01-15T10:30:00Z" },
{ name: "media/demo.mp4", type: "file", size: 2048, mtime: "2026-01-15T10:30:00Z" },
{ name: "sounds/theme.MP3", type: "file", size: 2048, mtime: "2026-01-15T10:30:00Z" },
{ name: "docs/manual.PDF", type: "file", size: 4096, mtime: "2026-01-15T10:30:00Z" },
{ name: "build/output.bin", type: "file", size: 8192, mtime: "2026-01-15T10:30:00Z" },
];
const dockFilesCss = readFileSync(resolve(__dirname, "../DockFilesView.css"), "utf8");
vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
useWorkspaceFileBrowser: () => ({
entries,
currentPath: "",
setPath: vi.fn(),
loading: false,
error: null,
refresh: vi.fn(),
}),
}));
const mockFetchContent = vi.fn(() => Promise.resolve({ content: "# hi" }));
const mockSaveContent = vi.fn(() => Promise.resolve({ mtime: "2026-01-15T10:31:00Z" }));
const mockDownloadFileUrl = vi.fn((workspace: string, filePath: string, projectId?: string, options?: { inline?: boolean }) => {
const params = new URLSearchParams({ workspace });
if (projectId) params.set("projectId", projectId);
if (options?.inline) params.set("inline", "1");
return `/api/files/${encodeURIComponent(filePath)}/download?${params.toString()}`;
});
vi.mock("../../api", () => ({
fetchWorkspaceFileContent: (...args: unknown[]) => mockFetchContent(...(args as [])),
saveWorkspaceFileContent: (...args: unknown[]) => mockSaveContent(...(args as [])),
downloadFileUrl: (...args: unknown[]) => mockDownloadFileUrl(...(args as [string, string, string | undefined, { inline?: boolean } | undefined])),
}));
const capturedEditorHookCalls: Array<{
workspace: string;
filePath: string | null;
enabled: boolean;
projectId?: string;
}> = [];
const mockSetContent = vi.fn();
const mockSave = vi.fn(() => Promise.resolve());
vi.mock("../../hooks/useWorkspaceFileEditor", () => ({
useWorkspaceFileEditor: (workspace: string, filePath: string | null, enabled: boolean, projectId?: string) => {
capturedEditorHookCalls.push({ workspace, filePath, enabled, projectId });
const hasChanges = filePath === "changed.txt";
return {
content: filePath ? `content for ${filePath}` : "",
setContent: mockSetContent,
originalContent: hasChanges ? "original" : filePath ? `content for ${filePath}` : "",
loading: false,
saving: false,
error: null,
save: mockSave,
hasChanges,
mtime: null,
};
},
}));
const capturedFileEditorProps: Array<{
filePath?: string;
toolbarExpanded?: boolean;
forceToolbarActionsVisible?: boolean;
showLineNumbers?: boolean;
onToggleLineNumbers?: () => void;
readOnly?: boolean;
}> = [];
// Keep the viewer simple: surface the file path it was asked to render and capture toolbar props.
vi.mock("../FileEditor", () => ({
FileEditor: (props: {
filePath?: string;
toolbarExpanded?: boolean;
forceToolbarActionsVisible?: boolean;
showLineNumbers?: boolean;
onToggleLineNumbers?: () => void;
readOnly?: boolean;
}) => {
capturedFileEditorProps.push(props);
return <div data-testid="mock-file-editor" data-file-path={props.filePath} />;
},
}));
// Render the tree's files as buttons so we can click one.
vi.mock("../FileBrowser", () => ({
FileBrowser: ({ entries: e, onSelectFile }: { entries: FileNode[]; onSelectFile: (p: string) => void }) => (
<div data-testid="mock-file-browser">
{e.map((entry) => (
<button key={entry.name} type="button" onClick={() => onSelectFile(entry.name)}>
{entry.name}
</button>
))}
</div>
),
}));
const PROJECT_ID = "proj-1";
const KEY = scopedKey("kb-dashboard-dock-files-current", PROJECT_ID);
describe("DockFilesView shared current-file state", () => {
beforeEach(() => {
window.localStorage.clear();
entries = [...defaultEntries];
mockFetchContent.mockClear();
mockSaveContent.mockClear();
mockDownloadFileUrl.mockClear();
mockSetContent.mockClear();
mockSave.mockClear();
capturedFileEditorProps.length = 0;
capturedEditorHookCalls.length = 0;
});
afterEach(() => cleanup());
it("keeps right-dock Files view dividers tokenized and invisible by default", () => {
/*
FNXC:RightDockChrome 2026-06-23-19:10:
The default Files dock view must not draw extra header or pane dividers unless a theme opts into the right-dock divider token.
*/
expect(dockFilesCss).toContain("border-bottom: var(--chrome-divider-width, 1px) solid var(--right-dock-view-divider-color, transparent);");
expect(dockFilesCss).toContain("border-right: var(--chrome-divider-width, 1px) solid var(--right-dock-view-divider-color, transparent);");
expect(dockFilesCss).not.toContain("border-bottom: 1px solid var(--border);");
expect(dockFilesCss).not.toContain("border-right: 1px solid var(--border);");
});
it("persists the selected file to scoped storage and a fresh expand instance reads it on mount", async () => {
// 1. Dock instance: select a file.
const dock = render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
fireEvent.click(screen.getByText("readme.md"));
// The path was persisted to the shared scoped key.
expect(getScopedItem("kb-dashboard-dock-files-current", PROJECT_ID)).toBe("readme.md");
await waitFor(() => {
expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md");
});
// 2. Unmount the dock; mount a SEPARATE expand instance (two-pane pop-out).
dock.unmount();
render(<DockFilesView projectId={PROJECT_ID} layout="two-pane" />);
// The expand instance opened the SAME file from storage on mount.
await waitFor(() => {
expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md");
});
expect(screen.queryByTestId("right-dock-files-empty")).toBeNull();
expect(screen.getByTestId("right-dock-files-view")).toHaveAttribute("data-layout", "two-pane");
});
it("clearing the file (back) clears the shared key", () => {
render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
fireEvent.click(screen.getByText("readme.md"));
expect(getScopedItem("kb-dashboard-dock-files-current", PROJECT_ID)).toBe("readme.md");
fireEvent.click(screen.getByTestId("right-dock-files-back"));
expect(getScopedItem("kb-dashboard-dock-files-current", PROJECT_ID)).toBeNull();
expect(screen.getByTestId("right-dock-files-empty")).toBeInTheDocument();
});
it("live-syncs from a cross-instance storage event", async () => {
render(<DockFilesView projectId={PROJECT_ID} layout="two-pane" />);
expect(screen.getByTestId("right-dock-files-empty")).toBeInTheDocument();
act(() => {
window.localStorage.setItem(KEY, "readme.md");
window.dispatchEvent(new StorageEvent("storage", { key: KEY, newValue: "readme.md" }));
});
await waitFor(() => {
expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md");
});
});
it("uses the full modal/mobile file editor toolbar in the right dock viewer", async () => {
render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
fireEvent.click(screen.getByText("readme.md"));
await waitFor(() => {
expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md");
});
const latest = capturedFileEditorProps.at(-1);
expect(latest).toMatchObject({
filePath: "readme.md",
toolbarExpanded: true,
forceToolbarActionsVisible: true,
showLineNumbers: true,
});
expect(latest?.readOnly).toBeFalsy();
expect(latest?.onToggleLineNumbers).toEqual(expect.any(Function));
expect(screen.getByTestId("right-dock-files-save")).toBeDisabled();
});
it.each([
{
layout: "auto" as const,
file: "assets/Logo.PNG",
selector: "img.file-browser-preview-media--image",
attr: "src",
expectedEnabled: false,
},
{
layout: "auto" as const,
file: "media/demo.mp4",
selector: "video.file-browser-preview-media--video",
attr: "src",
expectedEnabled: false,
},
{
layout: "auto" as const,
file: "sounds/theme.MP3",
selector: "audio.file-browser-preview-media--audio",
attr: "src",
expectedEnabled: false,
},
{
layout: "auto" as const,
file: "docs/manual.PDF",
selector: "iframe.file-browser-preview-media--pdf",
attr: "src",
expectedEnabled: false,
},
{
layout: "two-pane" as const,
file: "assets/Logo.PNG",
selector: "img.file-browser-preview-media--image",
attr: "src",
expectedEnabled: false,
},
{
layout: "two-pane" as const,
file: "media/demo.mp4",
selector: "video.file-browser-preview-media--video",
attr: "src",
expectedEnabled: false,
},
{
layout: "two-pane" as const,
file: "sounds/theme.MP3",
selector: "audio.file-browser-preview-media--audio",
attr: "src",
expectedEnabled: false,
},
{
layout: "two-pane" as const,
file: "docs/manual.PDF",
selector: "iframe.file-browser-preview-media--pdf",
attr: "src",
expectedEnabled: false,
},
])("renders $file as a native preview in $layout layout without loading the editor", async ({ layout, file, selector, attr }) => {
/*
FNXC:RightDockFiles 2026-06-25-00:00:
Symptom verification: browser-previewable files in both compact and pop-out dock layouts must use native preview elements backed by downloadFileUrl, not the FileEditor/CodeMirror binary-text path.
*/
render(<DockFilesView projectId={PROJECT_ID} layout={layout} />);
fireEvent.click(screen.getByText(file));
await waitFor(() => expect(document.querySelector(selector)).toBeInTheDocument());
const preview = document.querySelector(selector);
expect(preview).toHaveAttribute(attr, `/api/files/${encodeURIComponent(file)}/download?workspace=project&projectId=${PROJECT_ID}&inline=1`);
if (selector.startsWith("video") || selector.startsWith("audio")) {
expect(preview).toHaveAttribute("controls");
expect(preview).toHaveAttribute("aria-label", `Preview for ${file}`);
}
if (selector.startsWith("iframe")) {
expect(preview).toHaveAttribute("title", `Preview for ${file}`);
}
expect(screen.queryByTestId("mock-file-editor")).toBeNull();
expect(screen.queryByTestId("right-dock-files-save")).toBeNull();
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: file, enabled: false, projectId: PROJECT_ID });
expect(mockDownloadFileUrl).toHaveBeenLastCalledWith("project", file, PROJECT_ID, { inline: true });
});
it("keeps text files editable with Save when changes exist", async () => {
render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
fireEvent.click(screen.getByText("changed.txt"));
await waitFor(() => expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "changed.txt"));
expect(screen.getByTestId("right-dock-files-save")).toBeEnabled();
expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument();
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: "changed.txt", enabled: true, projectId: PROJECT_ID });
expect(mockDownloadFileUrl).not.toHaveBeenCalledWith("project", "changed.txt", PROJECT_ID, { inline: true });
});
it("keeps known non-preview binary files read-only without rendering garbage editor content", () => {
render(<DockFilesView projectId={PROJECT_ID} layout="two-pane" />);
fireEvent.click(screen.getByText("build/output.bin"));
expect(screen.getByTestId("right-dock-files-binary-read-only")).toHaveTextContent("Binary file — read only");
expect(screen.queryByTestId("mock-file-editor")).toBeNull();
expect(screen.queryByTestId("right-dock-files-save")).toBeNull();
expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument();
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: "build/output.bin", enabled: false, projectId: PROJECT_ID });
expect(mockDownloadFileUrl).not.toHaveBeenCalledWith("project", "build/output.bin", PROJECT_ID, { inline: true });
});
it("clears stale preview state when switching from a preview file back to text", async () => {
render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
fireEvent.click(screen.getByText("assets/Logo.PNG"));
await waitFor(() => expect(document.querySelector("img.file-browser-preview-media--image")).toBeInTheDocument());
expect(document.querySelector("img.file-browser-preview-media--image")).toHaveAttribute("src", expect.stringContaining("inline=1"));
fireEvent.click(screen.getByText("readme.md"));
await waitFor(() => expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md"));
expect(document.querySelector("img.file-browser-preview-media--image")).not.toBeInTheDocument();
expect(screen.getByTestId("right-dock-files-save")).toBeDisabled();
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: "readme.md", enabled: true, projectId: PROJECT_ID });
});
});