// Draft stream control tests cover pause, resume, and cancellation handling for channel drafts. import { setImmediate as nextMacrotask } from "node:timers/promises"; import { describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../test/helpers/promise.js"; import { clearFinalizableDraftMessage, createFinalizableDraftLifecycle, createFinalizableDraftStreamControlsForState, takeMessageIdAfterStop, } from "./draft-stream-controls.js"; describe("draft-stream-controls", () => { it("takeMessageIdAfterStop stops, reads, and clears message id", async () => { const events: string[] = []; let messageId: string | undefined = "m-1"; const result = await takeMessageIdAfterStop({ stopForClear: async () => { events.push("stop"); }, readMessageId: () => { events.push("read"); return messageId; }, clearMessageId: () => { events.push("clear"); messageId = undefined; }, }); expect(result).toBe("m-1"); expect(messageId).toBeUndefined(); expect(events).toEqual(["stop", "read", "clear"]); }); it("clearFinalizableDraftMessage deletes valid message ids", async () => { const deleteMessage = vi.fn(async () => {}); const onDeleteSuccess = vi.fn(); await clearFinalizableDraftMessage({ stopForClear: async () => {}, readMessageId: () => "m-2", clearMessageId: () => {}, isValidMessageId: (value): value is string => typeof value === "string", deleteMessage, onDeleteSuccess, warnPrefix: "cleanup failed", }); expect(deleteMessage).toHaveBeenCalledWith("m-2"); expect(onDeleteSuccess).toHaveBeenCalledWith("m-2"); }); it("clearFinalizableDraftMessage skips invalid message ids", async () => { const deleteMessage = vi.fn(async () => {}); await clearFinalizableDraftMessage({ stopForClear: async () => {}, readMessageId: () => 123, clearMessageId: () => {}, isValidMessageId: (value): value is string => typeof value === "string", deleteMessage, warnPrefix: "cleanup failed", }); expect(deleteMessage).not.toHaveBeenCalled(); }); it("clearFinalizableDraftMessage claims a failed delete and reports its retry target", async () => { const warn = vi.fn(); let messageId: string | undefined = "m-3"; const onDeleteFailure = vi.fn(); const deleteMessage = vi.fn(async () => { throw new Error("boom"); }); await clearFinalizableDraftMessage({ stopForClear: async () => {}, readMessageId: () => messageId, clearMessageId: () => { messageId = undefined; }, isValidMessageId: (value): value is string => typeof value === "string", deleteMessage, onDeleteFailure, warn, warnPrefix: "cleanup failed", }); expect(messageId).toBeUndefined(); expect(onDeleteFailure).toHaveBeenCalledWith("m-3"); expect(warn).toHaveBeenCalledWith("cleanup failed: boom"); }); it("clearFinalizableDraftMessage claims an id before concurrent clears", async () => { let messageId: string | undefined = "m-3"; const pendingDelete = createDeferred(); const deleteMessage = vi.fn(() => pendingDelete.promise); const clear = () => clearFinalizableDraftMessage({ stopForClear: async () => {}, readMessageId: () => messageId, clearMessageId: () => { messageId = undefined; }, isValidMessageId: (value): value is string => typeof value === "string", deleteMessage, warnPrefix: "cleanup failed", }); const firstClear = clear(); await vi.waitFor(() => expect(deleteMessage).toHaveBeenCalledWith("m-3")); await clear(); pendingDelete.resolve(); await firstClear; expect(deleteMessage).toHaveBeenCalledTimes(1); }); it("clearFinalizableDraftMessage preserves a replacement id while delete is in flight", async () => { let messageId: string | undefined = "preview-old"; const pendingDelete = createDeferred(); const deleteMessage = vi.fn(() => pendingDelete.promise); const clearPromise = clearFinalizableDraftMessage({ stopForClear: async () => {}, readMessageId: () => messageId, clearMessageId: () => { messageId = undefined; }, isValidMessageId: (value): value is string => typeof value === "string", deleteMessage, warnPrefix: "cleanup failed", }); await vi.waitFor(() => expect(deleteMessage).toHaveBeenCalledWith("preview-old")); messageId = "preview-new"; pendingDelete.resolve(); await clearPromise; expect(messageId).toBe("preview-new"); }); it("clearFinalizableDraftMessage reports the failed target after its id is replaced", async () => { let messageId: string | undefined = "preview-old"; const pendingDelete = createDeferred(); const onDeleteFailure = vi.fn(); const deleteMessage = vi.fn(() => pendingDelete.promise); const clearPromise = clearFinalizableDraftMessage({ stopForClear: async () => {}, readMessageId: () => messageId, clearMessageId: () => { messageId = undefined; }, isValidMessageId: (value): value is string => typeof value === "string", deleteMessage, onDeleteFailure, warnPrefix: "cleanup failed", }); await vi.waitFor(() => expect(deleteMessage).toHaveBeenCalledWith("preview-old")); messageId = "preview-new"; pendingDelete.reject(new Error("boom")); await clearPromise; expect(onDeleteFailure).toHaveBeenCalledWith("preview-old"); expect(messageId).toBe("preview-new"); }); it("lifecycle retries a failed old deletion after the current id is replaced", async () => { const state = { stopped: false, final: false }; let messageId: string | undefined = "preview-old"; const pendingDelete = createDeferred(); const warn = vi.fn(); const deleteMessage = vi .fn<(messageId: string) => Promise>() .mockImplementationOnce(() => pendingDelete.promise) .mockResolvedValue(undefined); const lifecycle = createFinalizableDraftLifecycle({ throttleMs: 250, state, sendOrEditStreamMessage: async () => true, readMessageId: () => messageId, clearMessageId: () => { messageId = undefined; }, isValidMessageId: (value): value is string => typeof value === "string", deleteMessage, warn, warnPrefix: "cleanup failed", }); const firstClear = lifecycle.clear(); await vi.waitFor(() => expect(deleteMessage).toHaveBeenCalledWith("preview-old")); messageId = "preview-new"; pendingDelete.reject(new Error("boom")); await firstClear; expect(messageId).toBe("preview-new"); expect(warn).toHaveBeenCalledWith("cleanup failed: boom"); await lifecycle.clear(); expect(deleteMessage.mock.calls.map(([id]) => id)).toEqual([ "preview-old", "preview-old", "preview-new", ]); expect(messageId).toBeUndefined(); }); it("lifecycle does not retry a successful delete when its success callback fails", async () => { const state = { stopped: false, final: false }; let messageId: string | undefined = "preview-old"; const deleteMessage = vi.fn(async () => {}); const warn = vi.fn(); const lifecycle = createFinalizableDraftLifecycle({ throttleMs: 250, state, sendOrEditStreamMessage: async () => true, readMessageId: () => messageId, clearMessageId: () => { messageId = undefined; }, isValidMessageId: (value): value is string => typeof value === "string", deleteMessage, onDeleteSuccess: () => { throw new Error("callback boom"); }, warn, warnPrefix: "cleanup failed", }); await lifecycle.clear(); await lifecycle.clear(); expect(deleteMessage).toHaveBeenCalledTimes(1); expect(messageId).toBeUndefined(); expect(warn).toHaveBeenCalledWith("cleanup failed after delete: callback boom"); }); it("keeps deletion serialized when stop rejects during an earlier clear", async () => { const state = { stopped: false, final: false }; let messageId: string | undefined = "preview-old"; const deleteStarted = createDeferred(); const releaseDelete = createDeferred(); const pendingSend = createDeferred(); const stopError = new Error("flush failed"); const deletedIds: string[] = []; let activeDeletes = 0; let maxActiveDeletes = 0; const lifecycle = createFinalizableDraftLifecycle({ throttleMs: 0, state, sendOrEditStreamMessage: () => pendingSend.promise, readMessageId: () => messageId, clearMessageId: () => { messageId = undefined; }, isValidMessageId: (value): value is string => typeof value === "string", deleteMessage: async (id) => { deletedIds.push(id); activeDeletes += 1; maxActiveDeletes = Math.max(maxActiveDeletes, activeDeletes); try { if (id === "preview-old") { deleteStarted.resolve(); await releaseDelete.promise; } } finally { activeDeletes -= 1; } }, warnPrefix: "cleanup failed", }); const firstClear = lifecycle.clearWithStop(async () => {}); let stopped: Promise | undefined; let secondClear: Promise | undefined; try { await deleteStarted.promise; messageId = "preview-new"; lifecycle.update("Pending update"); stopped = lifecycle.stop().catch((error: unknown) => error); expect(state.final).toBe(true); secondClear = lifecycle.clear(); pendingSend.reject(stopError); // Let queued promise reactions run while the first physical deletion is still held. await nextMacrotask(); expect(maxActiveDeletes).toBe(1); releaseDelete.resolve(); await Promise.all([firstClear, secondClear]); expect(await stopped).toBe(stopError); expect(deletedIds).toEqual(["preview-old", "preview-new"]); } finally { pendingSend.resolve(); releaseDelete.resolve(); await Promise.allSettled([firstClear, stopped, secondClear]); lifecycle.loop.stop(); } }); it("controls ignore updates after final", async () => { const sendOrEditStreamMessage = vi.fn(async () => true); const controls = createFinalizableDraftStreamControlsForState({ throttleMs: 250, state: { stopped: false, final: true }, sendOrEditStreamMessage, }); controls.update("ignored"); await controls.loop.flush(); expect(sendOrEditStreamMessage).not.toHaveBeenCalled(); }); it("lifecycle clear marks stopped, clears id, and deletes preview message", async () => { const state = { stopped: false, final: false }; let messageId: string | undefined = "m-4"; const deleteMessage = vi.fn(async () => {}); const lifecycle = createFinalizableDraftLifecycle({ throttleMs: 250, state, sendOrEditStreamMessage: async () => true, readMessageId: () => messageId, clearMessageId: () => { messageId = undefined; }, isValidMessageId: (value): value is string => typeof value === "string", deleteMessage, warnPrefix: "cleanup failed", }); await lifecycle.clear(); expect(state.stopped).toBe(true); expect(messageId).toBeUndefined(); expect(deleteMessage).toHaveBeenCalledWith("m-4"); }); it("lifecycle seal ignores late updates without clearing the preview id", async () => { const state = { stopped: false, final: false }; let messageId: string | undefined = "m-5"; const sendOrEditStreamMessage = vi.fn(async () => true); const deleteMessage = vi.fn(async () => {}); const lifecycle = createFinalizableDraftLifecycle({ throttleMs: 250, state, sendOrEditStreamMessage, readMessageId: () => messageId, clearMessageId: () => { messageId = undefined; }, isValidMessageId: (value): value is string => typeof value === "string", deleteMessage, warnPrefix: "cleanup failed", }); lifecycle.update("stale"); await lifecycle.seal(); lifecycle.update("late"); await lifecycle.loop.flush(); expect(state.final).toBe(true); expect(messageId).toBe("m-5"); expect(sendOrEditStreamMessage).toHaveBeenCalledTimes(1); expect(sendOrEditStreamMessage).toHaveBeenCalledWith("stale"); expect(deleteMessage).not.toHaveBeenCalled(); }); });