import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";import { EnvironmentId } from "@t3tools/contracts"; import { removeLocalStorageItem } from "./hooks/useLocalStorage"; import { MAX_STASH_ENTRIES, PROMPT_STASH_STORAGE_KEY, MAX_STASH_ENTRY_ATTACHMENT_CHARS, partitionStashAttachments, usePromptStashStore, writePromptStashStorageForTest, type PromptStashEntry,} from "./promptStashStore"; function makeEntry(input: { id: string; prompt?: string; attachmentChars?: number;}): PromptStashEntry { return { id: input.id, createdAt: "2026-07-24T12:00:00.000Z", prompt: input.prompt ?? `prompt ${input.id}`, attachments: input.attachmentChars !== undefined ? [ { id: `${input.id}-img`, name: "shot.png", mimeType: "image/png", sizeBytes: input.attachmentChars, dataUrl: "x".repeat(input.attachmentChars), }, ] : [], droppedImageNames: [], };} function resetPromptStashStore() { usePromptStashStore.setState({ entries: [] }); writePromptStashStorageForTest(""); removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY);} describe("partitionStashAttachments", () => { it("keeps attachments within the budget and reports dropped names in order", () => { const small = { id: "a", name: "small.png", mimeType: "image/png", sizeBytes: 10, dataUrl: "x".repeat(10), }; const huge = { id: "b", name: "huge.png", mimeType: "image/png", sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS, dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS), }; const alsoSmall = { id: "c", name: "also-small.png", mimeType: "image/png", sizeBytes: 10, dataUrl: "x".repeat(10), }; const { kept, droppedNames } = partitionStashAttachments([small, huge, alsoSmall]); expect(kept.map((attachment) => attachment.id)).toEqual(["a", "c"]); expect(droppedNames).toEqual(["huge.png"]); }); it("admits a single attachment that exactly fits the budget", () => { const exact = { id: "a", name: "exact.png", mimeType: "image/png", sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS, dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS), }; const { kept, droppedNames } = partitionStashAttachments([exact]); expect(kept).toHaveLength(1); expect(droppedNames).toEqual([]); });}); describe("promptStashStore", () => { beforeEach(() => { resetPromptStashStore(); }); afterEach(() => { resetPromptStashStore(); }); it("prepends entries so the newest stash is first", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "first" })); store.stashEntry(makeEntry({ id: "second" })); const entries = usePromptStashStore.getState().entries; expect(entries.map((entry) => entry.id)).toEqual(["second", "first"]); }); it("evicts the oldest entry past the cap and returns it", () => { const store = usePromptStashStore.getState(); for (let index = 0; index < MAX_STASH_ENTRIES; index += 1) { expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull(); } const { evicted } = store.stashEntry(makeEntry({ id: "overflow" })); expect(evicted?.id).toBe("entry-0"); const entries = usePromptStashStore.getState().entries; expect(entries).toHaveLength(MAX_STASH_ENTRIES); expect(entries[0]?.id).toBe("overflow"); }); // This test environment has no `localStorage`, so the store runs on its // in-memory fallback — the exact "kept for this session, gone on reload" // case the composer must distinguish from an outright write failure. it("distinguishes a memory-only write (written, not durable) from a failed one", () => { const store = usePromptStashStore.getState(); const result = store.stashEntry(makeEntry({ id: "memory-only" })); expect(result.written).toBe(true); expect(result.durable).toBe(false); expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual([ "memory-only", ]); }); it("takeEntry removes and returns the entry; second take returns null", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "keep" })); store.stashEntry(makeEntry({ id: "take" })); expect(store.takeEntry("take").entry?.id).toBe("take"); expect(store.takeEntry("take").entry).toBeNull(); const entries = usePromptStashStore.getState().entries; expect(entries.map((entry) => entry.id)).toEqual(["keep"]); }); it("finalizeEntryImages attaches images and clears the pending count", () => { const store = usePromptStashStore.getState(); store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 }); const { attached } = store.finalizeEntryImages("pending", { attachments: [ { id: "img-1", name: "a.webp", mimeType: "image/webp", sizeBytes: 10, dataUrl: "data:image/webp;base64,AAAA", }, ], droppedImageNames: ["big.png"], unreadableImageNames: [], }); expect(attached).toBe(true); const entry = usePromptStashStore.getState().entries[0]; expect(entry?.attachments).toHaveLength(1); expect(entry?.droppedImageNames).toEqual(["big.png"]); expect(entry?.pendingImageCount).toBe(0); }); it("takeEntry returns images and drop metadata finalized after a menu snapshot", () => { const store = usePromptStashStore.getState(); store.stashEntry({ ...makeEntry({ id: "pending-restore" }), pendingImageCount: 1 }); const menuSnapshot = usePromptStashStore.getState().entries[0]; expect(menuSnapshot?.attachments).toEqual([]); store.finalizeEntryImages("pending-restore", { attachments: [ { id: "img-finalized", name: "finalized.webp", mimeType: "image/webp", sizeBytes: 12, dataUrl: "data:image/webp;base64,BBBB", }, ], droppedImageNames: ["too-large.png"], unreadableImageNames: ["unreadable.png"], }); const { entry: taken } = store.takeEntry("pending-restore"); expect(taken).not.toBe(menuSnapshot); expect(taken).toMatchObject({ attachments: [ { id: "img-finalized", name: "finalized.webp", }, ], droppedImageNames: ["too-large.png"], unreadableImageNames: ["unreadable.png"], pendingImageCount: 0, }); }); it("preserves uploaded file references without storing file contents", () => { const store = usePromptStashStore.getState(); const file = { id: "file-1", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, attachmentId: "pending-report-pdf", environmentId: EnvironmentId.make("environment-1"), }; store.stashEntry({ ...makeEntry({ id: "with-file" }), files: [file] }); store.finalizeEntryImages("with-file", { attachments: [], droppedImageNames: [], unreadableImageNames: [], }); expect(usePromptStashStore.getState().entries[0]?.files).toEqual([file]); }); it("finalizeEntryImages reports false when the entry was already taken", () => { const store = usePromptStashStore.getState(); store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 }); // Restored (or deleted) while its images were still encoding. store.takeEntry("racing"); const { attached } = store.finalizeEntryImages("racing", { attachments: [], droppedImageNames: [], unreadableImageNames: [], }); expect(attached).toBe(false); }); it("settles a pending count left behind by a crashed or closed session", () => { writePromptStashStorageForTest( JSON.stringify({ version: 2, state: { entries: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], }, }), ); // Hydration must settle the stale count, or the entry would stay stuck // showing "saving…" with images that no longer exist anywhere. const entry = usePromptStashStore.getState().entries[0]; expect(entry?.pendingImageCount).toBe(0); expect(entry?.unreadableImageNames).toHaveLength(2); }); it("ignores an unreadable v1 payload seeded under the current key", () => { // The v1 shape (per-provider queues) does not decode as v2; hydration // must fall back to an empty stash rather than throw. writePromptStashStorageForTest( JSON.stringify({ version: 1, state: { queuesByScopeKey: { "provider:claudeAgent": [] } }, }), ); expect(usePromptStashStore.getState().entries).toEqual([]); });});
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { EnvironmentId } from "@t3tools/contracts";
import { removeLocalStorageItem } from "./hooks/useLocalStorage";
import {
MAX_STASH_ENTRIES,
PROMPT_STASH_STORAGE_KEY,
MAX_STASH_ENTRY_ATTACHMENT_CHARS,
partitionStashAttachments,
usePromptStashStore,
writePromptStashStorageForTest,
type PromptStashEntry,
} from "./promptStashStore";
function makeEntry(input: {
id: string;
prompt?: string;
attachmentChars?: number;
}): PromptStashEntry {
return {
id: input.id,
createdAt: "2026-07-24T12:00:00.000Z",
prompt: input.prompt ?? `prompt ${input.id}`,
attachments:
input.attachmentChars !== undefined
? [
{
id: `${input.id}-img`,
name: "shot.png",
mimeType: "image/png",
sizeBytes: input.attachmentChars,
dataUrl: "x".repeat(input.attachmentChars),
},
]
: [],
droppedImageNames: [],
};
}
function resetPromptStashStore() {
usePromptStashStore.setState({ entries: [] });
writePromptStashStorageForTest("");
removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY);
}
describe("partitionStashAttachments", () => {
it("keeps attachments within the budget and reports dropped names in order", () => {
const small = {
id: "a",
name: "small.png",
mimeType: "image/png",
sizeBytes: 10,
dataUrl: "x".repeat(10),
};
const huge = {
id: "b",
name: "huge.png",
mimeType: "image/png",
sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS,
dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS),
};
const alsoSmall = {
id: "c",
name: "also-small.png",
mimeType: "image/png",
sizeBytes: 10,
dataUrl: "x".repeat(10),
};
const { kept, droppedNames } = partitionStashAttachments([small, huge, alsoSmall]);
expect(kept.map((attachment) => attachment.id)).toEqual(["a", "c"]);
expect(droppedNames).toEqual(["huge.png"]);
});
it("admits a single attachment that exactly fits the budget", () => {
const exact = {
id: "a",
name: "exact.png",
mimeType: "image/png",
sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS,
dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS),
};
const { kept, droppedNames } = partitionStashAttachments([exact]);
expect(kept).toHaveLength(1);
expect(droppedNames).toEqual([]);
});
});
describe("promptStashStore", () => {
beforeEach(() => {
resetPromptStashStore();
});
afterEach(() => {
resetPromptStashStore();
});
it("prepends entries so the newest stash is first", () => {
const store = usePromptStashStore.getState();
store.stashEntry(makeEntry({ id: "first" }));
store.stashEntry(makeEntry({ id: "second" }));
const entries = usePromptStashStore.getState().entries;
expect(entries.map((entry) => entry.id)).toEqual(["second", "first"]);
});
it("evicts the oldest entry past the cap and returns it", () => {
const store = usePromptStashStore.getState();
for (let index = 0; index < MAX_STASH_ENTRIES; index += 1) {
expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull();
}
const { evicted } = store.stashEntry(makeEntry({ id: "overflow" }));
expect(evicted?.id).toBe("entry-0");
const entries = usePromptStashStore.getState().entries;
expect(entries).toHaveLength(MAX_STASH_ENTRIES);
expect(entries[0]?.id).toBe("overflow");
});
// This test environment has no `localStorage`, so the store runs on its
// in-memory fallback — the exact "kept for this session, gone on reload"
// case the composer must distinguish from an outright write failure.
it("distinguishes a memory-only write (written, not durable) from a failed one", () => {
const store = usePromptStashStore.getState();
const result = store.stashEntry(makeEntry({ id: "memory-only" }));
expect(result.written).toBe(true);
expect(result.durable).toBe(false);
expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual([
"memory-only",
]);
});
it("takeEntry removes and returns the entry; second take returns null", () => {
const store = usePromptStashStore.getState();
store.stashEntry(makeEntry({ id: "keep" }));
store.stashEntry(makeEntry({ id: "take" }));
expect(store.takeEntry("take").entry?.id).toBe("take");
expect(store.takeEntry("take").entry).toBeNull();
const entries = usePromptStashStore.getState().entries;
expect(entries.map((entry) => entry.id)).toEqual(["keep"]);
});
it("finalizeEntryImages attaches images and clears the pending count", () => {
const store = usePromptStashStore.getState();
store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 });
const { attached } = store.finalizeEntryImages("pending", {
attachments: [
{
id: "img-1",
name: "a.webp",
mimeType: "image/webp",
sizeBytes: 10,
dataUrl: "data:image/webp;base64,AAAA",
},
],
droppedImageNames: ["big.png"],
unreadableImageNames: [],
});
expect(attached).toBe(true);
const entry = usePromptStashStore.getState().entries[0];
expect(entry?.attachments).toHaveLength(1);
expect(entry?.droppedImageNames).toEqual(["big.png"]);
expect(entry?.pendingImageCount).toBe(0);
});
it("takeEntry returns images and drop metadata finalized after a menu snapshot", () => {
const store = usePromptStashStore.getState();
store.stashEntry({ ...makeEntry({ id: "pending-restore" }), pendingImageCount: 1 });
const menuSnapshot = usePromptStashStore.getState().entries[0];
expect(menuSnapshot?.attachments).toEqual([]);
store.finalizeEntryImages("pending-restore", {
attachments: [
{
id: "img-finalized",
name: "finalized.webp",
mimeType: "image/webp",
sizeBytes: 12,
dataUrl: "data:image/webp;base64,BBBB",
},
],
droppedImageNames: ["too-large.png"],
unreadableImageNames: ["unreadable.png"],
});
const { entry: taken } = store.takeEntry("pending-restore");
expect(taken).not.toBe(menuSnapshot);
expect(taken).toMatchObject({
attachments: [
{
id: "img-finalized",
name: "finalized.webp",
},
],
droppedImageNames: ["too-large.png"],
unreadableImageNames: ["unreadable.png"],
pendingImageCount: 0,
});
});
it("preserves uploaded file references without storing file contents", () => {
const store = usePromptStashStore.getState();
const file = {
id: "file-1",
name: "report.pdf",
mimeType: "application/pdf",
sizeBytes: 42,
attachmentId: "pending-report-pdf",
environmentId: EnvironmentId.make("environment-1"),
};
store.stashEntry({ ...makeEntry({ id: "with-file" }), files: [file] });
store.finalizeEntryImages("with-file", {
attachments: [],
droppedImageNames: [],
unreadableImageNames: [],
});
expect(usePromptStashStore.getState().entries[0]?.files).toEqual([file]);
});
it("finalizeEntryImages reports false when the entry was already taken", () => {
const store = usePromptStashStore.getState();
store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 });
// Restored (or deleted) while its images were still encoding.
store.takeEntry("racing");
const { attached } = store.finalizeEntryImages("racing", {
attachments: [],
droppedImageNames: [],
unreadableImageNames: [],
});
expect(attached).toBe(false);
});
it("settles a pending count left behind by a crashed or closed session", () => {
writePromptStashStorageForTest(
JSON.stringify({
version: 2,
state: {
entries: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }],
},
}),
);
// Hydration must settle the stale count, or the entry would stay stuck
// showing "saving…" with images that no longer exist anywhere.
const entry = usePromptStashStore.getState().entries[0];
expect(entry?.pendingImageCount).toBe(0);
expect(entry?.unreadableImageNames).toHaveLength(2);
});
it("ignores an unreadable v1 payload seeded under the current key", () => {
// The v1 shape (per-provider queues) does not decode as v2; hydration
// must fall back to an empty stash rather than throw.
writePromptStashStorageForTest(
JSON.stringify({
version: 1,
state: { queuesByScopeKey: { "provider:claudeAgent": [] } },
}),
);
expect(usePromptStashStore.getState().entries).toEqual([]);
});
});