openclaw / src /channels /draft-stream-controls.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3144483 verified
Raw History Blame Contribute Delete
12.6 kB
// 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<unknown>({
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<void>>()
.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<unknown> | undefined;
let secondClear: Promise<void> | 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();
});
});