- Add a shared AiSessionSync store with BroadcastChannel + storage fallback, ownership locks, heartbeats, and stale-tab detection - Merge cross-tab session snapshots into useBackgroundSessions with timestamp guards and rebroadcast SSE updates to peers - Update background session UI and planning/mission/subtask modals to show active-tab lock status and only allow takeover when ownership is stale - Add hook tests covering sync store messaging/fallback behavior and background session cross-tab merge flows
212 lines
6.0 KiB
TypeScript
212 lines
6.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
AiSessionSyncStore,
|
|
__destroyAiSessionSyncStoreForTests,
|
|
__resetAiSessionSyncStoreForTests,
|
|
} from "../useAiSessionSync";
|
|
|
|
class MockBroadcastChannel {
|
|
static channels = new Map<string, Set<MockBroadcastChannel>>();
|
|
|
|
readonly name: string;
|
|
onmessage: ((event: MessageEvent<unknown>) => void) | null = null;
|
|
|
|
constructor(name: string) {
|
|
this.name = name;
|
|
const group = MockBroadcastChannel.channels.get(name) ?? new Set<MockBroadcastChannel>();
|
|
group.add(this);
|
|
MockBroadcastChannel.channels.set(name, group);
|
|
}
|
|
|
|
postMessage(data: unknown): void {
|
|
const group = MockBroadcastChannel.channels.get(this.name);
|
|
if (!group) return;
|
|
|
|
for (const channel of group) {
|
|
if (channel === this) continue;
|
|
channel.onmessage?.({ data } as MessageEvent<unknown>);
|
|
}
|
|
}
|
|
|
|
close(): void {
|
|
const group = MockBroadcastChannel.channels.get(this.name);
|
|
if (!group) return;
|
|
|
|
group.delete(this);
|
|
if (group.size === 0) {
|
|
MockBroadcastChannel.channels.delete(this.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("AiSessionSyncStore", () => {
|
|
const originalBroadcastChannel = globalThis.BroadcastChannel;
|
|
const stores: AiSessionSyncStore[] = [];
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
MockBroadcastChannel.channels.clear();
|
|
__resetAiSessionSyncStoreForTests();
|
|
__destroyAiSessionSyncStoreForTests();
|
|
(globalThis as unknown as { BroadcastChannel: typeof BroadcastChannel }).BroadcastChannel =
|
|
MockBroadcastChannel as unknown as typeof BroadcastChannel;
|
|
});
|
|
|
|
afterEach(() => {
|
|
while (stores.length > 0) {
|
|
stores.pop()?.destroy();
|
|
}
|
|
__resetAiSessionSyncStoreForTests();
|
|
__destroyAiSessionSyncStoreForTests();
|
|
vi.useRealTimers();
|
|
(globalThis as unknown as { BroadcastChannel: typeof BroadcastChannel }).BroadcastChannel =
|
|
originalBroadcastChannel;
|
|
});
|
|
|
|
function createStore(): AiSessionSyncStore {
|
|
const store = new AiSessionSyncStore();
|
|
stores.push(store);
|
|
return store;
|
|
}
|
|
|
|
it("handles session updates/completion and tab ownership messages", () => {
|
|
const storeA = createStore();
|
|
const storeB = createStore();
|
|
|
|
storeA.broadcastUpdate({
|
|
sessionId: "sess-1",
|
|
status: "awaiting_input",
|
|
needsInput: true,
|
|
type: "planning",
|
|
title: "Cross-tab planning",
|
|
projectId: "proj-1",
|
|
timestamp: 10,
|
|
});
|
|
|
|
const syncedUpdate = storeB.getSnapshot().sessions.get("sess-1");
|
|
expect(syncedUpdate?.status).toBe("awaiting_input");
|
|
expect(syncedUpdate?.needsInput).toBe(true);
|
|
|
|
storeA.broadcastLock("sess-1", "tab-a");
|
|
expect(storeB.getSnapshot().activeTabMap.get("sess-1")?.tabId).toBe("tab-a");
|
|
|
|
storeA.broadcastUnlock("sess-1", "tab-a");
|
|
expect(storeB.getSnapshot().activeTabMap.has("sess-1")).toBe(false);
|
|
|
|
storeA.broadcastCompleted({ sessionId: "sess-1", status: "complete", timestamp: 20 });
|
|
|
|
const completed = storeB.getSnapshot().sessions.get("sess-1");
|
|
expect(completed?.status).toBe("complete");
|
|
expect(completed?.needsInput).toBe(false);
|
|
});
|
|
|
|
it("ignores stale updates using timestamp deduplication", () => {
|
|
const storeA = createStore();
|
|
const storeB = createStore();
|
|
|
|
storeA.broadcastUpdate({
|
|
sessionId: "sess-2",
|
|
status: "awaiting_input",
|
|
needsInput: true,
|
|
type: "planning",
|
|
title: "Latest state",
|
|
projectId: "proj-1",
|
|
timestamp: 200,
|
|
});
|
|
|
|
storeA.broadcastUpdate({
|
|
sessionId: "sess-2",
|
|
status: "error",
|
|
needsInput: false,
|
|
type: "planning",
|
|
title: "Stale state",
|
|
projectId: "proj-1",
|
|
timestamp: 100,
|
|
});
|
|
|
|
const state = storeB.getSnapshot().sessions.get("sess-2");
|
|
expect(state?.status).toBe("awaiting_input");
|
|
expect(state?.lastEventTimestamp).toBe(200);
|
|
});
|
|
|
|
it("responds to sync requests with known session state", () => {
|
|
const storeA = createStore();
|
|
const storeB = createStore();
|
|
|
|
storeA.broadcastUpdate({
|
|
sessionId: "sess-3",
|
|
status: "generating",
|
|
needsInput: false,
|
|
type: "mission_interview",
|
|
title: "Mission planning",
|
|
projectId: "proj-1",
|
|
timestamp: 50,
|
|
owningTabId: "tab-source",
|
|
});
|
|
|
|
storeB.requestSync();
|
|
|
|
const synced = storeB.getSnapshot().sessions.get("sess-3");
|
|
expect(synced).toBeDefined();
|
|
expect(synced?.status).toBe("generating");
|
|
expect(synced?.type).toBe("mission_interview");
|
|
});
|
|
|
|
it("falls back to localStorage storage events when BroadcastChannel is unavailable", () => {
|
|
(globalThis as unknown as { BroadcastChannel?: typeof BroadcastChannel }).BroadcastChannel =
|
|
undefined;
|
|
|
|
const storeA = createStore();
|
|
const storeB = createStore();
|
|
|
|
// Local updates still work without BroadcastChannel.
|
|
storeA.broadcastUpdate({
|
|
sessionId: "sess-4",
|
|
status: "generating",
|
|
needsInput: false,
|
|
type: "subtask",
|
|
title: "Fallback session",
|
|
projectId: "proj-1",
|
|
timestamp: 50,
|
|
});
|
|
|
|
const envelope = {
|
|
id: "evt-1",
|
|
message: {
|
|
type: "session:updated",
|
|
sessionId: "sess-4",
|
|
status: "awaiting_input",
|
|
needsInput: true,
|
|
sessionType: "subtask",
|
|
title: "Fallback session",
|
|
projectId: "proj-1",
|
|
timestamp: 75,
|
|
},
|
|
};
|
|
|
|
window.dispatchEvent(
|
|
new StorageEvent("storage", {
|
|
key: "fusion:ai-session-sync",
|
|
newValue: JSON.stringify(envelope),
|
|
}),
|
|
);
|
|
|
|
const state = storeB.getSnapshot().sessions.get("sess-4");
|
|
expect(state?.status).toBe("awaiting_input");
|
|
expect(state?.needsInput).toBe(true);
|
|
});
|
|
|
|
it("broadcasts tab:inactive for owned sessions during page unload cleanup", () => {
|
|
const storeA = createStore();
|
|
const storeB = createStore();
|
|
|
|
storeA.broadcastLock("sess-5", "tab-owner");
|
|
expect(storeB.getSnapshot().activeTabMap.get("sess-5")?.tabId).toBe("tab-owner");
|
|
|
|
window.dispatchEvent(new Event("beforeunload"));
|
|
|
|
expect(storeB.getSnapshot().activeTabMap.has("sess-5")).toBe(false);
|
|
});
|
|
});
|