mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 01:18:45 +00:00
* feat: support document attachments in webui * fix(webui): normalize document attachment MIME * refactor(webui): move attachment policy out of channel * fix(webui): reject oversized attachments before send * fix(webui): align Portuguese attachment errors * refactor(webui): separate ingress and transport limits * fix(webui): reject malformed attachment payloads
430 lines
13 KiB
TypeScript
430 lines
13 KiB
TypeScript
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
|
import type { EncodeResponse } from "@/lib/imageEncode";
|
|
import type { WebUIIngressLimits } from "@/lib/types";
|
|
|
|
const encodeImage = vi.fn<(file: File) => Promise<EncodeResponse>>();
|
|
|
|
vi.mock("@/lib/imageEncode", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/lib/imageEncode")>();
|
|
return {
|
|
...actual,
|
|
encodeImage: (file: File) => encodeImage(file),
|
|
};
|
|
});
|
|
|
|
function pngFile(name = "a.png", size = 10) {
|
|
return new File([new Uint8Array(size)], name, { type: "image/png" });
|
|
}
|
|
|
|
function pdfFile(name = "report.pdf", size = 8) {
|
|
return new File([new Uint8Array(size)], name, { type: "application/pdf" });
|
|
}
|
|
|
|
function csvFile(name = "report.csv", type = "application/vnd.ms-excel") {
|
|
return new File(["name,value\nnanobot,1"], name, { type });
|
|
}
|
|
|
|
function resolveReady(file: File): EncodeResponse {
|
|
return {
|
|
id: "stub",
|
|
ok: true,
|
|
dataUrl: `data:image/png;base64,${btoa(file.name)}`,
|
|
mime: "image/png",
|
|
bytes: file.size,
|
|
origBytes: file.size,
|
|
normalized: false,
|
|
};
|
|
}
|
|
|
|
function ingressLimits({
|
|
maxFrameBytes = 36 * 1024 * 1024,
|
|
maxTextBytes = 64 * 1024,
|
|
maxFileBytes = 6 * 1024 * 1024,
|
|
maxTotalBytes = 24 * 1024 * 1024,
|
|
}: {
|
|
maxFrameBytes?: number;
|
|
maxTextBytes?: number;
|
|
maxFileBytes?: number;
|
|
maxTotalBytes?: number;
|
|
} = {}): WebUIIngressLimits {
|
|
return {
|
|
transport: {
|
|
max_frame_bytes: maxFrameBytes,
|
|
envelope_reserve_bytes: 64 * 1024,
|
|
},
|
|
message: { max_text_bytes: maxTextBytes },
|
|
attachments: {
|
|
max_count: 4,
|
|
max_file_bytes: maxFileBytes,
|
|
max_total_bytes: maxTotalBytes,
|
|
},
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
encodeImage.mockReset();
|
|
let id = 0;
|
|
// Tests never read the preview URL contents so a stable blob: stub is fine.
|
|
if (!(globalThis.URL as unknown as { createObjectURL?: unknown }).createObjectURL) {
|
|
(globalThis.URL as unknown as { createObjectURL: (b: Blob) => string }).createObjectURL =
|
|
() => `blob:mock/${++id}`;
|
|
}
|
|
if (!(globalThis.URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL) {
|
|
(globalThis.URL as unknown as { revokeObjectURL: (u: string) => void }).revokeObjectURL =
|
|
() => {};
|
|
}
|
|
});
|
|
|
|
describe("ThreadComposer — attachments", () => {
|
|
it("attaches a picked image and includes its data url on send", async () => {
|
|
const file = pngFile("a.png");
|
|
encodeImage.mockResolvedValueOnce(resolveReady(file));
|
|
const onSend = vi.fn();
|
|
|
|
render(<ThreadComposer onSend={onSend} />);
|
|
|
|
const input = screen
|
|
.getByLabelText(/message input/i)
|
|
.closest("form")!
|
|
.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [file] } });
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("composer-chip")).toBeInTheDocument(),
|
|
);
|
|
|
|
const textarea = screen.getByLabelText(/message input/i);
|
|
fireEvent.change(textarea, { target: { value: "hi" } });
|
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
|
|
|
expect(onSend).toHaveBeenCalledTimes(1);
|
|
const [content, images] = onSend.mock.calls[0];
|
|
expect(content).toBe("hi");
|
|
expect(images).toHaveLength(1);
|
|
expect(images[0].media.data_url).toContain("data:image/png;base64,");
|
|
expect(images[0].media.name).toBe("a.png");
|
|
});
|
|
|
|
it("attaches a picked PDF and includes its data url on send", async () => {
|
|
const file = pdfFile();
|
|
const onSend = vi.fn();
|
|
|
|
render(<ThreadComposer onSend={onSend} />);
|
|
|
|
const input = screen
|
|
.getByLabelText(/message input/i)
|
|
.closest("form")!
|
|
.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [file] } });
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("composer-chip")).toHaveTextContent("report.pdf"),
|
|
);
|
|
|
|
const textarea = screen.getByLabelText(/message input/i);
|
|
fireEvent.change(textarea, { target: { value: "summarize" } });
|
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
|
|
|
expect(encodeImage).not.toHaveBeenCalled();
|
|
const [content, attachments] = onSend.mock.calls[0];
|
|
expect(content).toBe("summarize");
|
|
expect(attachments).toHaveLength(1);
|
|
expect(attachments[0].media.data_url).toContain("data:application/pdf;base64,");
|
|
expect(attachments[0].media.name).toBe("report.pdf");
|
|
expect(attachments[0].preview.kind).toBe("file");
|
|
});
|
|
|
|
it.each(["application/vnd.ms-excel", "image/png"])(
|
|
"normalizes document MIME from the file extension when the browser reports %s",
|
|
async (browserMime) => {
|
|
const file = csvFile("report.csv", browserMime);
|
|
const onSend = vi.fn();
|
|
|
|
render(<ThreadComposer onSend={onSend} />);
|
|
|
|
const input = screen
|
|
.getByLabelText(/message input/i)
|
|
.closest("form")!
|
|
.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [file] } });
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("composer-chip")).toHaveTextContent("report.csv"),
|
|
);
|
|
|
|
const textarea = screen.getByLabelText(/message input/i);
|
|
fireEvent.change(textarea, { target: { value: "summarize" } });
|
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
|
|
|
const [, attachments] = onSend.mock.calls[0];
|
|
expect(attachments[0].media.data_url).toMatch(/^data:text\/csv;base64,/);
|
|
expect(encodeImage).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("rejects empty attachments before sending them to the gateway", async () => {
|
|
const file = new File([], "empty.csv", { type: "application/vnd.ms-excel" });
|
|
const onSend = vi.fn();
|
|
|
|
render(<ThreadComposer onSend={onSend} />);
|
|
|
|
const input = screen
|
|
.getByLabelText(/message input/i)
|
|
.closest("form")!
|
|
.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [file] } });
|
|
});
|
|
|
|
expect(screen.getByText("Empty files cannot be attached")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument();
|
|
expect(encodeImage).not.toHaveBeenCalled();
|
|
expect(onSend).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an oversized document before adding a chip", async () => {
|
|
const file = pdfFile("oversized.pdf", 6 * 1024 * 1024 + 1);
|
|
|
|
render(<ThreadComposer onSend={vi.fn()} />);
|
|
|
|
const input = screen
|
|
.getByLabelText(/message input/i)
|
|
.closest("form")!
|
|
.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [file] } });
|
|
});
|
|
|
|
expect(screen.getByRole("alert")).toHaveTextContent("File is too large");
|
|
expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("reports a transport limit separately from attachment policy", async () => {
|
|
const first = pdfFile("first.pdf", 400 * 1024);
|
|
const second = pdfFile("second.pdf", 400 * 1024);
|
|
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
ingressLimits={ingressLimits({ maxFrameBytes: 1024 * 1024 })}
|
|
/>,
|
|
);
|
|
|
|
const input = screen
|
|
.getByLabelText(/message input/i)
|
|
.closest("form")!
|
|
.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [first, second] } });
|
|
});
|
|
|
|
expect(screen.getByRole("alert")).toHaveTextContent(
|
|
"gateway transport limit",
|
|
);
|
|
expect(screen.getAllByTestId("composer-chip")).toHaveLength(1);
|
|
expect(screen.getByText("first.pdf")).toBeInTheDocument();
|
|
expect(screen.queryByText("second.pdf")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("enforces the decoded attachment-total policy independently", async () => {
|
|
const first = pdfFile("first.pdf", 400 * 1024);
|
|
const second = pdfFile("second.pdf", 400 * 1024);
|
|
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
ingressLimits={ingressLimits({ maxTotalBytes: 700 * 1024 })}
|
|
/>,
|
|
);
|
|
|
|
const input = screen
|
|
.getByLabelText(/message input/i)
|
|
.closest("form")!
|
|
.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [first, second] } });
|
|
});
|
|
|
|
expect(screen.getByRole("alert")).toHaveTextContent(
|
|
"Attachments are too large together",
|
|
);
|
|
expect(screen.getAllByTestId("composer-chip")).toHaveLength(1);
|
|
});
|
|
|
|
it("enforces the text-byte policy without changing attachment limits", () => {
|
|
const onSend = vi.fn();
|
|
render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
ingressLimits={ingressLimits({ maxTextBytes: 4 })}
|
|
/>,
|
|
);
|
|
|
|
const textarea = screen.getByLabelText(/message input/i);
|
|
fireEvent.change(textarea, { target: { value: "你好" } });
|
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
|
|
|
expect(screen.getByRole("alert")).toHaveTextContent(
|
|
"Message text is too large (max 4 B)",
|
|
);
|
|
expect(onSend).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts supported documents from paste and drop", async () => {
|
|
const pasted = pdfFile("pasted.pdf");
|
|
const dropped = pdfFile("dropped.pdf");
|
|
const onSend = vi.fn();
|
|
|
|
render(<ThreadComposer onSend={onSend} />);
|
|
|
|
const textarea = screen.getByLabelText(/message input/i);
|
|
const form = textarea.closest("form")!;
|
|
|
|
await act(async () => {
|
|
fireEvent.paste(textarea, {
|
|
clipboardData: {
|
|
files: [pasted],
|
|
items: [{
|
|
kind: "file",
|
|
type: pasted.type,
|
|
getAsFile: () => pasted,
|
|
}],
|
|
types: ["Files"],
|
|
getData: () => "",
|
|
},
|
|
});
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByText("pasted.pdf")).toBeInTheDocument(),
|
|
);
|
|
|
|
await act(async () => {
|
|
fireEvent.drop(form, {
|
|
dataTransfer: {
|
|
files: [dropped],
|
|
items: [],
|
|
types: ["Files"],
|
|
dropEffect: "copy",
|
|
},
|
|
});
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByText("dropped.pdf")).toBeInTheDocument(),
|
|
);
|
|
expect(screen.getAllByTestId("composer-chip")).toHaveLength(2);
|
|
expect(encodeImage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks send while an image is still encoding", async () => {
|
|
const file = pngFile("slow.png");
|
|
let resolveEncode: (r: EncodeResponse) => void = () => {};
|
|
encodeImage.mockReturnValueOnce(
|
|
new Promise((r) => {
|
|
resolveEncode = r;
|
|
}),
|
|
);
|
|
const onSend = vi.fn();
|
|
|
|
render(<ThreadComposer onSend={onSend} />);
|
|
|
|
const fileInput = screen
|
|
.getByLabelText(/message input/i)
|
|
.closest("form")!
|
|
.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
fireEvent.change(fileInput, { target: { files: [file] } });
|
|
});
|
|
|
|
const textarea = screen.getByLabelText(/message input/i);
|
|
fireEvent.change(textarea, { target: { value: "hello" } });
|
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
|
expect(onSend).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
resolveEncode(resolveReady(file));
|
|
await Promise.resolve();
|
|
});
|
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
|
expect(onSend).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps a plain-text paste untouched without adding a chip", async () => {
|
|
const onSend = vi.fn();
|
|
render(<ThreadComposer onSend={onSend} />);
|
|
const textarea = screen.getByLabelText(/message input/i);
|
|
|
|
fireEvent.paste(textarea, {
|
|
clipboardData: {
|
|
files: [],
|
|
items: [
|
|
{
|
|
kind: "string",
|
|
type: "text/plain",
|
|
getAsFile: () => null,
|
|
},
|
|
],
|
|
types: ["text/plain"],
|
|
getData: () => "some pasted text",
|
|
},
|
|
});
|
|
|
|
expect(screen.queryByTestId("composer-chip")).toBeNull();
|
|
expect(encodeImage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("surfaces an inline error when encoding fails", async () => {
|
|
const file = pngFile("bad.png");
|
|
encodeImage.mockResolvedValueOnce({
|
|
id: "stub",
|
|
ok: false,
|
|
reason: "decode_failed",
|
|
} as EncodeResponse);
|
|
const onSend = vi.fn();
|
|
|
|
render(<ThreadComposer onSend={onSend} />);
|
|
const fileInput = screen
|
|
.getByLabelText(/message input/i)
|
|
.closest("form")!
|
|
.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
fireEvent.change(fileInput, { target: { files: [file] } });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const chip = screen.getByTestId("composer-chip");
|
|
expect(chip.textContent ?? "").toMatch(/decode|image/i);
|
|
});
|
|
|
|
const textarea = screen.getByLabelText(/message input/i);
|
|
fireEvent.change(textarea, { target: { value: "hi" } });
|
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
|
expect(onSend).not.toHaveBeenCalled();
|
|
});
|
|
});
|