226 lines
10 KiB
TypeScript
226 lines
10 KiB
TypeScript
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
|||
|
|
import type { AttachedFile } from "./types/sessionTypes"
|
||
|
|
import type { MessageQueueUpdatedEvent } from "./messageQueueStore"
|
||
|
|
|
||
|
|
type FetchCall = { path: string; method: string; body: ReturnType<typeof JSON.parse> }
|
||
|
|
let calls: FetchCall[] = []
|
||
|
|
let respond: (call: FetchCall) => Response = () => new Response("{}", { status: 200 })
|
||
|
|
|
||
|
|
mock.module("@/lib/runtime-fetch", () => ({
|
||
|
|
runtimeFetch: async (path: string, init?: RequestInit) => {
|
||
|
|
const call = {
|
||
|
|
path,
|
||
|
|
method: init?.method ?? "GET",
|
||
|
|
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
|
||
|
|
}
|
||
|
|
calls.push(call)
|
||
|
|
return respond(call)
|
||
|
|
},
|
||
|
|
}))
|
||
|
|
const desktop = await import("@/lib/desktop")
|
||
|
|
mock.module("@/lib/desktop", () => ({ ...desktop, isVSCodeRuntime: () => false }))
|
||
|
|
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => "runtime-a" }))
|
||
|
|
mock.module("@/lib/persistence", () => ({ updateDesktopSettings: async () => undefined }))
|
||
|
|
|
||
|
|
const {
|
||
|
|
applyMessageQueueUpdatedEvent,
|
||
|
|
createMessageQueueTarget,
|
||
|
|
getMessageQueueKey,
|
||
|
|
useMessageQueueStore,
|
||
|
|
} = await import("./messageQueueStore")
|
||
|
|
|
||
|
|
type ServerItem = MessageQueueUpdatedEvent["properties"]["session"]["items"][number]
|
||
|
|
type ServerSession = MessageQueueUpdatedEvent["properties"]["session"]
|
||
|
|
|
||
|
|
type ServerReply = {
|
||
|
|
revision: number
|
||
|
|
session?: ServerSession
|
||
|
|
sessions?: ServerSession[]
|
||
|
|
item?: ServerItem
|
||
|
|
items?: ServerItem[]
|
||
|
|
}
|
||
|
|
|
||
|
|
const json = (value: ServerReply, status = 200) => new Response(JSON.stringify(value), { status })
|
||
|
|
|
||
|
|
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
|
||
|
|
const key = getMessageQueueKey(target)
|
||
|
|
|
||
|
|
const serverItem = (id: string, content: string, extra: Partial<ServerItem> = {}): ServerItem => ({
|
||
|
|
id,
|
||
|
|
createdAt: 1,
|
||
|
|
content,
|
||
|
|
attachments: [],
|
||
|
|
sendConfig: { providerID: "p", modelID: "m" },
|
||
|
|
...extra,
|
||
|
|
})
|
||
|
|
|
||
|
|
const session = (items: ServerItem[], sendingId: string | null = null): ServerSession => ({
|
||
|
|
sessionId: "session-1",
|
||
|
|
directory: "/repo",
|
||
|
|
items,
|
||
|
|
sendingId,
|
||
|
|
})
|
||
|
|
|
||
|
|
const updated = (revision: number, updatedSession: ServerSession): MessageQueueUpdatedEvent => ({
|
||
|
|
type: "openchamber:message-queue.updated",
|
||
|
|
properties: { revision, session: updatedSession },
|
||
|
|
})
|
||
|
|
|
||
|
|
const attachment: AttachedFile = {
|
||
|
|
id: "att-1",
|
||
|
|
file: new File(["hi"], "note.txt", { type: "text/plain" }),
|
||
|
|
dataUrl: "data:text/plain;base64,aGk=",
|
||
|
|
mimeType: "text/plain",
|
||
|
|
filename: "note.txt",
|
||
|
|
size: 2,
|
||
|
|
source: "local",
|
||
|
|
}
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
calls = []
|
||
|
|
respond = () => json({ revision: 1, session: session([]) })
|
||
|
|
// Forgetting also drops the revision guard, so each test starts unordered.
|
||
|
|
useMessageQueueStore.getState().forgetQueue(target)
|
||
|
|
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} })
|
||
|
|
})
|
||
|
|
|
||
|
|
describe("server-owned message queue", () => {
|
||
|
|
// First: the one-time upload of a legacy local queue happens before this
|
||
|
|
// runtime is known to be server-owned, which the later hydrations establish.
|
||
|
|
test("hydrate uploads messages queued by an older build before reading the server", async () => {
|
||
|
|
useMessageQueueStore.setState({
|
||
|
|
queuedMessages: {
|
||
|
|
[key]: [{ id: "local-1", content: "from before", createdAt: 1, sendConfig: { providerID: "p", modelID: "m" } }],
|
||
|
|
},
|
||
|
|
})
|
||
|
|
respond = (call) => (call.method === "POST"
|
||
|
|
? json({ revision: 2, session: session([serverItem("q1", "from before")]) })
|
||
|
|
: json({ revision: 2, sessions: [session([serverItem("q1", "from before")])] }))
|
||
|
|
await useMessageQueueStore.getState().hydrate()
|
||
|
|
|
||
|
|
expect(calls[0]).toEqual({
|
||
|
|
method: "POST",
|
||
|
|
path: "/api/message-queue/sessions/session-1/items",
|
||
|
|
body: { directory: "/repo", item: { content: "from before", text: "from before", attachments: [], sendConfig: { providerID: "p", modelID: "m" } } },
|
||
|
|
})
|
||
|
|
expect(calls[1]?.path).toBe("/api/message-queue")
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
|
||
|
|
})
|
||
|
|
|
||
|
|
test("hydrate replaces the runtime's projection with the server queue", async () => {
|
||
|
|
respond = () => json({ revision: 3, sessions: [session([serverItem("q1", "hello")], "q1")] })
|
||
|
|
await useMessageQueueStore.getState().hydrate()
|
||
|
|
|
||
|
|
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual(["GET /api/message-queue"])
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["hello"])
|
||
|
|
expect(useMessageQueueStore.getState().sendingIds[key]).toEqual(["q1"])
|
||
|
|
})
|
||
|
|
|
||
|
|
test("addToQueue shows the message at once and settles on the server's copy", async () => {
|
||
|
|
respond = () => json({ revision: 5, session: session([serverItem("srv-1", "hi @reviewer", { agentMention: "reviewer" })]) })
|
||
|
|
const pending = useMessageQueueStore.getState().addToQueue(target, {
|
||
|
|
content: "hi @reviewer",
|
||
|
|
text: "hi",
|
||
|
|
agentMention: "reviewer",
|
||
|
|
attachments: [attachment],
|
||
|
|
sendConfig: { providerID: "p", modelID: "m", agent: "build" },
|
||
|
|
})
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1)
|
||
|
|
await pending
|
||
|
|
|
||
|
|
expect(calls[0]).toEqual({
|
||
|
|
method: "POST",
|
||
|
|
path: "/api/message-queue/sessions/session-1/items",
|
||
|
|
body: {
|
||
|
|
directory: "/repo",
|
||
|
|
item: {
|
||
|
|
content: "hi @reviewer",
|
||
|
|
text: "hi",
|
||
|
|
agentMention: "reviewer",
|
||
|
|
attachments: [{ id: "att-1", filename: "note.txt", mimeType: "text/plain", size: 2, source: "local", dataUrl: attachment.dataUrl }],
|
||
|
|
sendConfig: { providerID: "p", modelID: "m", agent: "build" },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["srv-1"])
|
||
|
|
})
|
||
|
|
|
||
|
|
test("addToQueue rolls the optimistic entry back when the server refuses", async () => {
|
||
|
|
respond = () => new Response("nope", { status: 500 })
|
||
|
|
await expect(useMessageQueueStore.getState().addToQueue(target, {
|
||
|
|
content: "x",
|
||
|
|
sendConfig: { providerID: "p", modelID: "m" },
|
||
|
|
})).rejects.toThrow()
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||
|
|
})
|
||
|
|
|
||
|
|
test("addToQueue refuses a message with no captured model", async () => {
|
||
|
|
await expect(useMessageQueueStore.getState().addToQueue(target, { content: "x" })).rejects.toThrow()
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||
|
|
expect(calls).toHaveLength(0)
|
||
|
|
})
|
||
|
|
|
||
|
|
test("takeForSend brings the full message back, attachments included", async () => {
|
||
|
|
respond = () => json({
|
||
|
|
revision: 7,
|
||
|
|
session: session([]),
|
||
|
|
item: serverItem("q1", "with file", {
|
||
|
|
attachments: [{ id: "att-1", filename: "note.txt", mimeType: "text/plain", size: 2, source: "local", dataUrl: "data:text/plain;base64,aGk=" }],
|
||
|
|
}),
|
||
|
|
})
|
||
|
|
const [taken] = await useMessageQueueStore.getState().takeForSend(target, "q1")
|
||
|
|
|
||
|
|
expect(calls[0]?.path).toBe("/api/message-queue/sessions/session-1/items/q1/take")
|
||
|
|
expect(calls[0]?.method).toBe("POST")
|
||
|
|
expect(taken?.content).toBe("with file")
|
||
|
|
expect(taken?.attachments?.[0]?.dataUrl).toBe("data:text/plain;base64,aGk=")
|
||
|
|
expect(taken?.attachments?.[0]?.file.size).toBe(2)
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||
|
|
})
|
||
|
|
|
||
|
|
test("takeForSend without an id takes everything the server is not already sending", async () => {
|
||
|
|
respond = () => json({ revision: 8, session: session([serverItem("q1", "in flight")], "q1"), items: [serverItem("q2", "second")] })
|
||
|
|
const taken = await useMessageQueueStore.getState().takeForSend(target)
|
||
|
|
|
||
|
|
expect(calls[0]?.path).toBe("/api/message-queue/sessions/session-1/take")
|
||
|
|
expect(calls[0]?.method).toBe("POST")
|
||
|
|
expect(taken.map((m) => m.content)).toEqual(["second"])
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
|
||
|
|
})
|
||
|
|
|
||
|
|
test("broadcasts update the projection but never move it backwards", () => {
|
||
|
|
applyMessageQueueUpdatedEvent(updated(4, session([serverItem("q1", "newer")])), "runtime-a")
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
|
||
|
|
|
||
|
|
applyMessageQueueUpdatedEvent(updated(2, session([serverItem("q0", "older")])), "runtime-a")
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
|
||
|
|
|
||
|
|
applyMessageQueueUpdatedEvent(updated(9, session([serverItem("q1", "newer")])), "runtime-b")
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
|
||
|
|
})
|
||
|
|
|
||
|
|
test("removeFromQueue and clearQueue update locally and tell the server", async () => {
|
||
|
|
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", createdAt: 1 }, { id: "q2", content: "b", createdAt: 2 }] } })
|
||
|
|
respond = () => json({ revision: 10, session: session([serverItem("q2", "b")]) })
|
||
|
|
useMessageQueueStore.getState().removeFromQueue(target, "q1")
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q2"])
|
||
|
|
await Promise.resolve()
|
||
|
|
await Promise.resolve()
|
||
|
|
expect(calls[0]).toEqual({ method: "DELETE", path: "/api/message-queue/sessions/session-1/items/q1", body: undefined })
|
||
|
|
|
||
|
|
respond = () => json({ revision: 11, session: session([]) })
|
||
|
|
useMessageQueueStore.getState().clearQueue(target)
|
||
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||
|
|
await Promise.resolve()
|
||
|
|
expect(calls[1]).toEqual({ method: "DELETE", path: "/api/message-queue/sessions/session-1", body: undefined })
|
||
|
|
})
|
||
|
|
|
||
|
|
test("reorderQueue sends the complete new order", async () => {
|
||
|
|
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", createdAt: 1 }, { id: "q2", content: "b", createdAt: 2 }] } })
|
||
|
|
respond = () => json({ revision: 12, session: session([serverItem("q2", "b"), serverItem("q1", "a")]) })
|
||
|
|
useMessageQueueStore.getState().reorderQueue(target, "q2", "q1")
|
||
|
|
await Promise.resolve()
|
||
|
|
expect(calls[0]).toEqual({ method: "PUT", path: "/api/message-queue/sessions/session-1/order", body: { itemIds: ["q2", "q1"] } })
|
||
|
|
})
|
||
|
|
})
|