diff options
Diffstat (limited to 'src/features/surface-host/logic')
| -rw-r--r-- | src/features/surface-host/logic/message-queue.test.ts | 48 | ||||
| -rw-r--r-- | src/features/surface-host/logic/message-queue.ts | 45 |
2 files changed, 93 insertions, 0 deletions
diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts new file mode 100644 index 0000000..ce078d9 --- /dev/null +++ b/src/features/surface-host/logic/message-queue.test.ts @@ -0,0 +1,48 @@ +import type { QueuedMessage } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { parseMessageQueuePayload } from "./message-queue"; + +const msg = (id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage => ({ + id, + text, + queuedAt, +}); + +describe("parseMessageQueuePayload", () => { + it("parses a well-formed payload with messages", () => { + const data = parseMessageQueuePayload({ + messages: [msg("m1", "steer left"), msg("m2", "actually, go right")], + }); + expect(data).toEqual({ + messages: [msg("m1", "steer left"), msg("m2", "actually, go right")], + }); + }); + + it("parses an empty-messages payload (queue is empty)", () => { + expect(parseMessageQueuePayload({ messages: [] })).toEqual({ messages: [] }); + }); + + it("preserves message order", () => { + const data = parseMessageQueuePayload({ + messages: [msg("a", "first"), msg("b", "second"), msg("c", "third")], + }); + expect(data?.messages.map((m) => m.id)).toEqual(["a", "b", "c"]); + }); + + it.each([ + ["null", null], + ["a number", 7], + ["a string", "nope"], + ["missing messages key", { foo: [] }], + ["messages not an array", { messages: "x" }], + ["entry not an object", { messages: ["x"] }], + ["entry missing id", { messages: [{ text: "x", queuedAt: 1 }] }], + ["entry with non-string id", { messages: [{ id: 1, text: "x", queuedAt: 1 }] }], + ["entry missing text", { messages: [{ id: "m1", queuedAt: 1 }] }], + ["entry with non-string text", { messages: [{ id: "m1", text: 1, queuedAt: 1 }] }], + ["entry missing queuedAt", { messages: [{ id: "m1", text: "x" }] }], + ["entry with non-finite queuedAt", { messages: [msg("m1", "x", Number.NaN)] }], + ])("returns null for invalid payload: %s", (_label, payload) => { + expect(parseMessageQueuePayload(payload)).toBeNull(); + }); +}); diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts new file mode 100644 index 0000000..a8e1567 --- /dev/null +++ b/src/features/surface-host/logic/message-queue.ts @@ -0,0 +1,45 @@ +import type { QueuedMessage } from "@dispatch/wire"; + +/** + * Pure parser for the `rendererId: "message-queue"` custom-field payload. + * + * The message-queue extension's per-conversation surface emits ONE `custom` + * field with `rendererId: "message-queue"` and `payload: QueuePayload` + * (`{ messages: QueuedMessage[] }` — the current queue snapshot). This parser + * validates the untyped `payload: unknown` at the network seam so a + * hostile/partial payload can never crash the renderer (graceful skip → null). + * + * Empty `messages` is a valid, parseable state (the queue is empty — nothing to + * render); the caller hides the panel. Null is returned only for a malformed + * payload shape. + */ +export interface MessageQueueData { + readonly messages: readonly QueuedMessage[]; +} + +function isQueuedMessage(v: unknown): v is QueuedMessage { + if (typeof v !== "object" || v === null) return false; + const o = v as Record<string, unknown>; + return ( + typeof o.id === "string" && + typeof o.text === "string" && + typeof o.queuedAt === "number" && + Number.isFinite(o.queuedAt) + ); +} + +export function parseMessageQueuePayload(payload: unknown): MessageQueueData | null { + if (typeof payload !== "object" || payload === null) return null; + const obj = payload as Record<string, unknown>; + const raw = obj.messages; + if (!Array.isArray(raw)) return null; + const messages: QueuedMessage[] = []; + for (const entry of raw) { + if (!isQueuedMessage(entry)) return null; + messages.push(entry); + } + return { messages }; +} + +/** The `rendererId` the message-queue extension's `custom` surface field uses. */ +export const MESSAGE_QUEUE_RENDERER_ID = "message-queue"; |
