fix(webui): validate inferred file paths before preview (#4935)
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
createModelConfiguration,
|
||||
deleteSession,
|
||||
fetchFilePreview,
|
||||
fetchFilePreviewAvailability,
|
||||
fetchAutomations,
|
||||
fetchApiService,
|
||||
fetchCliApps,
|
||||
@@ -102,6 +103,32 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("probes file preview availability without requesting contents", async () => {
|
||||
await expect(
|
||||
fetchFilePreviewAvailability("tok", "websocket:chat-1", "notes/ready.md"),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/file-preview?path=notes%2Fready.md&probe=1",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
credentials: "same-origin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when a file preview probe is unavailable", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ available: false }),
|
||||
} as Response);
|
||||
|
||||
await expect(
|
||||
fetchFilePreviewAvailability("tok", "websocket:chat-1", "notes/missing.md"),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys when fetching session automations", async () => {
|
||||
await fetchSessionAutomations("tok", "websocket:chat-1");
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
|
||||
|
||||
describe("MarkdownTextRenderer", () => {
|
||||
@@ -34,6 +35,75 @@ describe("MarkdownTextRenderer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unavailable inferred inline file paths non-interactive", async () => {
|
||||
const onOpenFilePreview = vi.fn();
|
||||
const resolve = vi.fn().mockResolvedValue(false);
|
||||
render(
|
||||
<FilePreviewAvailabilityProvider resolve={resolve}>
|
||||
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
|
||||
{"Future file: `notes/missing.md`"}
|
||||
</MarkdownTextRenderer>
|
||||
</FilePreviewAvailabilityProvider>,
|
||||
);
|
||||
|
||||
const reference = screen.getByTestId("inline-file-path");
|
||||
expect(reference).toHaveTextContent("missing.md");
|
||||
await waitFor(() => expect(resolve).toHaveBeenCalledWith("notes/missing.md"));
|
||||
expect(reference).not.toHaveAttribute("role");
|
||||
expect(reference).not.toHaveAttribute("tabindex");
|
||||
|
||||
fireEvent.click(reference);
|
||||
|
||||
expect(onOpenFilePreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps inferred inline file paths non-interactive when availability lookup fails", async () => {
|
||||
const onOpenFilePreview = vi.fn();
|
||||
let rejectAvailability!: (reason?: unknown) => void;
|
||||
const resolve = vi.fn(() => new Promise<boolean>((_resolve, reject) => {
|
||||
rejectAvailability = reject;
|
||||
}));
|
||||
render(
|
||||
<FilePreviewAvailabilityProvider resolve={resolve}>
|
||||
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
|
||||
{"Unreadable file: `notes/locked.md`"}
|
||||
</MarkdownTextRenderer>
|
||||
</FilePreviewAvailabilityProvider>,
|
||||
);
|
||||
|
||||
const reference = screen.getByTestId("inline-file-path");
|
||||
await waitFor(() => expect(resolve).toHaveBeenCalledWith("notes/locked.md"));
|
||||
await act(async () => {
|
||||
rejectAvailability(new Error("probe failed"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(reference).not.toHaveAttribute("role");
|
||||
expect(reference).not.toHaveAttribute("tabindex");
|
||||
fireEvent.click(reference);
|
||||
expect(onOpenFilePreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("makes available inferred inline file paths previewable", async () => {
|
||||
const onOpenFilePreview = vi.fn();
|
||||
const resolve = vi.fn().mockResolvedValue(true);
|
||||
render(
|
||||
<FilePreviewAvailabilityProvider resolve={resolve}>
|
||||
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
|
||||
{"Existing file: `notes/ready.md`"}
|
||||
</MarkdownTextRenderer>
|
||||
</FilePreviewAvailabilityProvider>,
|
||||
);
|
||||
|
||||
const reference = screen.getByTestId("inline-file-path");
|
||||
await waitFor(() => expect(reference).toHaveAttribute("role", "button"));
|
||||
expect(reference).toHaveAttribute("tabindex", "0");
|
||||
|
||||
fireEvent.click(reference);
|
||||
|
||||
expect(onOpenFilePreview).toHaveBeenCalledWith("notes/ready.md");
|
||||
});
|
||||
|
||||
it("does not treat non-file hrefs as previews just because the label looks like a file", () => {
|
||||
const onOpenFilePreview = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -231,6 +231,59 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps inferred file paths non-interactive when the availability probe fails", async () => {
|
||||
const client = makeClient();
|
||||
let resolveProbe!: (value: Response) => void;
|
||||
const probe = new Promise<Response>((resolve) => {
|
||||
resolveProbe = resolve;
|
||||
});
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Apreview-error/webui-thread")) {
|
||||
return Promise.resolve(httpJson(transcriptFromSimpleMessages([
|
||||
{ role: "assistant", content: "Unreadable file: `prompts/dream.md`" },
|
||||
])));
|
||||
}
|
||||
if (url.includes("websocket%3Apreview-error/file-preview?")) return probe;
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("preview-error")}
|
||||
title="Preview error"
|
||||
onToggleSidebar={() => {}}
|
||||
/>,
|
||||
));
|
||||
|
||||
const reference = await screen.findByTestId("inline-file-path");
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("file-preview?path=prompts%2Fdream.md&probe=1"),
|
||||
expect.anything(),
|
||||
));
|
||||
await act(async () => {
|
||||
resolveProbe({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "failed to read file",
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
await probe;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(reference).not.toHaveAttribute("role");
|
||||
expect(reference).not.toHaveAttribute("tabindex");
|
||||
fireEvent.click(reference);
|
||||
expect(screen.queryByText("failed to read file")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not navigate away when clicking the chat title", async () => {
|
||||
const client = makeClient();
|
||||
const onGoHome = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user