Deliver queue updates on the control SSE stream, recover independently of bootstrap suppression, and preserve authoritative empty snapshots against delayed responses. Coalesce hydration and recovery while retaining unfinished legacy migration across runtime switches. Workspace type-check, lint, tests and build passed. Follow-up recovery and migration fixes pass 27 queue tests, 3 control-stream tests and UI type-check.
467 lines
24 KiB
TypeScript
467 lines
24 KiB
TypeScript
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
|
import { selectInputHistoryEntries, useInputHistoryStore } from "./useInputHistoryStore"
|
|
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 activeRuntimeKey = "runtime-a"
|
|
let respond: (call: FetchCall) => Response | Promise<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: () => activeRuntimeKey }))
|
|
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 deferredResponse = () => {
|
|
let complete: ((response: Response) => void) | undefined
|
|
const promise = new Promise<Response>((resolve) => { complete = resolve })
|
|
return { promise, resolve: (response: Response) => {
|
|
if (!complete) throw new Error("Deferred response was not initialized")
|
|
complete(response)
|
|
} }
|
|
}
|
|
|
|
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,
|
|
text: content,
|
|
attachments: [],
|
|
sendConfig: { providerID: "p", modelID: "m" },
|
|
...extra,
|
|
})
|
|
|
|
const issueMetadata = { openchamberContext: { kind: "github-issue" as const, number: 3, title: "Bug", url: "https://x/issues/3" } }
|
|
|
|
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(() => {
|
|
useMessageQueueStore.getState().resetForRuntimeSwitch(activeRuntimeKey)
|
|
activeRuntimeKey = "runtime-a"
|
|
useInputHistoryStore.setState({ globalBuckets: {}, sessionBuckets: {} })
|
|
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", text: "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: [], context: [], 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("hydrate keeps a queue newer than its snapshot", async () => {
|
|
applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "queued after the read started")])), "runtime-a")
|
|
respond = () => json({ revision: 9, sessions: [] })
|
|
await useMessageQueueStore.getState().hydrate()
|
|
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
|
|
})
|
|
|
|
test("resync can establish the initial snapshot before bootstrap", async () => {
|
|
activeRuntimeKey = "runtime-never-hydrated"
|
|
respond = () => json({ revision: 1, sessions: [] })
|
|
await useMessageQueueStore.getState().resync()
|
|
expect(calls).toHaveLength(1)
|
|
})
|
|
|
|
test("a reconnect during the initial snapshot retains one trailing refresh", async () => {
|
|
const first = deferredResponse()
|
|
respond = () => calls.length === 1 ? first.promise : json({ revision: 12, sessions: [] })
|
|
const bootstrap = useMessageQueueStore.getState().hydrate()
|
|
const reconnect = useMessageQueueStore.getState().resync()
|
|
const secondReconnect = useMessageQueueStore.getState().resync()
|
|
expect(calls).toHaveLength(1)
|
|
first.resolve(json({ revision: 10, sessions: [session([serverItem("q1", "delivered after snapshot")])] }))
|
|
await Promise.all([bootstrap, reconnect, secondReconnect])
|
|
expect(calls).toHaveLength(2)
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined()
|
|
})
|
|
|
|
test("concurrent bootstrap and recovery migrate a legacy message only once", async () => {
|
|
activeRuntimeKey = "runtime-legacy-recovery"
|
|
const legacyTarget = createMessageQueueTarget("session-1", "/repo", activeRuntimeKey)
|
|
if (!legacyTarget) throw new Error("Missing test target")
|
|
const legacyKey = getMessageQueueKey(legacyTarget)
|
|
useMessageQueueStore.setState({ queuedMessages: { [legacyKey]: [{ id: "local", content: "legacy", text: "legacy", createdAt: 1, sendConfig: { providerID: "p", modelID: "m" } }] } })
|
|
const upload = deferredResponse()
|
|
respond = (call) => call.method === "POST" ? upload.promise : json({ revision: 2, sessions: [] })
|
|
const bootstrap = useMessageQueueStore.getState().hydrate()
|
|
const recovery = useMessageQueueStore.getState().resync()
|
|
upload.resolve(json({ revision: 2, session: session([]) }))
|
|
await Promise.all([bootstrap, recovery])
|
|
expect(calls.filter((call) => call.method === "POST")).toHaveLength(1)
|
|
expect(useMessageQueueStore.getState().queuedMessages[legacyKey]).toBeUndefined()
|
|
})
|
|
|
|
test("an empty snapshot prevents delayed responses from resurrecting omitted queues", async () => {
|
|
applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "queued")], "q1")), "runtime-a")
|
|
respond = () => json({ revision: 12, sessions: [] })
|
|
await useMessageQueueStore.getState().hydrate()
|
|
applyMessageQueueUpdatedEvent(updated(11, session([serverItem("q1", "stale")], "q1")), "runtime-a")
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined()
|
|
expect(useMessageQueueStore.getState().sendingIds[key]).toBeUndefined()
|
|
const other = { ...session([serverItem("q2", "unseen stale")]), sessionId: "unseen" }
|
|
applyMessageQueueUpdatedEvent(updated(11, other), "runtime-a")
|
|
expect(Object.keys(useMessageQueueStore.getState().queuedMessages)).toHaveLength(0)
|
|
})
|
|
|
|
test("recovery demand survives a failed in-flight snapshot", async () => {
|
|
const first = deferredResponse()
|
|
applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "delivered")])), "runtime-a")
|
|
respond = () => calls.length === 1 ? first.promise : json({ revision: 12, sessions: [] })
|
|
const bootstrap = useMessageQueueStore.getState().hydrate()
|
|
const recovery = useMessageQueueStore.getState().resync()
|
|
first.resolve(new Response(null, { status: 503 }))
|
|
await Promise.all([bootstrap, recovery])
|
|
expect(calls).toHaveLength(2)
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined()
|
|
})
|
|
|
|
test("returning to a runtime migrates its unattempted legacy messages without repeating the first upload", async () => {
|
|
activeRuntimeKey = "runtime-partial-migration"
|
|
const legacyTarget = createMessageQueueTarget("session-1", "/repo", activeRuntimeKey)
|
|
if (!legacyTarget) throw new Error("Missing test target")
|
|
const legacyKey = getMessageQueueKey(legacyTarget)
|
|
useMessageQueueStore.setState({ queuedMessages: { [legacyKey]: ["first", "second"].map((id) => ({ id, content: id, text: id, createdAt: 1, sendConfig: { providerID: "p", modelID: "m" } })) } })
|
|
const first = deferredResponse()
|
|
respond = (call) => call.method === "POST"
|
|
? calls.length === 1 ? first.promise : json({ revision: 2, session: session([]) })
|
|
: json({ revision: 3, sessions: [] })
|
|
const initial = useMessageQueueStore.getState().hydrate()
|
|
useMessageQueueStore.getState().resetForRuntimeSwitch(activeRuntimeKey)
|
|
activeRuntimeKey = "runtime-other"
|
|
first.resolve(json({ revision: 1, session: session([]) }))
|
|
await initial
|
|
activeRuntimeKey = "runtime-partial-migration"
|
|
await useMessageQueueStore.getState().hydrate()
|
|
expect(calls.filter((call) => call.method === "POST").map((call) => call.body.item.content)).toEqual(["first", "second"])
|
|
})
|
|
|
|
test("a failed refresh preserves the projection and a later recovery retries", async () => {
|
|
applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "queued")])), "runtime-a")
|
|
respond = () => new Response(null, { status: 503 })
|
|
await expect(useMessageQueueStore.getState().resync()).rejects.toThrow()
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1)
|
|
respond = () => json({ revision: 12, sessions: [] })
|
|
await useMessageQueueStore.getState().resync()
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined()
|
|
})
|
|
|
|
test("a runtime switch rejects an old snapshot and its pending recovery", async () => {
|
|
const old = deferredResponse()
|
|
respond = () => old.promise
|
|
const bootstrap = useMessageQueueStore.getState().hydrate()
|
|
const recovery = useMessageQueueStore.getState().resync()
|
|
useMessageQueueStore.getState().resetForRuntimeSwitch(activeRuntimeKey)
|
|
activeRuntimeKey = "runtime-b"
|
|
respond = () => json({ revision: 1, sessions: [] })
|
|
await useMessageQueueStore.getState().hydrate()
|
|
old.resolve(json({ revision: 99, sessions: [session([serverItem("q1", "old runtime")])] }))
|
|
await Promise.all([bootstrap, recovery])
|
|
expect(Object.keys(useMessageQueueStore.getState().queuedMessages)).toHaveLength(0)
|
|
expect(calls).toHaveLength(2)
|
|
})
|
|
|
|
test("resync drops a queue the server no longer lists", async () => {
|
|
respond = () => json({ revision: 3, sessions: [session([serverItem("q1", "queued")], "q1")] })
|
|
await useMessageQueueStore.getState().hydrate()
|
|
|
|
respond = () => json({ revision: 4, sessions: [] })
|
|
await useMessageQueueStore.getState().resync()
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
|
})
|
|
|
|
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 }],
|
|
context: [],
|
|
sendConfig: { providerID: "p", modelID: "m", agent: "build" },
|
|
},
|
|
},
|
|
})
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["srv-1"])
|
|
})
|
|
|
|
test("accepted queue history survives automatic delivery and manual take without recapture", async () => {
|
|
const historyTarget = { runtimeKey: 'runtime-a', directory: '/repo', sessionId: 'history-accepted' };
|
|
const item = serverItem('history-item', 'original prompt');
|
|
respond = () => json({ revision: 100, session: { ...session([item]), sessionId: historyTarget.sessionId } });
|
|
const pending = useMessageQueueStore.getState().addToQueue(historyTarget, {
|
|
content: item.content,
|
|
attachments: [{ ...attachment, dataUrl: 'file:///repo/note.txt' }],
|
|
sendConfig: { providerID: 'p', modelID: 'm' },
|
|
});
|
|
const entries = () => selectInputHistoryEntries({ ...useInputHistoryStore.getState(), scope: 'session' }, historyTarget);
|
|
expect(entries()).toHaveLength(0);
|
|
await pending;
|
|
expect(entries().map((entry) => entry.text)).toEqual(['original prompt']);
|
|
expect(entries()[0]?.restorableAttachments[0]?.reference).toBe('file:///repo/note.txt');
|
|
// A server delivery broadcast removes the projection, never the history.
|
|
applyMessageQueueUpdatedEvent(updated(101, { ...session([]), sessionId: historyTarget.sessionId }), historyTarget.runtimeKey);
|
|
expect(entries()).toHaveLength(1);
|
|
respond = () => json({ revision: 102, session: { ...session([]), sessionId: historyTarget.sessionId }, items: [item] });
|
|
await useMessageQueueStore.getState().takeForSend(historyTarget);
|
|
expect(entries()).toHaveLength(1);
|
|
});
|
|
|
|
test("queue acceptance records the captured owner after the active runtime changes", async () => {
|
|
const historyTarget = { runtimeKey: 'runtime-a', directory: '/original', sessionId: 'history-runtime-switch' };
|
|
respond = () => {
|
|
activeRuntimeKey = 'runtime-b';
|
|
return json({ revision: 110, session: { ...session([]), sessionId: historyTarget.sessionId, directory: historyTarget.directory } });
|
|
};
|
|
await useMessageQueueStore.getState().addToQueue(historyTarget, {
|
|
content: 'for original runtime', sendConfig: { providerID: 'p', modelID: 'm' },
|
|
});
|
|
expect(selectInputHistoryEntries({ ...useInputHistoryStore.getState(), scope: 'session' }, historyTarget).map((entry) => entry.text)).toEqual(['for original runtime']);
|
|
expect(selectInputHistoryEntries({ ...useInputHistoryStore.getState(), scope: 'session' }, { ...historyTarget, runtimeKey: activeRuntimeKey })).toEqual([]);
|
|
});
|
|
|
|
test("a rejected queue acceptance records no history", async () => {
|
|
const historyTarget = { runtimeKey: 'runtime-a', directory: '/repo', sessionId: 'history-rejected' };
|
|
respond = () => new Response('rejected', { status: 500 });
|
|
await expect(useMessageQueueStore.getState().addToQueue(historyTarget, {
|
|
content: 'rejected prompt', sendConfig: { providerID: 'p', modelID: 'm' },
|
|
})).rejects.toThrow();
|
|
expect(selectInputHistoryEntries({ ...useInputHistoryStore.getState(), scope: 'session' }, historyTarget)).toEqual([]);
|
|
});
|
|
|
|
test("addToQueue hands the captured context to the server, and a take brings it back", async () => {
|
|
const context = [
|
|
{ kind: "context" as const, text: "issue body", metadata: issueMetadata },
|
|
{ kind: "synthetic" as const, text: "conflict payload" },
|
|
]
|
|
respond = () => json({ revision: 6, session: session([serverItem("srv-1", "with context")]) })
|
|
await useMessageQueueStore.getState().addToQueue(target, {
|
|
content: "with context",
|
|
context,
|
|
sendConfig: { providerID: "p", modelID: "m" },
|
|
})
|
|
expect(calls[0]?.body.item.context).toEqual(context)
|
|
// The projection carries no context; the server strips payloads from snapshots.
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]?.[0]?.context).toBe(undefined)
|
|
|
|
respond = () => json({ revision: 7, session: session([]), item: serverItem("srv-1", "with context", { context }) })
|
|
const [taken] = await useMessageQueueStore.getState().takeForSend(target, "srv-1")
|
|
expect(taken?.context).toEqual(context)
|
|
expect(taken?.text).toBe("with context")
|
|
})
|
|
|
|
test("a server item with malformed context is rejected at the boundary", async () => {
|
|
respond = () => new Response(JSON.stringify({
|
|
revision: 8,
|
|
session: session([]),
|
|
item: { ...serverItem("srv-1", "x"), context: [{ kind: "context", text: "x", metadata: { openchamberContext: { kind: "nope" } } }] },
|
|
}), { status: 200 })
|
|
await expect(useMessageQueueStore.getState().takeForSend(target, "srv-1")).rejects.toThrow()
|
|
})
|
|
|
|
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("a failed take re-reads the server so a stale projection is cleared", async () => {
|
|
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "already delivered", text: "already delivered", createdAt: 1 }] } })
|
|
respond = (call) => (call.path.endsWith("/take")
|
|
? new Response(JSON.stringify({ error: "queued message not found" }), { status: 404 })
|
|
: json({ revision: 12, sessions: [] }))
|
|
await expect(useMessageQueueStore.getState().takeForSend(target, "q1")).rejects.toThrow()
|
|
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
|
})
|
|
|
|
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("an empty session without a directory still clears the projection it was keyed under", () => {
|
|
applyMessageQueueUpdatedEvent(updated(4, session([serverItem("q1", "queued")], "q1")), "runtime-a")
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1)
|
|
expect(useMessageQueueStore.getState().sendingIds[key]).toEqual(["q1"])
|
|
|
|
// A server that forgot the directory once the queue emptied.
|
|
applyMessageQueueUpdatedEvent(updated(5, { sessionId: "session-1", directory: "", items: [], sendingId: null }), "runtime-a")
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
|
expect(useMessageQueueStore.getState().sendingIds[key]).toBe(undefined)
|
|
|
|
// Still never backwards, and never another runtime's projection.
|
|
applyMessageQueueUpdatedEvent(updated(6, session([serverItem("q2", "later")])), "runtime-a")
|
|
applyMessageQueueUpdatedEvent(updated(3, { sessionId: "session-1", directory: "", items: [], sendingId: null }), "runtime-a")
|
|
applyMessageQueueUpdatedEvent(updated(9, { sessionId: "session-1", directory: "", items: [], sendingId: null }), "runtime-b")
|
|
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["later"])
|
|
})
|
|
|
|
test("removeFromQueue and clearQueue update locally and tell the server", async () => {
|
|
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", text: "a", createdAt: 1 }, { id: "q2", content: "b", text: "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", text: "a", createdAt: 1 }, { id: "q2", content: "b", text: "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"] } })
|
|
})
|
|
})
|