Files
fusion/packages/dashboard/src/__tests__/session-cross-tab.test.ts
gsxdsm 330545d45c test(core,dashboard): expand in-memory SQLite to more store tests
Extends the inMemoryDb opt-in established in 4fc58e0bb to single-instance
TaskStore/AgentStore/RoutineStore/PluginStore tests that were still
opening disk-backed fusion.db files. Cross-instance persistence tests
(open store A, close, open store B on same dir) and migration tests that
seed via a sibling Database instance keep their disk-backed stores —
swapping those would silently drop data between instances.

Sites flipped:
- core: store.test.ts (RunMutationContext, memory-toggle, diagnostics
  blocks), store-sort, settings-export, backup, plugin-loader,
  agent-instructions, agent-instructions-bundle, mission-store (all 10
  triage subtests), mission-planning-context.integration
- dashboard: routes.test.ts (Messaging routes block),
  session-reconnect, session-cross-tab, planning

Sites left disk-backed (cross-instance or sibling-Database dependency):
- run-audit*, task-documents, fts5-guard (sibling Database at same dir)
- mission-integration, mission-factory-parity (taskStore2 reopens)
- agent-store checkout-leasing (TaskStore + AgentStore at same dir)
- routes.test.ts AgentStore seed pattern (route handler opens its own)
- cli/extension.test.ts (makeCtx opens its own TaskStore)

All disk-backed tests continue to write to mkdtemp temp dirs — no live-db
risk introduced. Wall-clock impact: core 149s → 19s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:56:31 -07:00

172 lines
6.1 KiB
TypeScript

/**
* Covers optimistic locking and cross-tab continuity primitives:
* lock conflicts, beacon release, stale lock expiry, SSE summaries, and stale cleanup.
*/
// @vitest-environment node
import express from "express";
import { beforeEach, afterEach, describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore } from "@fusion/core";
import { AiSessionStore, type AiSessionRow } from "../ai-session-store.js";
import { createApiRoutes } from "../routes.js";
import { request } from "../test-request.js";
function makeRow(id: string, overrides: Partial<AiSessionRow> = {}): AiSessionRow {
const now = new Date().toISOString();
return {
id,
type: "planning",
status: "awaiting_input",
title: `Session ${id}`,
inputPayload: JSON.stringify({ initialPlan: "Cross-tab lock test" }),
conversationHistory: "[]",
currentQuestion: JSON.stringify({ id: "q-1", type: "text", question: "Q" }),
result: null,
thinkingOutput: "",
error: null,
projectId: "proj-locks",
createdAt: now,
updatedAt: now,
lockedByTab: null,
lockedAt: null,
...overrides,
};
}
describe("cross-tab session locking", () => {
let tmpRoot: string;
let taskStore: TaskStore;
let aiSessionStore: AiSessionStore;
let app: express.Express;
beforeEach(async () => {
tmpRoot = mkdtempSync(join(tmpdir(), "kb-session-cross-tab-"));
taskStore = new TaskStore(tmpRoot, join(tmpRoot, ".fusion-global-settings"), { inMemoryDb: true });
await taskStore.init();
aiSessionStore = new AiSessionStore(taskStore.getDatabase());
app = express();
app.use(express.json());
app.use("/api", createApiRoutes(taskStore, { aiSessionStore }));
});
afterEach(async () => {
try {
taskStore.close();
} catch {
// no-op
}
await rm(tmpRoot, { recursive: true, force: true });
});
it("enforces optimistic lock conflicts and reports current holder", () => {
aiSessionStore.upsert(makeRow("lock-conflict"));
const first = aiSessionStore.acquireLock("lock-conflict", "tab-a");
const second = aiSessionStore.acquireLock("lock-conflict", "tab-b");
expect(first).toEqual({ acquired: true, currentHolder: null });
expect(second).toEqual({ acquired: false, currentHolder: "tab-a" });
expect(aiSessionStore.getLockHolder("lock-conflict").tabId).toBe("tab-a");
});
it("supports concurrent lock attempts where only one tab acquires", async () => {
aiSessionStore.upsert(makeRow("lock-race"));
const [resultA, resultB] = await Promise.all([
Promise.resolve(aiSessionStore.acquireLock("lock-race", "tab-a")),
Promise.resolve(aiSessionStore.acquireLock("lock-race", "tab-b")),
]);
const acquiredCount = [resultA, resultB].filter((result) => result.acquired).length;
const denied = [resultA, resultB].find((result) => !result.acquired);
expect(acquiredCount).toBe(1);
expect(denied?.currentHolder).toMatch(/^tab-[ab]$/);
});
it("releases lock on tab close beacon endpoint", async () => {
aiSessionStore.upsert(makeRow("lock-beacon"));
aiSessionStore.acquireLock("lock-beacon", "tab-a");
const response = await request(
app,
"DELETE",
"/api/ai-sessions/lock-beacon/lock/beacon?tabId=tab-a",
);
expect(response.status).toBe(200);
expect(aiSessionStore.getLockHolder("lock-beacon")).toEqual({ tabId: null, lockedAt: null });
});
it("expires stale locks and clears ownership", () => {
aiSessionStore.upsert(makeRow("lock-expiry"));
aiSessionStore.acquireLock("lock-expiry", "tab-expired");
const staleTimestamp = new Date(Date.now() - 31 * 60 * 1000).toISOString();
taskStore
.getDatabase()
.prepare("UPDATE ai_sessions SET lockedAt = ? WHERE id = ?")
.run(staleTimestamp, "lock-expiry");
const released = aiSessionStore.releaseStaleLocks(30 * 60 * 1000);
expect(released).toBe(1);
expect(aiSessionStore.getLockHolder("lock-expiry")).toEqual({ tabId: null, lockedAt: null });
});
it("emits ai_session:updated summaries on lock acquisition/release transitions", () => {
aiSessionStore.upsert(makeRow("lock-sse"));
const summaries: Array<{ id: string; lockedByTab: string | null }> = [];
aiSessionStore.on("ai_session:updated", (summary) => {
summaries.push({ id: summary.id, lockedByTab: summary.lockedByTab });
});
aiSessionStore.acquireLock("lock-sse", "tab-a");
aiSessionStore.releaseLock("lock-sse", "tab-a");
aiSessionStore.forceAcquireLock("lock-sse", "tab-b");
expect(summaries).toEqual(
expect.arrayContaining([
{ id: "lock-sse", lockedByTab: "tab-a" },
{ id: "lock-sse", lockedByTab: null },
{ id: "lock-sse", lockedByTab: "tab-b" },
]),
);
const listActive = aiSessionStore.listActive("proj-locks");
const latest = listActive.find((session) => session.id === "lock-sse");
expect(latest?.lockedByTab).toBe("tab-b");
});
it("cleans stale active sessions and leaves fresh sessions intact", () => {
aiSessionStore.upsert(makeRow("stale-generating", { status: "generating" }));
aiSessionStore.upsert(makeRow("stale-awaiting", { status: "awaiting_input" }));
aiSessionStore.upsert(makeRow("fresh-generating", { status: "generating" }));
const stale = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
const fresh = new Date(Date.now() - 60 * 1000).toISOString();
taskStore
.getDatabase()
.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id IN (?, ?)")
.run(stale, "stale-generating", "stale-awaiting");
taskStore
.getDatabase()
.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?")
.run(fresh, "fresh-generating");
const summary = aiSessionStore.cleanupStaleSessions(7 * 24 * 60 * 60 * 1000);
expect(summary.orphanedDeleted).toBe(2);
expect(aiSessionStore.get("stale-generating")).toBeNull();
expect(aiSessionStore.get("stale-awaiting")).toBeNull();
expect(aiSessionStore.get("fresh-generating")).not.toBeNull();
});
});