2026-05-08 08:47:46 -04:00
|
|
|
import { beforeEach, describe, expect, test } from "bun:test"
|
|
|
|
|
import { useInputStore } from "./input-store"
|
|
|
|
|
|
|
|
|
|
class MockFileReader {
|
|
|
|
|
result: string | ArrayBuffer | null = null
|
|
|
|
|
onload: ((this: FileReader, event: ProgressEvent<FileReader>) => unknown) | null = null
|
2026-05-12 04:09:31 -04:00
|
|
|
onerror: ((this: FileReader, event: ProgressEvent<FileReader>) => unknown) | null = null
|
|
|
|
|
onabort: ((this: FileReader, event: ProgressEvent<FileReader>) => unknown) | null = null
|
|
|
|
|
error: DOMException | null = null
|
2026-05-08 08:47:46 -04:00
|
|
|
|
|
|
|
|
readAsDataURL() {
|
|
|
|
|
pendingReaders.push(this)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pendingReaders: MockFileReader[] = []
|
2026-05-12 04:11:34 -04:00
|
|
|
const originalFileReader = globalThis.FileReader
|
|
|
|
|
|
|
|
|
|
const restoreFileReader = () => {
|
|
|
|
|
pendingReaders.length = 0
|
|
|
|
|
globalThis.FileReader = originalFileReader
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const testWithMockFileReader = (name: string, fn: () => Promise<void>) => {
|
|
|
|
|
test(name, async () => {
|
|
|
|
|
try {
|
|
|
|
|
await fn()
|
|
|
|
|
} finally {
|
|
|
|
|
restoreFileReader()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-05-08 08:47:46 -04:00
|
|
|
|
|
|
|
|
const resolveReader = (reader: MockFileReader, result: string) => {
|
|
|
|
|
reader.result = result
|
|
|
|
|
reader.onload?.call(reader as unknown as FileReader, {} as ProgressEvent<FileReader>)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 04:09:31 -04:00
|
|
|
const rejectReader = (reader: MockFileReader) => {
|
|
|
|
|
reader.error = new DOMException("read failed", "NotReadableError")
|
|
|
|
|
reader.onerror?.call(reader as unknown as FileReader, {} as ProgressEvent<FileReader>)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 08:47:46 -04:00
|
|
|
describe("input-store attachments", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
pendingReaders.length = 0
|
|
|
|
|
globalThis.FileReader = MockFileReader as unknown as typeof FileReader
|
|
|
|
|
useInputStore.setState({
|
|
|
|
|
pendingInputText: null,
|
|
|
|
|
pendingInputMode: "replace",
|
|
|
|
|
pendingSyntheticParts: null,
|
|
|
|
|
activeEditorFile: null,
|
|
|
|
|
})
|
|
|
|
|
useInputStore.getState().setAttachedFiles([])
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-12 04:11:34 -04:00
|
|
|
testWithMockFileReader("does not attach a local file that finishes reading after attachments are cleared", async () => {
|
2026-05-08 08:47:46 -04:00
|
|
|
const addPromise = useInputStore.getState().addAttachedFile(new File(["hello"], "hello.txt", { type: "text/plain" }))
|
|
|
|
|
expect(pendingReaders).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
useInputStore.getState().clearAttachedFiles()
|
|
|
|
|
resolveReader(pendingReaders[0], "data:text/plain;base64,aGVsbG8=")
|
|
|
|
|
await addPromise
|
|
|
|
|
|
|
|
|
|
expect(useInputStore.getState().attachedFiles).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-12 04:11:34 -04:00
|
|
|
testWithMockFileReader("does not attach a local file after attached files are replaced", async () => {
|
2026-05-08 08:47:46 -04:00
|
|
|
const addPromise = useInputStore.getState().addAttachedFile(new File(["hello"], "hello.txt", { type: "text/plain" }))
|
|
|
|
|
expect(pendingReaders).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
useInputStore.getState().setAttachedFiles([])
|
|
|
|
|
resolveReader(pendingReaders[0], "data:text/plain;base64,aGVsbG8=")
|
|
|
|
|
await addPromise
|
|
|
|
|
|
|
|
|
|
expect(useInputStore.getState().attachedFiles).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-12 04:11:34 -04:00
|
|
|
testWithMockFileReader("does not attach a local file after attached files are restored", async () => {
|
2026-05-08 08:47:46 -04:00
|
|
|
const addPromise = useInputStore.getState().addAttachedFile(new File(["hello"], "hello.txt", { type: "text/plain" }))
|
|
|
|
|
expect(pendingReaders).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
const restored = new File(["restored"], "restored.txt", { type: "text/plain" })
|
|
|
|
|
useInputStore.getState().setAttachedFiles([{
|
|
|
|
|
id: "restored",
|
|
|
|
|
file: restored,
|
|
|
|
|
dataUrl: "data:text/plain;base64,cmVzdG9yZWQ=",
|
|
|
|
|
mimeType: "text/plain",
|
|
|
|
|
filename: "restored.txt",
|
|
|
|
|
size: restored.size,
|
|
|
|
|
source: "local",
|
|
|
|
|
}])
|
|
|
|
|
resolveReader(pendingReaders[0], "data:text/plain;base64,aGVsbG8=")
|
|
|
|
|
await addPromise
|
|
|
|
|
|
|
|
|
|
expect(useInputStore.getState().attachedFiles.map((file) => file.filename)).toEqual(["restored.txt"])
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-12 04:11:34 -04:00
|
|
|
testWithMockFileReader("does not attach a VS Code selection that finishes reading after attachments are cleared", async () => {
|
2026-05-08 08:47:46 -04:00
|
|
|
const addPromise = useInputStore.getState().addVSCodeSelectionAttachment(
|
|
|
|
|
"/workspace/hello.txt",
|
|
|
|
|
new File(["hello"], "hello.txt", { type: "text/plain" })
|
|
|
|
|
)
|
|
|
|
|
expect(pendingReaders).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
useInputStore.getState().clearAttachedFiles()
|
|
|
|
|
resolveReader(pendingReaders[0], "data:text/plain;base64,aGVsbG8=")
|
|
|
|
|
await addPromise
|
|
|
|
|
|
|
|
|
|
expect(useInputStore.getState().attachedFiles).toEqual([])
|
|
|
|
|
})
|
2026-05-12 04:09:31 -04:00
|
|
|
|
|
|
|
|
test("does not leave local file reads pending after a reader error", async () => {
|
|
|
|
|
const addPromise = useInputStore.getState().addAttachedFile(new File(["hello"], "hello.txt", { type: "text/plain" }))
|
|
|
|
|
expect(pendingReaders).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
rejectReader(pendingReaders[0])
|
|
|
|
|
await addPromise
|
|
|
|
|
|
|
|
|
|
expect(useInputStore.getState().attachedFiles).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("cleans up pending VS Code selection keys after a reader error", async () => {
|
|
|
|
|
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
|
|
|
|
|
const firstAdd = useInputStore.getState().addVSCodeSelectionAttachment("/workspace/hello.txt", file)
|
|
|
|
|
expect(pendingReaders).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
rejectReader(pendingReaders[0])
|
|
|
|
|
await firstAdd
|
|
|
|
|
|
|
|
|
|
const secondAdd = useInputStore.getState().addVSCodeSelectionAttachment("/workspace/hello.txt", file)
|
|
|
|
|
expect(pendingReaders).toHaveLength(2)
|
|
|
|
|
resolveReader(pendingReaders[1], "data:text/plain;base64,aGVsbG8=")
|
|
|
|
|
await secondAdd
|
|
|
|
|
|
|
|
|
|
expect(useInputStore.getState().attachedFiles.map((attached) => attached.filename)).toEqual(["hello.txt"])
|
|
|
|
|
})
|
2026-07-22 11:33:22 +03:00
|
|
|
|
|
|
|
|
testWithMockFileReader("normalizes code files to text/plain", async () => {
|
|
|
|
|
const addPromise = useInputStore.getState().addAttachedFile(
|
|
|
|
|
new File(["const value = 1"], "example.ts", { type: "text/typescript" })
|
|
|
|
|
)
|
|
|
|
|
expect(pendingReaders).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
resolveReader(pendingReaders[0], "data:text/typescript;base64,Y29uc3QgdmFsdWUgPSAx")
|
|
|
|
|
|
|
|
|
|
expect(await addPromise).toBe(true)
|
|
|
|
|
expect(useInputStore.getState().attachedFiles[0]?.filename).toBe("example.ts")
|
|
|
|
|
expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("text/plain")
|
|
|
|
|
expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe(
|
|
|
|
|
"data:text/plain;base64,Y29uc3QgdmFsdWUgPSAx"
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
testWithMockFileReader("normalizes structured text MIME types to text/plain", async () => {
|
|
|
|
|
const addPromise = useInputStore.getState().addAttachedFile(
|
|
|
|
|
new File(["{}"], "example.json", { type: "application/json" })
|
|
|
|
|
)
|
|
|
|
|
expect(pendingReaders).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
resolveReader(pendingReaders[0], "data:application/json;base64,e30=")
|
|
|
|
|
|
|
|
|
|
expect(await addPromise).toBe(true)
|
|
|
|
|
expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("text/plain")
|
|
|
|
|
expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe("data:text/plain;base64,e30=")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("rejects an unknown binary file after inspecting its contents", async () => {
|
|
|
|
|
const attached = await useInputStore.getState().addAttachedFile(
|
|
|
|
|
new File([new Uint8Array([0, 1, 2, 3])], "archive.bin", { type: "application/octet-stream" })
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(attached).toBe(false)
|
|
|
|
|
expect(pendingReaders).toHaveLength(0)
|
|
|
|
|
expect(useInputStore.getState().attachedFiles).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("rejects unknown content with too many control bytes", async () => {
|
|
|
|
|
const attached = await useInputStore.getState().addAttachedFile(
|
|
|
|
|
new File([new Uint8Array([1, 2, 3, 65])], "encoded.custom", { type: "application/octet-stream" })
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(attached).toBe(false)
|
|
|
|
|
expect(pendingReaders).toHaveLength(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
testWithMockFileReader("accepts an unknown MIME type when its contents are text", async () => {
|
|
|
|
|
const addPromise = useInputStore.getState().addAttachedFile(
|
|
|
|
|
new File(["custom text"], "example.custom", { type: "application/octet-stream" })
|
|
|
|
|
)
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
|
expect(pendingReaders).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
resolveReader(pendingReaders[0], "data:application/octet-stream;base64,Y3VzdG9tIHRleHQ=")
|
|
|
|
|
|
|
|
|
|
expect(await addPromise).toBe(true)
|
|
|
|
|
expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("text/plain")
|
|
|
|
|
expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe(
|
|
|
|
|
"data:text/plain;base64,Y3VzdG9tIHRleHQ="
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
testWithMockFileReader("preserves supported image MIME types", async () => {
|
|
|
|
|
const addPromise = useInputStore.getState().addAttachedFile(
|
|
|
|
|
new File([new Uint8Array([1, 2, 3])], "image.webp", { type: "image/webp" })
|
|
|
|
|
)
|
|
|
|
|
expect(pendingReaders).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
resolveReader(pendingReaders[0], "data:image/webp;base64,AQID")
|
|
|
|
|
|
|
|
|
|
expect(await addPromise).toBe(true)
|
|
|
|
|
expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("image/webp")
|
|
|
|
|
expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe("data:image/webp;base64,AQID")
|
|
|
|
|
})
|
2026-05-08 08:47:46 -04:00
|
|
|
})
|