Replace image hook tests with typed ports

This commit is contained in:
Mohamed Boudra
2026-07-28 16:45:40 +02:00
parent e53dcf2a6c
commit 4735bfb8c8
6 changed files with 260 additions and 275 deletions
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { AttachmentMetadata } from "@/attachments/types";
import {
createAssistantImageFileAcquisition,
type AssistantImageFileAcquisitionPort,
} from "./file-acquisition";
class MemoryFileAcquisitionPort implements AssistantImageFileAcquisitionPort {
readonly reads: Array<{ cwd: string; path: string }> = [];
async readFile(cwd: string, path: string) {
this.reads.push({ cwd, path });
return {
kind: "image" as const,
path,
mime: "image/png",
size: 4,
modifiedAt: "1",
bytes: new Uint8Array([1, 2, 3, 4]),
};
}
async persist(input: { id: string; mimeType: string; fileName: string | null }) {
return {
id: input.id,
mimeType: input.mimeType,
storageType: "web-indexeddb" as const,
storageKey: input.id,
fileName: input.fileName,
byteSize: 4,
createdAt: 1,
} satisfies AttachmentMetadata;
}
}
describe("assistant image file acquisition", () => {
it("recreates the same acquisition with a live port after reconnect", async () => {
const common = {
resolution: { kind: "file_rpc" as const, cwd: "/workspace", path: "reconnect.png" },
serverId: "server",
occurrenceKey: "agent:message:reconnect-image",
unavailableMessage: "Image unavailable",
};
const disconnected = createAssistantImageFileAcquisition({ ...common, port: null });
const connectedPort = new MemoryFileAcquisitionPort();
const connected = createAssistantImageFileAcquisition({ ...common, port: connectedPort });
expect(disconnected?.key).toBe(connected?.key);
await expect(disconnected?.locate()).rejects.toThrow("Image unavailable");
await expect(connected?.locate()).resolves.toMatchObject({ mimeType: "image/png" });
expect(connectedPort.reads).toEqual([{ cwd: "/workspace", path: "reconnect.png" }]);
});
});
@@ -0,0 +1,67 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { getFileNameFromPath } from "@/attachments/utils";
import type { AssistantImageSourceResolution } from "@/utils/assistant-image-source";
import {
createAssistantImageFileAcquisitionKey,
createAssistantImageFilePreviewAttachmentId,
} from "./acquisition-cache";
export interface AssistantImageFileAcquisitionPort {
readFile(cwd: string, path: string): Promise<FileReadResult>;
persist(input: {
id: string;
bytes: Uint8Array;
mimeType: string;
fileName: string | null;
}): Promise<AttachmentMetadata>;
}
export interface AssistantImageAcquisition {
key: string;
locate: () => Promise<AttachmentMetadata>;
}
export function createAssistantImageFileAcquisition(input: {
port: AssistantImageFileAcquisitionPort | null;
resolution: AssistantImageSourceResolution | null;
serverId?: string;
occurrenceKey: string;
unavailableMessage: string;
}): AssistantImageAcquisition | null {
if (input.resolution?.kind !== "file_rpc") {
return null;
}
const { port, resolution } = input;
return {
key: createAssistantImageFileAcquisitionKey({
serverId: input.serverId,
occurrenceKey: input.occurrenceKey,
cwd: resolution.cwd,
path: resolution.path,
}),
locate: async () => {
if (!port) {
throw new Error(input.unavailableMessage);
}
const file = await port.readFile(resolution.cwd, resolution.path);
if (file.kind !== "image") {
throw new Error(input.unavailableMessage);
}
return await port.persist({
id: createAssistantImageFilePreviewAttachmentId({
serverId: input.serverId,
occurrenceKey: input.occurrenceKey,
mimeType: file.mime,
path: file.path || resolution.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
}),
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path || resolution.path),
});
},
};
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
type AssistantImageRenderedDimensionsReader,
resolveAssistantImageLoadDimensions,
} from "./load-dimensions";
class MemoryRenderedDimensionsReader implements AssistantImageRenderedDimensionsReader {
constructor(private readonly dimensions: { width: number; height: number } | null) {}
read(): { width: number; height: number } | null {
return this.dimensions;
}
}
describe("assistant image load dimensions", () => {
it("prefers native source dimensions", () => {
const dimensions = resolveAssistantImageLoadDimensions({
source: { width: 640, height: 320 },
target: { naturalWidth: 800, naturalHeight: 400 },
renderedImage: null,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 640, height: 320 });
});
it("uses browser event dimensions when native dimensions are absent", () => {
const dimensions = resolveAssistantImageLoadDimensions({
source: { width: 0, height: 0 },
target: { naturalWidth: 800, naturalHeight: 400 },
renderedImage: null,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 800, height: 400 });
});
it("falls back to the rendered image adapter", () => {
const renderedImage = { id: "rendered-image" };
const dimensions = resolveAssistantImageLoadDimensions({
target: null,
renderedImage,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 900, height: 600 });
});
});
@@ -0,0 +1,45 @@
export interface AssistantImageDimensions {
width: number;
height: number;
}
interface AssistantImageDimensionCandidate {
width?: unknown;
height?: unknown;
}
export interface AssistantImageRenderedDimensionsReader {
read(renderedImage: unknown): AssistantImageDimensions | null;
}
function readPositiveDimensions(
candidate: AssistantImageDimensionCandidate | null | undefined,
): AssistantImageDimensions | null {
if (
typeof candidate?.width !== "number" ||
typeof candidate.height !== "number" ||
candidate.width <= 0 ||
candidate.height <= 0
) {
return null;
}
return { width: candidate.width, height: candidate.height };
}
export function resolveAssistantImageLoadDimensions(input: {
source?: AssistantImageDimensionCandidate | null;
target?: { naturalWidth?: unknown; naturalHeight?: unknown } | null;
renderedImage: unknown;
renderedDimensions: AssistantImageRenderedDimensionsReader;
}): AssistantImageDimensions | null {
const source = readPositiveDimensions(input.source);
if (source) {
return source;
}
const target = readPositiveDimensions({
width: input.target?.naturalWidth,
height: input.target?.naturalHeight,
});
return target ?? input.renderedDimensions.read(input.renderedImage);
}
@@ -1,173 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { ImageLoadEvent } from "react-native";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentStore } from "@/attachments/types";
import { __setAttachmentStoreForTests } from "@/attachments/store";
import { setAssistantImageMetadata } from "@/utils/assistant-image-metadata";
import { useAssistantImage } from "./use-assistant-image";
describe("useAssistantImage", () => {
afterEach(() => {
cleanup();
__setAttachmentStoreForTests(null);
});
it("keeps a successfully loaded image when cached dimensions are available", async () => {
const source = "https://example.com/expiring-image.png";
setAssistantImageMetadata({ source }, { width: 640, height: 320 });
const { result } = renderHook(() =>
useAssistantImage({ source, occurrenceKey: "agent:message:image" }),
);
await waitFor(() => expect(result.current.status).toBe("loading"));
const binding = result.current.status === "failed" ? null : result.current.binding;
if (!binding) {
throw new Error("Expected a direct image render binding");
}
act(() => {
binding.onLoad({
nativeEvent: { source: { uri: source, width: 0, height: 0 } },
} as ImageLoadEvent);
});
expect(result.current).toMatchObject({ status: "loaded", aspectRatio: 2 });
});
it("uses browser image dimensions from the successful load event", async () => {
const source = "https://example.com/browser-image.png";
const { result } = renderHook(() =>
useAssistantImage({ source, occurrenceKey: "agent:message:browser-image" }),
);
await waitFor(() => expect(result.current.status).toBe("loading"));
const binding = result.current.status === "failed" ? null : result.current.binding;
if (!binding) {
throw new Error("Expected a direct image render binding");
}
act(() => {
binding.onLoad({
nativeEvent: { target: { naturalWidth: 800, naturalHeight: 400 } },
} as unknown as ImageLoadEvent);
});
expect(result.current).toMatchObject({ status: "loaded", aspectRatio: 2 });
});
it("uses the rendered browser image when React Native Web clears the load target", async () => {
const source = "https://example.com/react-native-web-image.png";
const { result } = renderHook(() =>
useAssistantImage({ source, occurrenceKey: "agent:message:react-native-web-image" }),
);
await waitFor(() => expect(result.current.status).toBe("loading"));
const binding = result.current.status === "failed" ? null : result.current.binding;
if (!binding) {
throw new Error("Expected a direct image render binding");
}
const root = document.createElement("div");
const image = document.createElement("img");
Object.defineProperties(image, {
naturalWidth: { value: 900 },
naturalHeight: { value: 600 },
});
root.append(image);
binding.onRef(root);
act(() => {
binding.onLoad({ nativeEvent: { target: null } } as unknown as ImageLoadEvent);
});
expect(result.current).toMatchObject({ status: "loaded", aspectRatio: 1.5 });
});
it("restores a previously loaded URI without returning to loading", async () => {
const source = "https://example.com/remounted-image.png";
const first = renderHook(() =>
useAssistantImage({ source, occurrenceKey: "agent:message:remounted-image" }),
);
await waitFor(() => expect(first.result.current.status).toBe("loading"));
const binding = first.result.current.status === "failed" ? null : first.result.current.binding;
if (!binding) {
throw new Error("Expected a direct image render binding");
}
act(() => {
binding.onLoad({
nativeEvent: { target: { naturalWidth: 800, naturalHeight: 400 } },
} as unknown as ImageLoadEvent);
});
expect(first.result.current.status).toBe("loaded");
first.unmount();
const remounted = renderHook(() =>
useAssistantImage({ source, occurrenceKey: "agent:message:remounted-image" }),
);
expect(remounted.result.current).toMatchObject({ status: "loaded", aspectRatio: 2 });
});
it("restarts a failed file acquisition when the daemon client reconnects", async () => {
const attachmentStore: AttachmentStore = {
storageType: "web-indexeddb",
async save(input) {
return {
id: input.id ?? "assistant-image",
mimeType: input.mimeType ?? "image/png",
storageType: "web-indexeddb",
storageKey: input.id ?? "assistant-image",
fileName: input.fileName,
byteSize: 4,
createdAt: 1,
};
},
async encodeBase64() {
return "";
},
async resolvePreviewUrl({ attachment }) {
return `blob:${attachment.id}`;
},
async delete() {},
async garbageCollect() {},
};
__setAttachmentStoreForTests(attachmentStore);
let readCount = 0;
const connectedClient = {
async readFile(_cwd: string, path: string) {
readCount += 1;
return {
kind: "image" as const,
path,
mime: "image/png",
size: 4,
modifiedAt: "1",
bytes: new Uint8Array([1, 2, 3, 4]),
};
},
} as unknown as DaemonClient;
const initialProps: { client: DaemonClient | null } = { client: null };
const { result, rerender } = renderHook(
({ client }: { client: DaemonClient | null }) =>
useAssistantImage({
source: "/workspace/reconnect.png",
occurrenceKey: "agent:message:reconnect-image",
client,
workspaceRoot: "/workspace",
serverId: "server",
}),
{ initialProps },
);
await waitFor(() => expect(result.current.status).toBe("failed"), { timeout: 3_000 });
rerender({ client: connectedClient });
await waitFor(() => {
expect(readCount).toBe(1);
expect(result.current.status).toBe("loading");
expect(result.current.status === "loading" && result.current.binding).not.toBeNull();
});
});
});
@@ -20,28 +20,28 @@ import {
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
import {
createPreviewAttachmentId,
getFileNameFromPath,
parseImageDataUrl,
} from "@/attachments/utils";
import { createPreviewAttachmentId, parseImageDataUrl } from "@/attachments/utils";
import {
getAssistantImageMetadata,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import type { AssistantImageSourceResolution } from "@/utils/assistant-image-source";
import { createAssistantImageAcquisitionCache } from "./acquisition-cache";
import {
createAssistantImageAcquisitionCache,
createAssistantImageFileAcquisitionKey,
createAssistantImageFilePreviewAttachmentId,
} from "./acquisition-cache";
createAssistantImageFileAcquisition,
type AssistantImageAcquisition,
type AssistantImageFileAcquisitionPort,
} from "./file-acquisition";
import {
createAssistantImageLifecycle,
transitionAssistantImageLifecycle,
type AssistantImageLifecycle,
type AssistantImageLifecycleEvent,
} from "./lifecycle";
import {
type AssistantImageRenderedDimensionsReader,
resolveAssistantImageLoadDimensions,
} from "./load-dimensions";
import { runAssistantImageOperationWithRetry } from "./retry";
interface AssistantImageRenderBinding {
@@ -51,35 +51,17 @@ interface AssistantImageRenderBinding {
onError: () => void;
}
function getImageLoadDimensions(
event: ImageLoadEvent,
renderedImage: unknown,
): { width: number; height: number } | null {
const nativeEvent = event.nativeEvent as ImageLoadEvent["nativeEvent"] & {
target?: { naturalWidth?: unknown; naturalHeight?: unknown };
};
const source = nativeEvent.source;
if (source && source.width > 0 && source.height > 0) {
return source;
}
const { naturalWidth, naturalHeight } = nativeEvent.target ?? {};
if (
typeof naturalWidth === "number" &&
typeof naturalHeight === "number" &&
naturalWidth > 0 &&
naturalHeight > 0
) {
return { width: naturalWidth, height: naturalHeight };
}
if (isWeb && renderedImage instanceof HTMLElement) {
const image = renderedImage.querySelector("img");
if (image && image.naturalWidth > 0 && image.naturalHeight > 0) {
return { width: image.naturalWidth, height: image.naturalHeight };
const renderedDimensions: AssistantImageRenderedDimensionsReader = {
read(renderedImage) {
if (!isWeb || !(renderedImage instanceof HTMLElement)) {
return null;
}
}
return null;
}
const image = renderedImage.querySelector("img");
return image && image.naturalWidth > 0 && image.naturalHeight > 0
? { width: image.naturalWidth, height: image.naturalHeight }
: null;
},
};
export type AssistantImageResult =
| {
@@ -114,11 +96,6 @@ type AttachmentAcquisitionState =
| { status: "loaded"; attachment: AttachmentMetadata }
| { status: "failed"; error: unknown };
interface AttachmentAcquisition {
key: string;
locate: () => Promise<AttachmentMetadata>;
}
interface DataImage {
mimeType: string;
base64: string;
@@ -169,12 +146,12 @@ function rememberLoadedImage(uri: string, aspectRatio: number): void {
}
}
function acquireAttachment(acquisition: AttachmentAcquisition): Promise<AttachmentMetadata> {
function acquireAttachment(acquisition: AssistantImageAcquisition): Promise<AttachmentMetadata> {
return attachmentAcquisitionCache.acquire(acquisition.key, acquisition.locate);
}
function useAttachmentAcquisition(
acquisition: AttachmentAcquisition | null,
acquisition: AssistantImageAcquisition | null,
): AttachmentAcquisitionState {
const acquisitionKey = acquisition?.key ?? null;
const [entry, setEntry] = useState<{
@@ -232,54 +209,10 @@ function useAttachmentAcquisition(
return entry.key === acquisitionKey ? entry.state : { status: "waiting" };
}
function createFileAcquisition(input: {
client?: DaemonClient | null;
resolution: AssistantImageSourceResolution | null;
serverId?: string;
occurrenceKey: string;
unavailableMessage: string;
}): AttachmentAcquisition | null {
if (input.resolution?.kind !== "file_rpc") {
return null;
}
const { client, resolution } = input;
return {
key: createAssistantImageFileAcquisitionKey({
serverId: input.serverId,
occurrenceKey: input.occurrenceKey,
cwd: resolution.cwd,
path: resolution.path,
}),
locate: async () => {
if (!client) {
throw new Error(input.unavailableMessage);
}
const file = await client.readFile(resolution.cwd, resolution.path);
if (file.kind !== "image") {
throw new Error(input.unavailableMessage);
}
return await persistAttachmentFromBytes({
id: createAssistantImageFilePreviewAttachmentId({
serverId: input.serverId,
occurrenceKey: input.occurrenceKey,
mimeType: file.mime,
path: file.path || resolution.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
}),
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path || resolution.path),
});
},
};
}
function createDataImageAcquisition(input: {
source: string;
dataImage: DataImage | null;
}): AttachmentAcquisition | null {
}): AssistantImageAcquisition | null {
if (!input.dataImage) {
return null;
}
@@ -436,17 +369,21 @@ export function useAssistantImage({
[source, workspaceRoot],
);
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const fileAcquisition = useMemo(
() =>
createFileAcquisition({
client,
resolution,
serverId,
occurrenceKey,
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
}),
[client, occurrenceKey, resolution, serverId, t],
);
const fileAcquisition = useMemo(() => {
const port: AssistantImageFileAcquisitionPort | null = client
? {
readFile: async (cwd, path) => await client.readFile(cwd, path),
persist: persistAttachmentFromBytes,
}
: null;
return createAssistantImageFileAcquisition({
port,
resolution,
serverId,
occurrenceKey,
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
});
}, [client, occurrenceKey, resolution, serverId, t]);
const dataImageAcquisition = useMemo(
() => createDataImageAcquisition({ source, dataImage }),
[dataImage, source],
@@ -520,7 +457,15 @@ export function useAssistantImage({
if (!uri) {
return;
}
const dimensions = getImageLoadDimensions(event, renderedImageRef.current);
const nativeEvent = event.nativeEvent as ImageLoadEvent["nativeEvent"] & {
target?: { naturalWidth?: unknown; naturalHeight?: unknown };
};
const dimensions = resolveAssistantImageLoadDimensions({
source: nativeEvent.source,
target: nativeEvent.target,
renderedImage: renderedImageRef.current,
renderedDimensions,
});
const metadata = dimensions
? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions)
: null;