import { afterEach, expect, test } from "@rstest/core";
import { cleanup, render } from "@testing-library/react";
import { StrictMode } from "react";
import {
stageProjectAttachment,
useStagedProjectAttachments,
} from "@/core/projects/composer-attach";
import type { AttachProjectDocumentResult } from "@/core/projects/types";
const ATTACHMENT: AttachProjectDocumentResult = {
filename: "roadmap.md",
size_bytes: 2048,
virtual_path: "/mnt/user-data/uploads/roadmap.md",
artifact_url:
"/api/threads/thread-1/artifacts/mnt/user-data/uploads/roadmap.md",
};
const OTHER: AttachProjectDocumentResult = {
filename: "notes.txt",
size_bytes: 128,
virtual_path: "/mnt/user-data/uploads/notes.txt",
artifact_url:
"/api/threads/thread-1/artifacts/mnt/user-data/uploads/notes.txt",
};
// Latest hook state, exposed to assertions without a DOM surface. The probe
// keeps the setter reachable so tests can drive removal/clear like the
// composer's chip button and onSent callback do.
const observed: {
current: AttachProjectDocumentResult[];
set: (value: AttachProjectDocumentResult[]) => void;
} = {
current: [],
set: () => {
throw new Error("composer probe not mounted");
},
};
function ComposerProbe({ threadId }: { threadId: string }) {
const [attachments, setAttachments] = useStagedProjectAttachments(threadId);
observed.current = attachments;
observed.set = setAttachments;
return null;
}
afterEach(() => {
cleanup();
observed.current = [];
observed.set = () => {
throw new Error("composer probe not mounted");
};
window.sessionStorage.clear();
});
// StrictMode double-invokes mount effects (mount -> cleanup -> re-run). The
// pending list is loaded without any consume step, so the replay re-applies
// the same state instead of resetting it.
test("a Strict-Mode effect replay keeps the pending attachment", () => {
stageProjectAttachment("thread-1", ATTACHMENT);
render(
,
);
expect(observed.current).toEqual([ATTACHMENT]);
});
test("a remount (navigation or reload) re-reads every pending attachment", () => {
stageProjectAttachment("thread-1", ATTACHMENT);
const first = render(
,
);
expect(observed.current).toEqual([ATTACHMENT]);
// Navigating away unmounts the composer; staging a second document from
// the project page appends to the same pending list.
first.unmount();
stageProjectAttachment("thread-1", OTHER);
render(
,
);
expect(observed.current).toEqual([ATTACHMENT, OTHER]);
});
test("switching threads shows that thread's own pending list", () => {
stageProjectAttachment("thread-1", ATTACHMENT);
const { rerender } = render(
,
);
expect(observed.current).toEqual([ATTACHMENT]);
// No pending entry for the new thread: the old chip must not leak across.
rerender(
,
);
expect(observed.current).toEqual([]);
// A pending entry for the new thread is picked up on the switch.
stageProjectAttachment("thread-3", OTHER);
rerender(
,
);
expect(observed.current).toEqual([OTHER]);
});
test("removing one chip persists the remaining pending attachments", () => {
stageProjectAttachment("thread-1", ATTACHMENT);
stageProjectAttachment("thread-1", OTHER);
const { unmount } = render(
,
);
observed.set([ATTACHMENT]);
unmount();
expect(
window.sessionStorage.getItem("deerflow.project-attachment.thread-1"),
).toBe(JSON.stringify([ATTACHMENT]));
render(
,
);
expect(observed.current).toEqual([ATTACHMENT]);
});
test("clearing after a successful send drops the pending list for good", () => {
stageProjectAttachment("thread-1", ATTACHMENT);
const { unmount } = render(
,
);
observed.set([]);
unmount();
expect(
window.sessionStorage.getItem("deerflow.project-attachment.thread-1"),
).toBeNull();
render(
,
);
expect(observed.current).toEqual([]);
});