924 lines
30 KiB
TypeScript
924 lines
30 KiB
TypeScript
/* eslint-disable max-lines -- queue lifecycle cases share one authoritative hook harness. */
|
|
|
|
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { QueuedMessage, QueueOperationToken } from "@/lib/state/slices/session/types";
|
|
import type { EntityReference } from "@/lib/types/entity-reference";
|
|
|
|
const queueApiMock = vi.hoisted(() => {
|
|
class QueueEntryNotFoundError extends Error {}
|
|
class QueueSendNowError extends Error {}
|
|
class QueueReorderError extends Error {}
|
|
return {
|
|
QueueEntryNotFoundError,
|
|
QueueSendNowError,
|
|
QueueReorderError,
|
|
queueMessage: vi.fn(),
|
|
clearQueue: vi.fn(),
|
|
getQueueStatus: vi.fn(),
|
|
updateQueuedMessage: vi.fn(),
|
|
removeQueuedEntry: vi.fn(),
|
|
mergeQueuedEntry: vi.fn(),
|
|
reorderQueuedEntries: vi.fn(),
|
|
sendQueuedNow: vi.fn(),
|
|
setQueueAutoRun: vi.fn(),
|
|
setQueueAutoMerge: vi.fn(),
|
|
};
|
|
});
|
|
|
|
type MockQueueState = {
|
|
queue: {
|
|
bySessionId: Record<string, QueuedMessage[]>;
|
|
metaBySessionId: Record<
|
|
string,
|
|
{ count: number; max: number; mergeEnabled?: boolean; autoRun?: boolean }
|
|
>;
|
|
activeOperationBySessionId: Record<string, QueueOperationToken>;
|
|
};
|
|
connection: { status: string };
|
|
taskSessions: {
|
|
items: Record<
|
|
string,
|
|
{
|
|
task_id?: string;
|
|
queue_incarnation_id?: string;
|
|
cancellation_pending?: boolean;
|
|
}
|
|
>;
|
|
};
|
|
setQueueEntries: ReturnType<typeof vi.fn>;
|
|
removeQueueEntry: ReturnType<typeof vi.fn>;
|
|
beginQueueOperation: ReturnType<typeof vi.fn>;
|
|
finishQueueOperation: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
let mockState: MockQueueState;
|
|
|
|
vi.mock("@/components/state-provider", () => ({
|
|
useAppStore: (selector: (state: MockQueueState) => unknown) => selector(mockState),
|
|
}));
|
|
|
|
vi.mock("@/lib/api/domains/queue-api", () => queueApiMock);
|
|
|
|
import { useQueue } from "./use-queue";
|
|
|
|
const SESSION_ID = "sess-1";
|
|
const STALE_ENTRY_ID = "stale-entry";
|
|
const TASK_ID = "task-1";
|
|
const IDENTITY = {
|
|
task_id: TASK_ID,
|
|
session_id: SESSION_ID,
|
|
session_incarnation_id: "incarnation-1",
|
|
};
|
|
const reference: EntityReference = {
|
|
version: 1,
|
|
ref: "mention:v1:github:issue:acme%2Frepo:42",
|
|
provider: "github",
|
|
kind: "issue",
|
|
id: "42",
|
|
key: "acme/repo#42",
|
|
title: "Fix composer references",
|
|
url: "https://github.com/acme/repo/issues/42",
|
|
scope: "acme/repo",
|
|
};
|
|
|
|
function entry(overrides: Partial<QueuedMessage> = {}): QueuedMessage {
|
|
return {
|
|
id: "q-1",
|
|
session_id: SESSION_ID,
|
|
task_id: TASK_ID,
|
|
content: "queued prompt",
|
|
plan_mode: false,
|
|
queued_at: "2026-06-27T00:00:00Z",
|
|
queued_by: "user",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function setDocumentVisibility(value: DocumentVisibilityState) {
|
|
Object.defineProperty(document, "visibilityState", {
|
|
configurable: true,
|
|
value,
|
|
});
|
|
}
|
|
|
|
function resetMockState() {
|
|
mockState = {
|
|
queue: {
|
|
bySessionId: {},
|
|
metaBySessionId: {},
|
|
activeOperationBySessionId: {},
|
|
},
|
|
connection: { status: "connected" },
|
|
taskSessions: {
|
|
items: {
|
|
[SESSION_ID]: {
|
|
task_id: TASK_ID,
|
|
queue_incarnation_id: IDENTITY.session_incarnation_id,
|
|
},
|
|
},
|
|
},
|
|
setQueueEntries: vi.fn(),
|
|
removeQueueEntry: vi.fn(),
|
|
beginQueueOperation: vi
|
|
.fn()
|
|
.mockReturnValue({ sessionIncarnationId: IDENTITY.session_incarnation_id, generation: 1 }),
|
|
finishQueueOperation: vi.fn(),
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetMockState();
|
|
setDocumentVisibility("visible");
|
|
queueApiMock.getQueueStatus.mockResolvedValue({ ...IDENTITY, entries: [], count: 0, max: 10 });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// eslint-disable-next-line max-lines-per-function -- queue admission and lifecycle cases share one hook contract.
|
|
describe("useQueue", () => {
|
|
it("reports queue readiness only when the session has a complete identity", async () => {
|
|
const { result, rerender } = renderHook(() => useQueue(SESSION_ID));
|
|
|
|
await waitFor(() => expect(result.current.isQueueReady).toBe(true));
|
|
|
|
mockState.taskSessions.items[SESSION_ID].queue_incarnation_id = undefined;
|
|
rerender();
|
|
|
|
expect(result.current.isQueueReady).toBe(false);
|
|
});
|
|
|
|
it("reports unsuccessful admission when the queue identity is unavailable", async () => {
|
|
mockState.taskSessions.items[SESSION_ID].queue_incarnation_id = undefined;
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
|
|
await expect(result.current.queue({ taskId: TASK_ID, content: "preserve me" })).resolves.toBe(
|
|
false,
|
|
);
|
|
expect(queueApiMock.queueMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports unsuccessful admission when an operation token cannot be acquired", async () => {
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
mockState.beginQueueOperation.mockReturnValue(undefined);
|
|
|
|
await expect(result.current.queue({ taskId: TASK_ID, content: "preserve me" })).resolves.toBe(
|
|
false,
|
|
);
|
|
expect(queueApiMock.queueMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refetches the queue snapshot when the WebSocket reconnects", async () => {
|
|
mockState.connection.status = "disconnected";
|
|
const { rerender } = renderHook(() => useQueue(SESSION_ID));
|
|
|
|
await act(async () => {});
|
|
expect(queueApiMock.getQueueStatus).not.toHaveBeenCalled();
|
|
|
|
mockState.connection.status = "connected";
|
|
rerender();
|
|
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY));
|
|
expect(mockState.setQueueEntries).toHaveBeenCalledWith(
|
|
SESSION_ID,
|
|
[],
|
|
{
|
|
count: 0,
|
|
max: 10,
|
|
mergeEnabled: true,
|
|
autoRun: true,
|
|
taskId: TASK_ID,
|
|
sessionIncarnationId: IDENTITY.session_incarnation_id,
|
|
},
|
|
{ establishStatusEpoch: true },
|
|
);
|
|
});
|
|
|
|
it("rejects an identity-less queue snapshot after session identity is known", async () => {
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
mockState.setQueueEntries.mockClear();
|
|
queueApiMock.getQueueStatus.mockResolvedValueOnce({ entries: [entry()], count: 1, max: 10 });
|
|
|
|
await act(async () => {
|
|
await result.current.refetch();
|
|
});
|
|
|
|
expect(mockState.setQueueEntries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refetches a stale queue snapshot when a suspended tab becomes visible again", async () => {
|
|
mockState.queue.bySessionId[SESSION_ID] = [entry()];
|
|
mockState.queue.metaBySessionId[SESSION_ID] = { count: 1, max: 10 };
|
|
queueApiMock.getQueueStatus.mockResolvedValueOnce({
|
|
...IDENTITY,
|
|
entries: [entry()],
|
|
count: 1,
|
|
max: 10,
|
|
});
|
|
|
|
renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledTimes(1));
|
|
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
mockState.setQueueEntries.mockClear();
|
|
queueApiMock.getQueueStatus.mockResolvedValueOnce({
|
|
...IDENTITY,
|
|
entries: [],
|
|
count: 0,
|
|
max: 10,
|
|
});
|
|
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY));
|
|
expect(mockState.setQueueEntries).toHaveBeenCalledWith(
|
|
SESSION_ID,
|
|
[],
|
|
{
|
|
count: 0,
|
|
max: 10,
|
|
mergeEnabled: true,
|
|
autoRun: true,
|
|
taskId: TASK_ID,
|
|
sessionIncarnationId: IDENTITY.session_incarnation_id,
|
|
},
|
|
{ establishStatusEpoch: true },
|
|
);
|
|
});
|
|
|
|
it("refetches a stale queue snapshot when the Kandev window regains focus", async () => {
|
|
renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledTimes(1));
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
|
|
window.dispatchEvent(new Event("focus"));
|
|
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY));
|
|
});
|
|
|
|
it("does not refetch on foreground visibility while disconnected", async () => {
|
|
mockState.connection.status = "disconnected";
|
|
renderHook(() => useQueue(SESSION_ID));
|
|
|
|
await act(async () => {});
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
|
|
expect(queueApiMock.getQueueStatus).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("useQueue message metadata", () => {
|
|
it("queues structured references with busy-agent messages", async () => {
|
|
queueApiMock.queueMessage.mockResolvedValue(entry());
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.queueMessage.mockClear();
|
|
|
|
await act(async () => {
|
|
await result.current.queue({
|
|
taskId: TASK_ID,
|
|
content: "queued reference",
|
|
entityReferences: [reference],
|
|
} as never);
|
|
});
|
|
|
|
expect(queueApiMock.queueMessage).toHaveBeenCalledWith({
|
|
session_id: SESSION_ID,
|
|
session_incarnation_id: IDENTITY.session_incarnation_id,
|
|
task_id: TASK_ID,
|
|
content: "queued reference",
|
|
model: undefined,
|
|
plan_mode: undefined,
|
|
attachments: undefined,
|
|
entity_references: [reference],
|
|
});
|
|
});
|
|
|
|
it("refetches authoritative status after a queue admission error", async () => {
|
|
const mutationError = new Error("queue failed");
|
|
queueApiMock.queueMessage.mockRejectedValueOnce(mutationError);
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
|
|
await act(async () => {
|
|
await expect(result.current.queue({ taskId: TASK_ID, content: "rejected" })).rejects.toBe(
|
|
mutationError,
|
|
);
|
|
});
|
|
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
});
|
|
|
|
it("replaces queued reference metadata with an explicit empty array", async () => {
|
|
queueApiMock.updateQueuedMessage.mockResolvedValue({ entry_id: "q-1" });
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
|
|
await act(async () => {
|
|
await result.current.editEntry("q-1", "reference removed", undefined, [] as never);
|
|
});
|
|
|
|
expect(queueApiMock.updateQueuedMessage).toHaveBeenCalledWith({
|
|
session_id: SESSION_ID,
|
|
task_id: TASK_ID,
|
|
session_incarnation_id: IDENTITY.session_incarnation_id,
|
|
entry_id: "q-1",
|
|
content: "reference removed",
|
|
attachments: undefined,
|
|
entity_references: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
type QueueSnapshot = { entries: QueuedMessage[]; count: number; max: number };
|
|
|
|
describe("queue refetch races", () => {
|
|
beforeEach(() => {
|
|
resetMockState();
|
|
setDocumentVisibility("visible");
|
|
queueApiMock.getQueueStatus.mockResolvedValue({ entries: [], count: 0, max: 10 });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("does not let a stale refetch overwrite a newer queue event", async () => {
|
|
const pendingSnapshot = Promise.withResolvers<QueueSnapshot>();
|
|
const newerSnapshot = Promise.withResolvers<QueueSnapshot>();
|
|
queueApiMock.getQueueStatus
|
|
.mockReturnValueOnce(pendingSnapshot.promise)
|
|
.mockReturnValueOnce(newerSnapshot.promise);
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledTimes(1));
|
|
|
|
const eventEntry = entry({ id: "event-entry", content: "newer event" });
|
|
await act(async () => {
|
|
void result.current.refetch();
|
|
});
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledTimes(2));
|
|
|
|
await act(async () => {
|
|
pendingSnapshot.resolve({
|
|
...IDENTITY,
|
|
entries: [entry({ id: STALE_ENTRY_ID })],
|
|
count: 1,
|
|
max: 10,
|
|
});
|
|
newerSnapshot.resolve({
|
|
...IDENTITY,
|
|
entries: [eventEntry],
|
|
count: 1,
|
|
max: 10,
|
|
});
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mockState.setQueueEntries).not.toHaveBeenCalledWith(
|
|
SESSION_ID,
|
|
[expect.objectContaining({ id: STALE_ENTRY_ID })],
|
|
expect.anything(),
|
|
);
|
|
expect(mockState.setQueueEntries).toHaveBeenCalledWith(
|
|
SESSION_ID,
|
|
[eventEntry],
|
|
expect.anything(),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
it("drops a delayed response from a previous session when both snapshots lack metadata", async () => {
|
|
const previousSessionId = SESSION_ID;
|
|
const pendingSnapshot = Promise.withResolvers<QueueSnapshot>();
|
|
queueApiMock.getQueueStatus.mockReturnValueOnce(pendingSnapshot.promise);
|
|
const { rerender } = renderHook(({ sessionId }: { sessionId: string }) => useQueue(sessionId), {
|
|
initialProps: { sessionId: previousSessionId },
|
|
});
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY));
|
|
|
|
rerender({ sessionId: "sess-2" });
|
|
|
|
await act(async () => {
|
|
pendingSnapshot.resolve({
|
|
...IDENTITY,
|
|
entries: [entry({ session_id: previousSessionId, id: STALE_ENTRY_ID })],
|
|
count: 1,
|
|
max: 10,
|
|
});
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mockState.setQueueEntries).not.toHaveBeenCalledWith(
|
|
previousSessionId,
|
|
[expect.objectContaining({ id: STALE_ENTRY_ID })],
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
describe("useQueue context file metadata and Send Now", () => {
|
|
it("forwards context file metadata with queued messages", async () => {
|
|
queueApiMock.queueMessage.mockResolvedValue(entry());
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.queueMessage.mockClear();
|
|
|
|
await act(async () => {
|
|
await result.current.queue({
|
|
taskId: TASK_ID,
|
|
content: "queued context",
|
|
contextFilesMeta: [{ path: "src/components", name: "components", is_directory: true }],
|
|
} as never);
|
|
});
|
|
|
|
expect(queueApiMock.queueMessage).toHaveBeenCalledWith({
|
|
session_id: SESSION_ID,
|
|
session_incarnation_id: IDENTITY.session_incarnation_id,
|
|
task_id: TASK_ID,
|
|
content: "queued context",
|
|
model: undefined,
|
|
plan_mode: undefined,
|
|
attachments: undefined,
|
|
entity_references: undefined,
|
|
context_files: [{ path: "src/components", name: "components", is_directory: true }],
|
|
});
|
|
});
|
|
|
|
it("sends one exact entry now and refetches authoritative status", async () => {
|
|
queueApiMock.sendQueuedNow.mockResolvedValue({
|
|
session_id: SESSION_ID,
|
|
dispatched: true,
|
|
sent_count: 1,
|
|
});
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
|
|
await act(async () => {
|
|
await result.current.sendEntryNow("q-2");
|
|
});
|
|
|
|
expect(queueApiMock.sendQueuedNow).toHaveBeenCalledWith({
|
|
session_id: SESSION_ID,
|
|
task_id: TASK_ID,
|
|
session_incarnation_id: IDENTITY.session_incarnation_id,
|
|
scope: "entry",
|
|
entry_id: "q-2",
|
|
});
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
});
|
|
});
|
|
|
|
describe("useQueue automation controls", () => {
|
|
it("refetches after an Auto-run mutation failure and preserves its error", async () => {
|
|
const mutationError = new Error("policy update failed");
|
|
queueApiMock.setQueueAutoRun.mockRejectedValueOnce(mutationError);
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
|
|
await act(async () => {
|
|
await expect(result.current.setAutoRun(false)).rejects.toBe(mutationError);
|
|
});
|
|
|
|
expect(queueApiMock.setQueueAutoRun).toHaveBeenCalledWith(IDENTITY, false);
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
});
|
|
|
|
it("exposes authoritative cancellation progress for disabling controls", async () => {
|
|
mockState.taskSessions.items[SESSION_ID] = {
|
|
task_id: TASK_ID,
|
|
queue_incarnation_id: IDENTITY.session_incarnation_id,
|
|
cancellation_pending: true,
|
|
};
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
|
|
expect(result.current.cancellationPending).toBe(true);
|
|
});
|
|
|
|
it("sets Auto-run and refetches authoritative queue status", async () => {
|
|
queueApiMock.setQueueAutoRun.mockResolvedValue({
|
|
session_id: SESSION_ID,
|
|
auto_run: false,
|
|
dispatched: false,
|
|
});
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
const setAutoRun = (
|
|
result.current as typeof result.current & {
|
|
setAutoRun?: (enabled: boolean) => Promise<void>;
|
|
}
|
|
).setAutoRun;
|
|
|
|
expect(setAutoRun).toBeTypeOf("function");
|
|
await act(async () => {
|
|
await setAutoRun!(false);
|
|
});
|
|
|
|
expect(queueApiMock.setQueueAutoRun).toHaveBeenCalledWith(IDENTITY, false);
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
});
|
|
it("sets Auto-merge for the immutable session and refetches policy", async () => {
|
|
queueApiMock.getQueueStatus.mockResolvedValue({
|
|
...IDENTITY,
|
|
entries: [],
|
|
count: 0,
|
|
max: 10,
|
|
...IDENTITY,
|
|
merge_enabled: true,
|
|
auto_run: true,
|
|
auto_merge_available: true,
|
|
auto_merge_enabled: true,
|
|
auto_merge_source: "global",
|
|
auto_merge_revision: 4,
|
|
});
|
|
queueApiMock.setQueueAutoMerge.mockResolvedValue({
|
|
...IDENTITY,
|
|
auto_merge_enabled: false,
|
|
auto_merge_source: "session",
|
|
auto_merge_revision: 1,
|
|
});
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(mockState.setQueueEntries).toHaveBeenCalled());
|
|
expect(mockState.setQueueEntries).toHaveBeenLastCalledWith(
|
|
SESSION_ID,
|
|
[],
|
|
expect.objectContaining({
|
|
autoMergeAvailable: true,
|
|
autoMergeEnabled: true,
|
|
autoMergeSource: "global",
|
|
autoMergeRevision: 4,
|
|
}),
|
|
{ establishStatusEpoch: true },
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.setAutoMerge(false);
|
|
});
|
|
|
|
expect(queueApiMock.setQueueAutoMerge).toHaveBeenCalledWith(IDENTITY, false);
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenLastCalledWith(IDENTITY);
|
|
});
|
|
});
|
|
|
|
describe("useQueue mergeEntry", () => {
|
|
it("merges an entry and refetches the queue", async () => {
|
|
queueApiMock.mergeQueuedEntry.mockResolvedValue({ entry_id: "q-1" });
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
|
|
await act(async () => {
|
|
await result.current.mergeEntry("q-2");
|
|
});
|
|
|
|
expect(queueApiMock.mergeQueuedEntry).toHaveBeenCalledWith({
|
|
session_id: SESSION_ID,
|
|
task_id: TASK_ID,
|
|
session_incarnation_id: IDENTITY.session_incarnation_id,
|
|
entry_id: "q-2",
|
|
user_id: undefined,
|
|
});
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
});
|
|
|
|
it("forwards an explicit user_id on merge", async () => {
|
|
queueApiMock.mergeQueuedEntry.mockResolvedValue({ entry_id: "q-1" });
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
|
|
await act(async () => {
|
|
await result.current.mergeEntry("q-2", "alice");
|
|
});
|
|
|
|
expect(queueApiMock.mergeQueuedEntry).toHaveBeenCalledWith({
|
|
session_id: SESSION_ID,
|
|
task_id: TASK_ID,
|
|
session_incarnation_id: IDENTITY.session_incarnation_id,
|
|
entry_id: "q-2",
|
|
user_id: "alice",
|
|
});
|
|
});
|
|
|
|
it("refetches the queue when the merge target was already drained", async () => {
|
|
queueApiMock.mergeQueuedEntry.mockRejectedValue(new queueApiMock.QueueEntryNotFoundError());
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
|
|
await act(async () => {
|
|
await expect(result.current.mergeEntry("q-2")).rejects.toThrow(
|
|
queueApiMock.QueueEntryNotFoundError,
|
|
);
|
|
});
|
|
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
});
|
|
});
|
|
|
|
describe("useQueue clearAll", () => {
|
|
beforeEach(() => {
|
|
queueApiMock.clearQueue.mockReset();
|
|
queueApiMock.clearQueue.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it("refetches authoritative status after a successful clear", async () => {
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
|
|
await act(async () => {
|
|
await result.current.clearAll();
|
|
});
|
|
|
|
expect(queueApiMock.clearQueue).toHaveBeenCalledWith(IDENTITY);
|
|
expect(mockState.setQueueEntries.mock.calls).toContainEqual([
|
|
SESSION_ID,
|
|
[],
|
|
{
|
|
count: 0,
|
|
max: 0,
|
|
mergeEnabled: true,
|
|
autoRun: true,
|
|
taskId: TASK_ID,
|
|
sessionIncarnationId: IDENTITY.session_incarnation_id,
|
|
},
|
|
]);
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
});
|
|
|
|
it("refetches authoritative status and rethrows when clear fails", async () => {
|
|
queueApiMock.clearQueue.mockRejectedValueOnce(new Error("clear failed"));
|
|
const authoritative = entry({ id: "still-queued" });
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
queueApiMock.getQueueStatus.mockResolvedValueOnce({
|
|
...IDENTITY,
|
|
entries: [authoritative],
|
|
count: 1,
|
|
max: 10,
|
|
});
|
|
|
|
await act(async () => {
|
|
await expect(result.current.clearAll()).rejects.toThrow("clear failed");
|
|
});
|
|
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
expect(mockState.setQueueEntries).toHaveBeenCalledWith(
|
|
SESSION_ID,
|
|
[authoritative],
|
|
{
|
|
count: 1,
|
|
max: 10,
|
|
mergeEnabled: true,
|
|
autoRun: true,
|
|
taskId: TASK_ID,
|
|
sessionIncarnationId: IDENTITY.session_incarnation_id,
|
|
},
|
|
{ establishStatusEpoch: true },
|
|
);
|
|
});
|
|
|
|
it("discards an in-flight refetch that resolves after the clear", async () => {
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
|
|
// A refetch starts before the clear and resolves afterwards with the
|
|
// pre-clear entries.
|
|
let resolveStale: (status: { entries: QueuedMessage[]; count: number; max: number }) => void;
|
|
queueApiMock.getQueueStatus.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveStale = resolve;
|
|
}),
|
|
);
|
|
let staleRefetch: Promise<void>;
|
|
act(() => {
|
|
staleRefetch = result.current.refetch();
|
|
});
|
|
expect(mockState.setQueueEntries).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
await result.current.clearAll();
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveStale!({ entries: [entry({ id: "pre-clear" })], count: 1, max: 10 });
|
|
await staleRefetch!;
|
|
});
|
|
|
|
// The stale pre-clear snapshot must never be applied; the empty snapshot
|
|
// from clearAll stays the last one written.
|
|
expect(mockState.setQueueEntries).not.toHaveBeenCalledWith(
|
|
SESSION_ID,
|
|
[entry({ id: "pre-clear" })],
|
|
{ count: 1, max: 10, mergeEnabled: true, autoRun: true },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("useQueue clearAll optimistic state", () => {
|
|
beforeEach(() => {
|
|
queueApiMock.clearQueue.mockReset();
|
|
queueApiMock.clearQueue.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it("clears the visible queue before a slow clear request resolves", async () => {
|
|
const pendingClear = Promise.withResolvers<void>();
|
|
queueApiMock.clearQueue.mockReturnValueOnce(pendingClear.promise);
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
mockState.setQueueEntries.mockClear();
|
|
|
|
let clearPromise!: Promise<void>;
|
|
act(() => {
|
|
clearPromise = result.current.clearAll();
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(mockState.setQueueEntries).toHaveBeenCalledWith(SESSION_ID, [], {
|
|
count: 0,
|
|
max: 0,
|
|
mergeEnabled: true,
|
|
autoRun: true,
|
|
taskId: TASK_ID,
|
|
sessionIncarnationId: IDENTITY.session_incarnation_id,
|
|
}),
|
|
);
|
|
expect(queueApiMock.clearQueue).toHaveBeenCalledWith(IDENTITY);
|
|
|
|
await act(async () => {
|
|
pendingClear.resolve();
|
|
await clearPromise;
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("useQueue removeEntry", () => {
|
|
beforeEach(() => {
|
|
queueApiMock.removeQueuedEntry.mockReset();
|
|
});
|
|
|
|
it("optimistically removes then refetches authoritative status after success", async () => {
|
|
queueApiMock.removeQueuedEntry.mockResolvedValueOnce({ entry_id: "q-1" });
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
|
|
await act(async () => {
|
|
await result.current.removeEntry("q-1");
|
|
});
|
|
|
|
expect(mockState.removeQueueEntry).toHaveBeenCalledWith(SESSION_ID, "q-1");
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
});
|
|
|
|
it("refetches after a drain race without surfacing a benign error", async () => {
|
|
queueApiMock.removeQueuedEntry.mockRejectedValueOnce(
|
|
new queueApiMock.QueueEntryNotFoundError(),
|
|
);
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
|
|
await act(async () => {
|
|
await expect(result.current.removeEntry("q-1")).resolves.toBeUndefined();
|
|
});
|
|
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
});
|
|
|
|
it("refetches and rethrows a failed removal", async () => {
|
|
queueApiMock.removeQueuedEntry.mockRejectedValueOnce(new Error("remove failed"));
|
|
const authoritative = entry({ id: "q-1" });
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
mockState.setQueueEntries.mockClear();
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
queueApiMock.getQueueStatus.mockResolvedValueOnce({
|
|
...IDENTITY,
|
|
entries: [authoritative],
|
|
count: 1,
|
|
max: 10,
|
|
});
|
|
|
|
await act(async () => {
|
|
await expect(result.current.removeEntry("q-1")).rejects.toThrow("remove failed");
|
|
});
|
|
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
expect(mockState.setQueueEntries).toHaveBeenCalledWith(
|
|
SESSION_ID,
|
|
[authoritative],
|
|
{
|
|
count: 1,
|
|
max: 10,
|
|
mergeEnabled: true,
|
|
autoRun: true,
|
|
taskId: TASK_ID,
|
|
sessionIncarnationId: IDENTITY.session_incarnation_id,
|
|
},
|
|
{ establishStatusEpoch: true },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("useQueue reorderEntries", () => {
|
|
beforeEach(() => {
|
|
resetMockState();
|
|
setDocumentVisibility("visible");
|
|
queueApiMock.reorderQueuedEntries.mockReset();
|
|
queueApiMock.getQueueStatus.mockResolvedValue({ entries: [], count: 0, max: 10 });
|
|
});
|
|
|
|
it("optimistically reorders the store and refetches the authoritative queue", async () => {
|
|
const first = entry({ id: "q-1", content: "first" });
|
|
const second = entry({ id: "q-2", content: "second" });
|
|
// The test harness's setQueueEntries is a no-op spy, so seed the store
|
|
// directly: the hook's optimistic reorder reads the current entries.
|
|
mockState.queue.bySessionId[SESSION_ID] = [first, second];
|
|
mockState.queue.metaBySessionId[SESSION_ID] = { count: 2, max: 10 };
|
|
queueApiMock.getQueueStatus.mockResolvedValue({
|
|
entries: [first, second],
|
|
count: 2,
|
|
max: 10,
|
|
});
|
|
queueApiMock.reorderQueuedEntries.mockResolvedValue({
|
|
session_id: SESSION_ID,
|
|
reordered: 2,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
|
|
await act(async () => {
|
|
await result.current.reorderEntries([second.id, first.id]);
|
|
});
|
|
|
|
expect(queueApiMock.reorderQueuedEntries).toHaveBeenCalledWith({
|
|
...IDENTITY,
|
|
session_id: SESSION_ID,
|
|
ordered_ids: [second.id, first.id],
|
|
});
|
|
expect(mockState.setQueueEntries).toHaveBeenCalledWith(SESSION_ID, [second, first], {
|
|
count: 2,
|
|
max: 10,
|
|
mergeEnabled: true,
|
|
autoRun: true,
|
|
});
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
});
|
|
|
|
it("refetches and rethrows when the reorder fails", async () => {
|
|
const first = entry({ id: "q-1", content: "first" });
|
|
const second = entry({ id: "q-2", content: "second" });
|
|
queueApiMock.getQueueStatus.mockResolvedValue({
|
|
entries: [first, second],
|
|
count: 2,
|
|
max: 10,
|
|
});
|
|
queueApiMock.reorderQueuedEntries.mockRejectedValueOnce(new Error("reorder failed"));
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
queueApiMock.getQueueStatus.mockClear();
|
|
queueApiMock.getQueueStatus.mockResolvedValueOnce({
|
|
entries: [first, second],
|
|
count: 2,
|
|
max: 10,
|
|
});
|
|
|
|
await act(async () => {
|
|
await expect(result.current.reorderEntries([second.id, first.id])).rejects.toThrow(
|
|
"reorder failed",
|
|
);
|
|
});
|
|
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
});
|
|
|
|
it("refetches and rethrows a QueueReorderError so the panel can swallow it", async () => {
|
|
const first = entry({ id: "q-1", content: "first" });
|
|
queueApiMock.getQueueStatus.mockResolvedValue({ entries: [first], count: 1, max: 10 });
|
|
queueApiMock.reorderQueuedEntries.mockRejectedValueOnce(new queueApiMock.QueueReorderError());
|
|
|
|
const { result } = renderHook(() => useQueue(SESSION_ID));
|
|
await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
|
|
|
|
await act(async () => {
|
|
await expect(result.current.reorderEntries([first.id])).rejects.toThrow(
|
|
queueApiMock.QueueReorderError,
|
|
);
|
|
});
|
|
|
|
expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(IDENTITY);
|
|
});
|
|
});
|