hermes-agent/web/src/lib/chatImagePaste.test.ts
Brooklyn Nicholson 301acc9eaa fix(web): paste/drop images into dashboard Chat via HERMES_HOME/images
Dashboard Chat is an xterm mirror of a TUI inside the gateway, so
server-side clipboard.paste never sees the browser clipboard. Upload
pasted/dropped images to the profile's images/ dir (same place
clipboard.paste / image.attach use), then drive /image over the PTY.

Uses a dedicated /api/chat/image-upload endpoint (magic-byte check,
25MB cap, profile scope) instead of relative managed-files uploads that
400 on local dashboards without a locked root. Ctrl/Cmd+Shift+V also
tries clipboard.read() for images before falling back to text, since
preventDefault on that chord suppresses the DOM paste event.

Salvages #57912 (client composition + /image PTY drive) and folds in
#48563's upload endpoint + drop path.

Co-authored-by: bird <6666242+bird@users.noreply.github.com>
Co-authored-by: tt-a1i <53142663+tt-a1i@users.noreply.github.com>
2026-07-10 02:20:04 -05:00

99 lines
2.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
firstImageFromClipboard,
imageFilesFromTransfer,
transferMayContainImage,
} from "./chatImagePaste";
// Minimal DataTransfer stand-ins. jsdom's DataTransfer doesn't let us seed
// items/files, so we hand-roll the shape the helpers read.
function makeItem(kind: string, type: string, file: File | null) {
return { kind, type, getAsFile: () => file } as unknown as DataTransferItem;
}
function makeData(opts: {
items?: DataTransferItem[];
files?: File[];
}): DataTransfer {
const items = opts.items ?? [];
const files = opts.files ?? [];
const itemList: Record<string | number, unknown> = { length: items.length };
items.forEach((it, i) => {
itemList[i] = it;
});
const fileList: Record<string | number, unknown> = { length: files.length };
files.forEach((f, i) => {
fileList[i] = f;
});
return {
items: itemList,
files: fileList,
} as unknown as DataTransfer;
}
const png = new File([new Uint8Array([1, 2, 3])], "x.png", {
type: "image/png",
});
const gif = new File([new Uint8Array([4, 5])], "y.gif", {
type: "image/gif",
});
describe("firstImageFromClipboard", () => {
it("returns null for null clipboard data", () => {
expect(firstImageFromClipboard(null)).toBeNull();
});
it("finds an image via items[].getAsFile()", () => {
const data = makeData({ items: [makeItem("file", "image/png", png)] });
expect(firstImageFromClipboard(data)).toBe(png);
});
it("ignores non-file and non-image items", () => {
const data = makeData({
items: [
makeItem("string", "text/plain", null),
makeItem("file", "application/pdf", new File([], "a.pdf")),
],
});
expect(firstImageFromClipboard(data)).toBeNull();
});
it("falls back to files[] when items are absent (Safari/Firefox)", () => {
const data = makeData({ files: [png] });
expect(firstImageFromClipboard(data)).toBe(png);
});
it("returns null when nothing image-like is present", () => {
const data = makeData({
files: [new File([], "notes.txt", { type: "text/plain" })],
});
expect(firstImageFromClipboard(data)).toBeNull();
});
});
describe("imageFilesFromTransfer", () => {
it("dedupes the same file when present in both items and files", () => {
const data = makeData({
items: [makeItem("file", "image/png", png)],
files: [png, gif],
});
expect(imageFilesFromTransfer(data)).toEqual([png, gif]);
});
});
describe("transferMayContainImage", () => {
it("is true for image items even when type is empty (some browsers)", () => {
const data = makeData({
items: [makeItem("file", "", png)],
});
expect(transferMayContainImage(data)).toBe(true);
});
it("is false for text-only transfers", () => {
const data = makeData({
items: [makeItem("string", "text/plain", null)],
});
expect(transferMayContainImage(data)).toBe(false);
});
});