Migration 28 (add_snapshot_hot_path_indexes) referenced task_comments,
documents, meetings, sprints, agent_messages, activity — all tables
created by ensureOptionalColumns() which runs AFTER runMigrations() on
a fresh DB. Converted from sql-string migration to fn-based so each
index is gated by tableExists(); safe on both fresh and migrated DBs.
Also updated tests/unit/selective-ipc.test.ts exhaustive switch to
include the 7 new DesktopEvent variants added in ef90a58.
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import type {
|
|
DesktopEvent,
|
|
TaskUpdatedEvent,
|
|
RunUpdatedEvent,
|
|
ApprovalUpdatedEvent,
|
|
AgentUpdatedEvent,
|
|
BudgetChangedEvent,
|
|
} from "@shared/types";
|
|
import type { GranularEventEntry } from "@renderer/lib/ipc-batcher";
|
|
|
|
describe("selective IPC event types", () => {
|
|
it("TaskUpdatedEvent discriminated by type field", () => {
|
|
const event: TaskUpdatedEvent = {
|
|
type: "task-updated",
|
|
taskId: "t-1",
|
|
action: "created",
|
|
};
|
|
expect(event.type).toBe("task-updated");
|
|
expect(event.taskId).toBe("t-1");
|
|
expect(event.action).toBe("created");
|
|
});
|
|
|
|
it("RunUpdatedEvent discriminated by type field", () => {
|
|
const event: RunUpdatedEvent = {
|
|
type: "run-updated",
|
|
runId: "r-1",
|
|
action: "completed",
|
|
};
|
|
expect(event.type).toBe("run-updated");
|
|
expect(event.runId).toBe("r-1");
|
|
expect(event.action).toBe("completed");
|
|
});
|
|
|
|
it("ApprovalUpdatedEvent discriminated by type field", () => {
|
|
const event: ApprovalUpdatedEvent = {
|
|
type: "approval-updated",
|
|
approvalId: "a-1",
|
|
action: "decided",
|
|
};
|
|
expect(event.type).toBe("approval-updated");
|
|
expect(event.approvalId).toBe("a-1");
|
|
expect(event.action).toBe("decided");
|
|
});
|
|
|
|
it("AgentUpdatedEvent discriminated by type field", () => {
|
|
const event: AgentUpdatedEvent = {
|
|
type: "agent-updated",
|
|
agentId: "ag-1",
|
|
action: "updated",
|
|
};
|
|
expect(event.type).toBe("agent-updated");
|
|
expect(event.agentId).toBe("ag-1");
|
|
expect(event.action).toBe("updated");
|
|
});
|
|
|
|
it("BudgetChangedEvent discriminated by type field", () => {
|
|
const event: BudgetChangedEvent = {
|
|
type: "budget-changed",
|
|
companyId: "c-1",
|
|
};
|
|
expect(event.type).toBe("budget-changed");
|
|
expect(event.companyId).toBe("c-1");
|
|
});
|
|
|
|
it("all five new event types are assignable to DesktopEvent union", () => {
|
|
const events: DesktopEvent[] = [
|
|
{ type: "task-updated", taskId: "t-1", action: "created" },
|
|
{ type: "run-updated", runId: "r-1", action: "status_changed" },
|
|
{ type: "approval-updated", approvalId: "a-1", action: "created" },
|
|
{ type: "agent-updated", agentId: "ag-1", action: "paused" },
|
|
{ type: "budget-changed", companyId: "c-1" },
|
|
];
|
|
expect(events).toHaveLength(5);
|
|
});
|
|
|
|
it("DesktopEvent union has all 14 members (9 original + 5 new)", () => {
|
|
// This is a compile-time check -- if any type is missing, TypeScript will error
|
|
const exhaustiveCheck = (event: DesktopEvent): string => {
|
|
switch (event.type) {
|
|
case "run-log":
|
|
return "run-log";
|
|
case "domain-changed":
|
|
return "domain-changed";
|
|
case "shortcut":
|
|
return "shortcut";
|
|
case "run-status":
|
|
return "run-status";
|
|
case "heartbeat-status":
|
|
return "heartbeat-status";
|
|
case "agent-message":
|
|
return "agent-message";
|
|
case "automation-fired":
|
|
return "automation-fired";
|
|
case "new-message":
|
|
return "new-message";
|
|
case "notification-click":
|
|
return "notification-click";
|
|
case "task-updated":
|
|
return "task-updated";
|
|
case "run-updated":
|
|
return "run-updated";
|
|
case "approval-updated":
|
|
return "approval-updated";
|
|
case "agent-updated":
|
|
return "agent-updated";
|
|
case "budget-changed":
|
|
return "budget-changed";
|
|
case "thread-wait-state-updated":
|
|
return "thread-wait-state-updated";
|
|
case "recovery-needed":
|
|
return "recovery-needed";
|
|
case "oauth:status":
|
|
return "oauth:status";
|
|
case "oauth:auth-url":
|
|
return "oauth:auth-url";
|
|
case "oauth:progress":
|
|
return "oauth:progress";
|
|
case "oauth:prompt-request":
|
|
return "oauth:prompt-request";
|
|
case "oauth:manual-code-request":
|
|
return "oauth:manual-code-request";
|
|
case "credentials-changed":
|
|
return "credentials-changed";
|
|
case "agent-event":
|
|
return "agent-event";
|
|
default: {
|
|
const _exhaustive: never = event;
|
|
return _exhaustive;
|
|
}
|
|
}
|
|
};
|
|
// Dummy call to avoid unused-var warning
|
|
expect(typeof exhaustiveCheck).toBe("function");
|
|
});
|
|
});
|
|
|
|
// Extracted granular batch handler logic for testability
|
|
// Mirrors the handleGranularBatch function in App.tsx
|
|
async function handleGranularBatch(
|
|
events: GranularEventEntry[],
|
|
deps: {
|
|
companyId: string | null;
|
|
getTask: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: any }>;
|
|
getRun: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: any }>;
|
|
getApproval: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: any }>;
|
|
getAgent: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: any }>;
|
|
patchTask: (task: any) => void;
|
|
removeTask: (id: string) => void;
|
|
patchRun: (run: any) => void;
|
|
removeRun: (id: string) => void;
|
|
patchApproval: (approval: any) => void;
|
|
removeApproval: (id: string) => void;
|
|
patchAgent: (agent: any) => void;
|
|
refreshBadges: () => Promise<void>;
|
|
fullRefresh: () => void;
|
|
},
|
|
) {
|
|
if (!deps.companyId) {
|
|
deps.fullRefresh();
|
|
return;
|
|
}
|
|
|
|
let needsFullRefresh = false;
|
|
|
|
for (const entry of events) {
|
|
if (entry.type === "run-updated" && entry.action === "completed") {
|
|
needsFullRefresh = true;
|
|
break;
|
|
}
|
|
|
|
try {
|
|
switch (entry.type) {
|
|
case "task-updated": {
|
|
if (entry.action === "deleted") {
|
|
deps.removeTask(entry.entityId);
|
|
} else {
|
|
const result = await deps.getTask({ companyId: deps.companyId, id: entry.entityId });
|
|
if (result.ok) {
|
|
deps.patchTask(result.data);
|
|
} else {
|
|
deps.removeTask(entry.entityId);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "run-updated": {
|
|
if (entry.action === "deleted") {
|
|
deps.removeRun(entry.entityId);
|
|
} else {
|
|
const result = await deps.getRun({ companyId: deps.companyId, id: entry.entityId });
|
|
if (result.ok) {
|
|
deps.patchRun(result.data);
|
|
} else {
|
|
deps.removeRun(entry.entityId);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "approval-updated": {
|
|
if (entry.action === "deleted") {
|
|
deps.removeApproval(entry.entityId);
|
|
} else {
|
|
const result = await deps.getApproval({ companyId: deps.companyId, id: entry.entityId });
|
|
if (result.ok) {
|
|
deps.patchApproval(result.data);
|
|
} else {
|
|
deps.removeApproval(entry.entityId);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "agent-updated": {
|
|
const result = await deps.getAgent({ companyId: deps.companyId, id: entry.entityId });
|
|
if (result.ok) {
|
|
deps.patchAgent(result.data);
|
|
} else {
|
|
needsFullRefresh = true;
|
|
}
|
|
break;
|
|
}
|
|
case "budget-changed": {
|
|
needsFullRefresh = true;
|
|
break;
|
|
}
|
|
default: {
|
|
needsFullRefresh = true;
|
|
break;
|
|
}
|
|
}
|
|
} catch {
|
|
needsFullRefresh = true;
|
|
break;
|
|
}
|
|
|
|
if (needsFullRefresh) break;
|
|
}
|
|
|
|
if (needsFullRefresh) {
|
|
deps.fullRefresh();
|
|
} else {
|
|
void deps.refreshBadges();
|
|
}
|
|
}
|
|
|
|
function createMockDeps() {
|
|
return {
|
|
companyId: "company-1" as string | null,
|
|
getTask: vi.fn().mockResolvedValue({ ok: true, data: { id: "t-1", title: "Test task" } }),
|
|
getRun: vi.fn().mockResolvedValue({ ok: true, data: { id: "r-1", status: "running" } }),
|
|
getApproval: vi.fn().mockResolvedValue({ ok: true, data: { id: "a-1", state: "pending" } }),
|
|
getAgent: vi.fn().mockResolvedValue({ ok: true, data: { id: "ag-1", name: "Agent" } }),
|
|
patchTask: vi.fn(),
|
|
removeTask: vi.fn(),
|
|
patchRun: vi.fn(),
|
|
removeRun: vi.fn(),
|
|
patchApproval: vi.fn(),
|
|
removeApproval: vi.fn(),
|
|
patchAgent: vi.fn(),
|
|
refreshBadges: vi.fn().mockResolvedValue(undefined),
|
|
fullRefresh: vi.fn(),
|
|
};
|
|
}
|
|
|
|
describe("handleGranularBatch", () => {
|
|
it("task-updated event calls getTask and patchTask", async () => {
|
|
const deps = createMockDeps();
|
|
await handleGranularBatch(
|
|
[{ type: "task-updated", entityId: "t-1", action: "updated" }],
|
|
deps,
|
|
);
|
|
expect(deps.getTask).toHaveBeenCalledWith({ companyId: "company-1", id: "t-1" });
|
|
expect(deps.patchTask).toHaveBeenCalledWith({ id: "t-1", title: "Test task" });
|
|
expect(deps.fullRefresh).not.toHaveBeenCalled();
|
|
expect(deps.refreshBadges).toHaveBeenCalled();
|
|
});
|
|
|
|
it("deleted task calls removeTask without fetching", async () => {
|
|
const deps = createMockDeps();
|
|
await handleGranularBatch(
|
|
[{ type: "task-updated", entityId: "t-1", action: "deleted" }],
|
|
deps,
|
|
);
|
|
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
|
|
expect(deps.getTask).not.toHaveBeenCalled();
|
|
expect(deps.fullRefresh).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("run-updated:completed triggers full refresh", async () => {
|
|
const deps = createMockDeps();
|
|
await handleGranularBatch(
|
|
[{ type: "run-updated", entityId: "r-1", action: "completed" }],
|
|
deps,
|
|
);
|
|
expect(deps.fullRefresh).toHaveBeenCalled();
|
|
expect(deps.getRun).not.toHaveBeenCalled();
|
|
expect(deps.refreshBadges).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("failed getTask fetch falls back to removeTask", async () => {
|
|
const deps = createMockDeps();
|
|
deps.getTask.mockResolvedValue({ ok: false, error: { code: "NOT_FOUND", message: "nope" } });
|
|
await handleGranularBatch(
|
|
[{ type: "task-updated", entityId: "t-1", action: "updated" }],
|
|
deps,
|
|
);
|
|
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
|
|
expect(deps.patchTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls refreshBadges after successful granular processing", async () => {
|
|
const deps = createMockDeps();
|
|
await handleGranularBatch(
|
|
[{ type: "task-updated", entityId: "t-1", action: "created" }],
|
|
deps,
|
|
);
|
|
expect(deps.refreshBadges).toHaveBeenCalled();
|
|
expect(deps.fullRefresh).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("approval-updated event calls getApproval and patchApproval", async () => {
|
|
const deps = createMockDeps();
|
|
await handleGranularBatch(
|
|
[{ type: "approval-updated", entityId: "a-1", action: "decided" }],
|
|
deps,
|
|
);
|
|
expect(deps.getApproval).toHaveBeenCalledWith({ companyId: "company-1", id: "a-1" });
|
|
expect(deps.patchApproval).toHaveBeenCalledWith({ id: "a-1", state: "pending" });
|
|
});
|
|
|
|
it("agent-updated event calls getAgent and patchAgent", async () => {
|
|
const deps = createMockDeps();
|
|
await handleGranularBatch(
|
|
[{ type: "agent-updated", entityId: "ag-1", action: "paused" }],
|
|
deps,
|
|
);
|
|
expect(deps.getAgent).toHaveBeenCalledWith({ companyId: "company-1", id: "ag-1" });
|
|
expect(deps.patchAgent).toHaveBeenCalledWith({ id: "ag-1", name: "Agent" });
|
|
});
|
|
|
|
it("budget-changed triggers full refresh", async () => {
|
|
const deps = createMockDeps();
|
|
await handleGranularBatch(
|
|
[{ type: "budget-changed", entityId: "c-1", action: "changed" }],
|
|
deps,
|
|
);
|
|
expect(deps.fullRefresh).toHaveBeenCalled();
|
|
expect(deps.refreshBadges).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("no companyId triggers full refresh", async () => {
|
|
const deps = createMockDeps();
|
|
deps.companyId = null;
|
|
await handleGranularBatch(
|
|
[{ type: "task-updated", entityId: "t-1", action: "updated" }],
|
|
deps,
|
|
);
|
|
expect(deps.fullRefresh).toHaveBeenCalled();
|
|
expect(deps.getTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("IPC error falls back to full refresh", async () => {
|
|
const deps = createMockDeps();
|
|
deps.getTask.mockRejectedValue(new Error("IPC failed"));
|
|
await handleGranularBatch(
|
|
[{ type: "task-updated", entityId: "t-1", action: "updated" }],
|
|
deps,
|
|
);
|
|
expect(deps.fullRefresh).toHaveBeenCalled();
|
|
});
|
|
|
|
it("unknown event type triggers full refresh", async () => {
|
|
const deps = createMockDeps();
|
|
await handleGranularBatch(
|
|
[{ type: "some-unknown-event", entityId: "x-1", action: "created" }],
|
|
deps,
|
|
);
|
|
expect(deps.fullRefresh).toHaveBeenCalled();
|
|
});
|
|
|
|
it("run-updated:status_changed fetches and patches run", async () => {
|
|
const deps = createMockDeps();
|
|
await handleGranularBatch(
|
|
[{ type: "run-updated", entityId: "r-1", action: "status_changed" }],
|
|
deps,
|
|
);
|
|
expect(deps.getRun).toHaveBeenCalledWith({ companyId: "company-1", id: "r-1" });
|
|
expect(deps.patchRun).toHaveBeenCalledWith({ id: "r-1", status: "running" });
|
|
expect(deps.fullRefresh).not.toHaveBeenCalled();
|
|
});
|
|
});
|