feat(FN-2220): merge fusion/fn-2220

This commit is contained in:
gsxdsm
2026-04-22 01:31:40 -07:00
parent 421926d82f
commit a5800392f1
14 changed files with 568 additions and 68 deletions

View File

@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { Folder, File, ChevronRight, Loader2, Copy, Move, Trash2, Pencil, Download, Archive } from "lucide-react";
import type { FileNode } from "../api";
import { copyFile, moveFile, deleteFile, renameFile, downloadFileUrl, downloadZipUrl } from "../api";
import { appendTokenQuery } from "../auth";
interface FileBrowserProps {
entries: FileNode[];
@@ -447,14 +448,14 @@ export function FileBrowser({
if (action === "download") {
if (!workspace) return;
const url = downloadFileUrl(workspace, fullPath, projectId);
window.open(url, "_blank");
window.open(appendTokenQuery(url), "_blank");
return;
}
if (action === "download-zip") {
if (!workspace) return;
const url = downloadZipUrl(workspace, fullPath, projectId);
window.open(url, "_blank");
window.open(appendTokenQuery(url), "_blank");
return;
}

View File

@@ -15,6 +15,7 @@ import {
import type { ToastType } from "../hooks/useToast";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { appendTokenQuery } from "../auth";
/** Provider-specific API key setup metadata for onboarding form rendering */
interface ApiKeyInfo {
@@ -863,7 +864,7 @@ export function ModelOnboardingModal({
try {
const { url } = await loginProvider(providerId);
window.open(url, "_blank");
window.open(appendTokenQuery(url), "_blank");
// Poll for auth completion
pollIntervalRef.current = setInterval(async () => {

View File

@@ -14,6 +14,7 @@ import { PiExtensionsManager } from "./PiExtensionsManager";
import { PluginSlot } from "./PluginSlot";
import { AgentPromptsManager } from "./AgentPromptsManager";
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
import { appendTokenQuery } from "../auth";
/**
* Settings sections configuration.
@@ -363,7 +364,7 @@ export function SettingsModal({
setAuthActionInProgress(providerId);
try {
const { url } = await loginProvider(providerId);
window.open(url, "_blank");
window.open(appendTokenQuery(url), "_blank");
// Poll for auth completion every 2 seconds
pollIntervalRef.current = setInterval(async () => {

View File

@@ -20,6 +20,7 @@ import { TaskDocumentsTab } from "./TaskDocumentsTab";
import { PluginSlot } from "./PluginSlot";
import { subscribeSse } from "../sse-bus";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { appendTokenQuery } from "../auth";
interface ModelSelection {
provider?: string;
@@ -1510,32 +1511,35 @@ export function TaskDetailModal({
<h4>Attachments</h4>
{attachments.length > 0 ? (
<div className="detail-attachments-grid">
{attachments.map((a) => (
<div key={a.filename} className="detail-attachment-card">
<a
className="detail-attachment-link"
href={`/api/tasks/${task.id}/attachments/${a.filename}`}
target="_blank"
rel="noopener noreferrer"
>
<img
src={`/api/tasks/${task.id}/attachments/${a.filename}`}
alt={a.originalName}
className="detail-attachment-image"
/>
</a>
<div className="detail-attachment-meta">
{a.originalName} ({formatBytes(a.size)})
{attachments.map((a) => {
const attachmentUrl = appendTokenQuery(`/api/tasks/${task.id}/attachments/${a.filename}`);
return (
<div key={a.filename} className="detail-attachment-card">
<a
className="detail-attachment-link"
href={attachmentUrl}
target="_blank"
rel="noopener noreferrer"
>
<img
src={attachmentUrl}
alt={a.originalName}
className="detail-attachment-image"
/>
</a>
<div className="detail-attachment-meta">
{a.originalName} ({formatBytes(a.size)})
</div>
<button
className="detail-attachment-delete"
onClick={() => handleDeleteAttachment(a.filename)}
title="Delete attachment"
>
×
</button>
</div>
<button
className="detail-attachment-delete"
onClick={() => handleDeleteAttachment(a.filename)}
title="Delete attachment"
>
×
</button>
</div>
))}
);
})}
</div>
) : (
<div className="detail-empty-inline">(no attachments)</div>

View File

@@ -5,6 +5,7 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { FileBrowser } from "../FileBrowser";
import type { FileNode } from "../../api";
import { clearAuthToken } from "../../auth";
// Resolve paths relative to this test file so tests pass regardless of cwd
// (a global test safety guard may change cwd to a per-worker temp dir).
@@ -33,16 +34,20 @@ const mockCopyFile = vi.fn();
const mockMoveFile = vi.fn();
const mockDeleteFile = vi.fn();
const mockRenameFile = vi.fn();
const mockDownloadFileUrl = vi.fn((_workspace: string, filePath: string) =>
`/api/files/${encodeURIComponent(filePath)}/download?workspace=test-ws`,
);
const mockDownloadZipUrl = vi.fn((_workspace: string, filePath: string) =>
`/api/files/${encodeURIComponent(filePath)}/download-zip?workspace=test-ws`,
);
vi.mock("../../api", () => ({
copyFile: (...args: any[]) => mockCopyFile(...args),
moveFile: (...args: any[]) => mockMoveFile(...args),
deleteFile: (...args: any[]) => mockDeleteFile(...args),
renameFile: (...args: any[]) => mockRenameFile(...args),
downloadFileUrl: (_workspace: string, filePath: string) =>
`/api/files/${encodeURIComponent(filePath)}/download?workspace=test-ws`,
downloadZipUrl: (_workspace: string, filePath: string) =>
`/api/files/${encodeURIComponent(filePath)}/download-zip?workspace=test-ws`,
downloadFileUrl: (...args: any[]) => mockDownloadFileUrl(...args),
downloadZipUrl: (...args: any[]) => mockDownloadZipUrl(...args),
}));
// ── Test Data ───────────────────────────────────────────────────────────
@@ -99,6 +104,14 @@ function touchStart(entryName: string, coords: { x: number; y: number } = { x: 2
describe("FileBrowser", () => {
beforeEach(() => {
vi.clearAllMocks();
clearAuthToken();
localStorage.removeItem("fn.authToken");
mockDownloadFileUrl.mockImplementation((_workspace: string, filePath: string) =>
`/api/files/${encodeURIComponent(filePath)}/download?workspace=test-ws`,
);
mockDownloadZipUrl.mockImplementation((_workspace: string, filePath: string) =>
`/api/files/${encodeURIComponent(filePath)}/download-zip?workspace=test-ws`,
);
vi.useRealTimers();
Object.defineProperty(window, "innerWidth", {
configurable: true,
@@ -118,6 +131,8 @@ describe("FileBrowser", () => {
});
afterEach(() => {
clearAuthToken();
localStorage.removeItem("fn.authToken");
cleanup();
});
@@ -394,6 +409,19 @@ describe("FileBrowser", () => {
openSpy.mockRestore();
});
it("appends daemon token query for same-origin file download URLs", () => {
localStorage.setItem("fn.authToken", "daemon-token");
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderFileBrowser();
contextMenuClick("readme.md");
fireEvent.click(screen.getByText("Download"));
expect(openSpy).toHaveBeenCalledWith(
"/api/files/readme.md/download?workspace=test-ws&fn_token=daemon-token",
"_blank"
);
openSpy.mockRestore();
});
it("opens download-zip URL for directory when Download as ZIP is clicked", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderFileBrowser();
@@ -406,6 +434,19 @@ describe("FileBrowser", () => {
openSpy.mockRestore();
});
it("does not append daemon token query for cross-origin download URLs", () => {
localStorage.setItem("fn.authToken", "daemon-token");
mockDownloadFileUrl.mockReturnValue("https://downloads.example.com/readme.md");
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderFileBrowser();
contextMenuClick("readme.md");
fireEvent.click(screen.getByText("Download"));
expect(openSpy).toHaveBeenCalledWith("https://downloads.example.com/readme.md", "_blank");
openSpy.mockRestore();
});
// ── Delete Dialog ───────────────────────────────────────────────────
it("shows delete confirmation dialog when Delete is clicked", () => {

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
import { ModelOnboardingModal } from "../ModelOnboardingModal";
import type { AuthProvider } from "../../api";
import { clearAuthToken } from "../../auth";
import type { Task } from "@fusion/core";
// Mock the API module
@@ -131,6 +132,8 @@ async function navigateToFirstTaskStep() {
beforeEach(() => {
vi.clearAllMocks();
clearAuthToken();
localStorage.removeItem("fn.authToken");
mockTrackOnboardingEvent.mockReset();
mockFetchModels.mockResolvedValue({ models: defaultModels, favoriteProviders: [], favoriteModels: [] });
mockFetchGlobalSettings.mockResolvedValue({});
@@ -155,8 +158,10 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
clearAuthToken();
// Clean up localStorage
localStorage.removeItem("kb-onboarding-state");
localStorage.removeItem("fn.authToken");
});
describe("ModelOnboardingModal", () => {
@@ -450,7 +455,8 @@ describe("ModelOnboardingModal", () => {
});
});
it("initiates OAuth login when Login is clicked", async () => {
it("initiates OAuth login when Login is clicked without appending token to external provider URLs", async () => {
localStorage.setItem("fn.authToken", "daemon-token");
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
@@ -468,6 +474,28 @@ describe("ModelOnboardingModal", () => {
});
});
it("appends daemon query token for same-origin OAuth login popup URLs", async () => {
localStorage.setItem("fn.authToken", "daemon-token");
mockLoginProvider.mockResolvedValueOnce({ url: "/api/auth/providers/anthropic/login?state=xyz" });
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
fireEvent.click(screen.getByText("Login"));
await waitFor(() => {
expect(mockWindowOpen).toHaveBeenCalledWith(
"/api/auth/providers/anthropic/login?state=xyz&fn_token=daemon-token",
"_blank",
);
});
});
it("saves API key when Save is clicked", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);

View File

@@ -1,9 +1,10 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useState } from "react";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TaskDetailModal } from "../TaskDetailModal";
import type { TaskDetail, Column, MergeResult, Task } from "@fusion/core";
import { clearAuthToken } from "../../auth";
vi.mock("../../api", () => ({
uploadAttachment: vi.fn(),
@@ -93,6 +94,16 @@ const noopRetry = vi.fn(async () => ({}) as Task);
const noopOpenDetail = vi.fn();
describe("TaskDetailModal", () => {
beforeEach(() => {
clearAuthToken();
localStorage.removeItem("fn.authToken");
});
afterEach(() => {
clearAuthToken();
localStorage.removeItem("fn.authToken");
});
it("renders markdown-body without detail-prompt class when prompt exists", () => {
const { container } = render(
<TaskDetailModal
@@ -180,6 +191,72 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Comments")).toBeTruthy();
});
it("appends daemon token query to attachment href/src URLs for direct browser loads", () => {
localStorage.setItem("fn.authToken", "daemon-token");
render(
<TaskDetailModal
task={makeTask({
attachments: [
{
filename: "screenshot.png",
originalName: "Screenshot",
mimeType: "image/png",
size: 1024,
createdAt: "2026-01-01T00:00:00Z",
},
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const attachmentLink = screen.getByRole("link", { name: "Screenshot" });
const attachmentImage = screen.getByAltText("Screenshot");
expect(attachmentLink.getAttribute("href")).toBe(
"/api/tasks/FN-099/attachments/screenshot.png?fn_token=daemon-token",
);
expect(attachmentImage.getAttribute("src")).toBe(
"/api/tasks/FN-099/attachments/screenshot.png?fn_token=daemon-token",
);
});
it("leaves attachment href/src URLs unchanged when no daemon token is present", () => {
render(
<TaskDetailModal
task={makeTask({
attachments: [
{
filename: "screenshot.png",
originalName: "Screenshot",
mimeType: "image/png",
size: 1024,
createdAt: "2026-01-01T00:00:00Z",
},
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const attachmentLink = screen.getByRole("link", { name: "Screenshot" });
const attachmentImage = screen.getByAltText("Screenshot");
expect(attachmentLink.getAttribute("href")).toBe("/api/tasks/FN-099/attachments/screenshot.png");
expect(attachmentImage.getAttribute("src")).toBe("/api/tasks/FN-099/attachments/screenshot.png");
});
it("renders Retry button when task status is 'failed' (in Actions dropdown)", () => {
render(
<TaskDetailModal