feat(FN-2220): merge fusion/fn-2220
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user