Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions __tests__/components/features/files-tab/file-content-viewer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { FileContentViewer } from "#/components/features/files-tab/file-content-viewer";
import type { ViewMode } from "#/components/features/files-tab/view-mode";
import { useWorkspaceMutationCounter } from "#/stores/use-workspace-mutation-counter";

// Mock the *services* the file-content hook depends on — not the hook itself —
// so the real classification (text decoded, then flipped to binary on a NUL
// sniff) runs end to end through the viewer.
const useWorkspaceSessionMock = vi.fn();
vi.mock("#/hooks/query/use-workspace-session", async (importOriginal) => {
const real =
await importOriginal<
typeof import("#/hooks/query/use-workspace-session")
>();
return {
...real, // keep the real joinWorkspaceUrl the hook builds its fetch URL with
useWorkspaceSession: () => useWorkspaceSessionMock(),
};
});

const useActiveConversationMock = vi.fn();
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => useActiveConversationMock(),
}));

const useRuntimeIsReadyMock = vi.fn();
vi.mock("#/hooks/use-runtime-is-ready", () => ({
useRuntimeIsReady: () => useRuntimeIsReadyMock(),
}));

const getActiveBackendMock = vi.fn();
vi.mock("#/api/backend-registry/active-store", () => ({
getActiveBackend: () => getActiveBackendMock(),
Comment thread
hieptl marked this conversation as resolved.
}));

// The hook statically imports the cloud runtime service; stub the module so
// this local-path test never loads the real cloud/proxy machinery. The test
// uses the fetch (local) path, so downloadFile is never called or asserted.
vi.mock("#/api/runtime-service/agent-server-runtime-service", () => ({
default: { downloadFile: vi.fn() },
}));

const fetchMock = vi.fn();

const BASE_URL =
"https://agent.example.com/api/conversations/conv-1/workspace/";

function renderViewer(path: string, viewMode: ViewMode = "rich") {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<FileContentViewer path={path} viewMode={viewMode} />
</QueryClientProvider>,
);
}

describe("FileContentViewer", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockReset();
useWorkspaceSessionMock.mockReset();
useActiveConversationMock.mockReset();
useRuntimeIsReadyMock.mockReset();
getActiveBackendMock.mockReset();

useRuntimeIsReadyMock.mockReturnValue(true);
useActiveConversationMock.mockReturnValue({
data: {
id: "conv-1",
conversation_url: "https://agent.example.com/api/conversations/conv-1",
session_api_key: "session-key",
},
});
useWorkspaceSessionMock.mockReturnValue({
data: { baseUrl: BASE_URL },
isLoading: false,
isError: false,
error: null,
});
getActiveBackendMock.mockReturnValue({
backend: { id: "local-1", kind: "local", host: "http://localhost:8000" },
orgId: null,
});
useWorkspaceMutationCounter.setState({ count: 0 });
});

afterEach(() => {
vi.unstubAllGlobals();
});

// The acceptance criteria require the clear message in BOTH view modes. The
// plain-mode fallback and the rich-mode binary branch both route through
// UnpreviewableFallback, so one parametrized spec covers both code paths.
it.each(["rich", "plain"] as const)(
"shows a clear unsupported-document message for an Office file (.pptx) in %s mode",
async (viewMode) => {
// Arrange: the workspace fileserver returns real .pptx bytes — a ZIP whose
// header carries a NUL, so the hook classifies the file as binary.
fetchMock.mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: () =>
Promise.resolve(
new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00]).buffer,
),
});

// Act
renderViewer("demo.pptx", viewMode);

// Assert: the format-aware "no preview" message replaces the generic
// binary fallback in both modes, so the pane is never blank.
expect(
await screen.findByTestId("file-content-viewer-unsupported-document"),
).toBeInTheDocument();
},
);
});
63 changes: 39 additions & 24 deletions src/components/features/files-tab/file-content-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,47 @@ interface FileContentViewerProps {
const HTML_LIKE_EXTS = new Set(["html", "htm", "svg"]);
const MARKDOWN_EXTS = new Set(["md", "markdown", "mdx"]);

// Office/document formats we can't preview inline. The label doubles as the
// allow-list (a present entry => Office doc) and feeds a clear, format-named
// "no preview" message instead of the generic binary fallback.
const OFFICE_DOCUMENT_LABELS: Record<string, string> = {
pptx: "PowerPoint",
ppt: "PowerPoint",
docx: "Word",
doc: "Word",
xlsx: "Excel",
xls: "Excel",
};

function getExtension(path: string): string {
const idx = path.lastIndexOf(".");
return idx === -1 ? "" : path.slice(idx + 1).toLowerCase();
}

/**
* Fallback shown when a file's bytes aren't previewable. Office documents
* (.pptx / .docx / .xlsx …) get a clear, format-named message; every other
* binary keeps the generic "binary file" string so the pane is never blank.
*/
function UnpreviewableFallback({ path }: { path: string }) {
const { t } = useTranslation("openhands");
const documentLabel = OFFICE_DOCUMENT_LABELS[getExtension(path)];
return (
<div
className="flex h-full w-full items-center justify-center text-sm text-[var(--oh-muted)]"
data-testid={
documentLabel
? "file-content-viewer-unsupported-document"
: "file-content-viewer-binary-fallback"
}
>
{documentLabel
? t(I18nKey.FILES$UNSUPPORTED_DOCUMENT, { type: documentLabel })
: t(I18nKey.FILES$BINARY_FALLBACK)}
</div>
);
}

/**
* Renders the contents of a single workspace file. In `rich` mode we point
* an iframe / <img> straight at the agent server's static workspace
Expand Down Expand Up @@ -82,14 +118,7 @@ export function FileContentViewer({ path, viewMode }: FileContentViewerProps) {
/>
);
}
return (
<div
className="flex h-full w-full items-center justify-center text-sm text-[var(--oh-muted)]"
data-testid="file-content-viewer-binary-fallback"
>
{t(I18nKey.FILES$BINARY_FALLBACK)}
</div>
);
return <UnpreviewableFallback path={path} />;
}

// ----- Rich mode: render HTML, markdown, images, PDFs from staticUrl. ----
Expand Down Expand Up @@ -129,14 +158,7 @@ export function FileContentViewer({ path, viewMode }: FileContentViewerProps) {
}

if (kind === "binary") {
return (
<div
className="flex h-full w-full items-center justify-center text-sm text-[var(--oh-muted)]"
data-testid="file-content-viewer-binary-fallback"
>
{t(I18nKey.FILES$BINARY_FALLBACK)}
</div>
);
return <UnpreviewableFallback path={path} />;
}

// Text-like content.
Expand Down Expand Up @@ -202,12 +224,5 @@ export function FileContentViewer({ path, viewMode }: FileContentViewerProps) {

// Truly unknown / empty payload — show a fallback so the pane is never
// blank.
return (
<div
className="flex h-full w-full items-center justify-center text-sm text-[var(--oh-muted)]"
data-testid="file-content-viewer-binary-fallback"
>
{t(I18nKey.FILES$BINARY_FALLBACK)}
</div>
);
return <UnpreviewableFallback path={path} />;
}
17 changes: 17 additions & 0 deletions src/i18n/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -21640,6 +21640,23 @@
"uk": "Двійковий файл – попередній перегляд недоступний",
"ca": "Fitxer binari – previsualització no disponible"
},
"FILES$UNSUPPORTED_DOCUMENT": {
"en": "Preview isn't available for {{type}} files.",
"ja": "{{type}} ファイルのプレビューは利用できません。",
"zh-CN": "{{type}} 文件无法预览。",
"zh-TW": "{{type}} 檔案無法預覽。",
"ko-KR": "{{type}} 파일은 미리 보기를 사용할 수 없습니다.",
"no": "Forhåndsvisning er ikke tilgjengelig for {{type}}-filer.",
"it": "L'anteprima non è disponibile per i file {{type}}.",
"pt": "A visualização não está disponível para arquivos {{type}}.",
"es": "La vista previa no está disponible para archivos {{type}}.",
"ar": "المعاينة غير متاحة لملفات {{type}}.",
"fr": "L'aperçu n'est pas disponible pour les fichiers {{type}}.",
"tr": "{{type}} dosyaları için önizleme kullanılamıyor.",
"de": "Für {{type}}-Dateien ist keine Vorschau verfügbar.",
"uk": "Попередній перегляд недоступний для файлів {{type}}.",
"ca": "La previsualització no està disponible per als fitxers {{type}}."
},
"FILES$LOADING_FILES": {
"en": "Loading files…",
"ja": "ファイルを読み込んでいます…",
Expand Down
Loading