Files
fathah--hermes-desktop/tests/session-cache-sync.test.ts
2026-07-13 13:36:53 +08:00

631 lines
18 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { join } from "path";
import { mkdirSync, rmSync, existsSync, writeFileSync } from "fs";
// vi.hoisted runs before module imports, so we can't reference imported
// helpers here — use the bare Node modules via require.
const { TEST_HOME } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require("path");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require("os");
return {
TEST_HOME: path.join(
os.tmpdir(),
`hermes-session-cache-test-${Date.now()}`,
),
};
});
vi.mock("../src/main/installer", () => ({
HERMES_HOME: TEST_HOME,
HERMES_PYTHON: "/usr/bin/python3",
HERMES_SCRIPT: "/dev/null",
hermesCliArgs: (args: string[] = []) => ["/dev/null", ...args],
getEnhancedPath: () => process.env.PATH || "",
}));
vi.mock("../src/main/utils", () => ({
activeStateDbPath: () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require("path");
return path.join(TEST_HOME, "state.db");
},
profileHome: () => TEST_HOME,
getActiveProfileNameSync: () => "default",
safeWriteFile: (path: string, data: string) => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require("fs");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nodePath = require("path");
fs.mkdirSync(nodePath.dirname(path), { recursive: true });
fs.writeFileSync(path, data, "utf-8");
},
}));
// Stub the i18n + locale modules so the cache code doesn't need the
// renderer-side translation files at test time.
vi.mock("../src/shared/i18n", () => ({
t: (key: string) => key,
}));
vi.mock("../src/main/locale", () => ({
getAppLocale: () => "en",
}));
vi.mock("better-sqlite3", () => {
// The app rebuilds better-sqlite3 for Electron during postinstall, while
// Vitest runs under Node. Mock the tiny DB surface this unit test needs so
// npm ci && npm test does not depend on native module ABI state.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require("fs");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require("path");
interface SessionRow {
id: string;
source: string;
started_at: number;
ended_at: number | null;
message_count: number;
model: string;
title: string | null;
}
interface MessageRow {
id: number;
session_id: string;
role: string;
content: string;
timestamp: number;
}
interface Store {
sessions: Map<string, SessionRow>;
messages: MessageRow[];
nextMessageId: number;
}
const stores = new Map<string, Store>();
function getStore(dbPath: string): Store {
if (!fs.existsSync(dbPath)) {
stores.set(dbPath, {
sessions: new Map<string, SessionRow>(),
messages: [],
nextMessageId: 1,
});
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.writeFileSync(dbPath, "");
}
let store = stores.get(dbPath);
if (!store) {
store = {
sessions: new Map<string, SessionRow>(),
messages: [],
nextMessageId: 1,
};
stores.set(dbPath, store);
}
return store;
}
class FakeStatement {
constructor(
private readonly sql: string,
private readonly store: Store,
) {}
run(...args: unknown[]): { changes: number } {
if (this.sql.includes("INSERT OR REPLACE INTO sessions")) {
const [id, source, startedAt, messageCount, model, title] = args;
this.store.sessions.set(String(id), {
id: String(id),
source: String(source),
started_at: Number(startedAt),
ended_at: null,
message_count: Number(messageCount),
model: String(model),
title: title === null || title === undefined ? null : String(title),
});
return { changes: 1 };
}
if (this.sql.includes("INSERT INTO messages")) {
const [sessionId, role, content, timestamp] = args;
this.store.messages.push({
id: this.store.nextMessageId++,
session_id: String(sessionId),
role: String(role),
content: String(content),
timestamp: Number(timestamp),
});
return { changes: 1 };
}
throw new Error(`Unhandled fake run SQL: ${this.sql}`);
}
all(
...args: unknown[]
): SessionRow[] | Array<{ id: string; message_count: number }> {
if (this.sql.includes("FROM sessions s")) {
const threshold = Number(args[0] ?? 0);
return Array.from(this.store.sessions.values())
.filter((session) => session.started_at > threshold)
.sort((a, b) => b.started_at - a.started_at);
}
// Phase-2 refresh query introduced for issue #226:
// SELECT id, message_count FROM sessions WHERE id IN (?, ?, …)
if (
this.sql.includes("SELECT id, message_count FROM sessions") &&
this.sql.includes("WHERE id IN")
) {
const ids = args.map(String);
return ids
.map((id) => this.store.sessions.get(id))
.filter((s): s is SessionRow => !!s)
.map((s) => ({ id: s.id, message_count: s.message_count }));
}
// Context-folder batch read (issue #27). These tests never seed linked
// folders, so report none — `tableExists` returns false above, so this
// is only a defensive fallback if the lookup path ever changes.
if (this.sql.includes("desktop_session_context_folders")) {
return [];
}
throw new Error(`Unhandled fake all SQL: ${this.sql}`);
}
get(...args: unknown[]): { content: string } | undefined {
// `tableExists` probes sqlite_master before reading context folders
// (issue #27). The desktop context-folder table is never created in
// these tests, so report it absent — sessions then resolve to a null
// contextFolder instead of throwing on an unhandled query.
if (this.sql.includes("sqlite_master")) {
return undefined;
}
if (this.sql.includes("SELECT content FROM messages")) {
const sessionId = String(args[0]);
const match = this.store.messages
.filter(
(message) =>
message.session_id === sessionId &&
message.role === "user" &&
message.content !== null,
)
.sort((a, b) => a.timestamp - b.timestamp || a.id - b.id)[0];
return match ? { content: match.content } : undefined;
}
throw new Error(`Unhandled fake get SQL: ${this.sql}`);
}
}
class FakeDatabase {
private readonly store: Store;
constructor(dbPath: string) {
this.store = getStore(dbPath);
}
exec(): void {
/* Schema creation is a no-op for the in-memory fake. */
}
prepare(sql: string): FakeStatement {
return new FakeStatement(sql, this.store);
}
close(): void {
/* no-op */
}
}
return { default: FakeDatabase };
});
import Database from "better-sqlite3";
import { syncSessionCache } from "../src/main/session-cache";
import { closeDbConnection } from "../src/main/db";
const CACHE_FILE = join(TEST_HOME, "desktop", "sessions.json");
const DB_PATH = join(TEST_HOME, "state.db");
function seedDb(
sessions: Array<{
id: string;
started_at: number;
source?: string;
message_count?: number;
model?: string;
title?: string | null;
firstUserMessage?: string;
}>,
): void {
const db = new Database(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
source TEXT,
started_at INTEGER,
ended_at INTEGER,
message_count INTEGER,
model TEXT,
title TEXT
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
role TEXT,
content TEXT,
timestamp INTEGER
);
`);
const insSession = db.prepare(
`INSERT OR REPLACE INTO sessions (id, source, started_at, ended_at, message_count, model, title)
VALUES (?, ?, ?, NULL, ?, ?, ?)`,
);
const insMessage = db.prepare(
`INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)`,
);
for (const s of sessions) {
insSession.run(
s.id,
s.source ?? "cli",
s.started_at,
s.message_count ?? 0,
s.model ?? "gpt-4o",
s.title ?? null,
);
if (s.firstUserMessage) {
insMessage.run(s.id, "user", s.firstUserMessage, s.started_at);
}
}
db.close();
}
beforeEach(() => {
mkdirSync(TEST_HOME, { recursive: true });
});
afterEach(() => {
closeDbConnection();
if (existsSync(TEST_HOME)) {
rmSync(TEST_HOME, { recursive: true, force: true });
}
});
describe("syncSessionCache", () => {
it("returns an empty list when no DB exists yet", () => {
expect(syncSessionCache()).toEqual([]);
});
it("on first sync, ingests all sessions and generates titles", () => {
const now = Math.floor(Date.now() / 1000);
seedDb([
{
id: "s1",
started_at: now,
message_count: 2,
firstUserMessage: "How do I write a Python decorator?",
},
{
id: "s2",
started_at: now + 100,
message_count: 4,
firstUserMessage: "Explain RAII in Rust",
},
]);
const result = syncSessionCache();
expect(result).toHaveLength(2);
// Sorted by startedAt DESC
expect(result[0].id).toBe("s2");
expect(result[1].id).toBe("s1");
expect(result[0].title).toContain("RAII");
expect(result[1].title).toContain("Python decorator");
expect(existsSync(CACHE_FILE)).toBe(true);
});
it("treats an empty cache with a stale lastSync as a cold cache", () => {
const oldStart = Math.floor(Date.now() / 1000) - 86400 * 14;
seedDb([
{
id: "old-hidden-session",
started_at: oldStart,
message_count: 3,
firstUserMessage: "Recover this older session",
},
]);
mkdirSync(join(TEST_HOME, "desktop"), { recursive: true });
writeFileSync(
CACHE_FILE,
JSON.stringify({
sessions: [],
lastSync: Math.floor(Date.now() / 1000),
}),
"utf-8",
);
const result = syncSessionCache();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: "old-hidden-session",
messageCount: 3,
});
expect(result[0].title).toContain("Recover this older session");
});
it("updates messageCount on existing sessions without duplicating them (issue #16 regression)", () => {
// Use a future started_at so the 5-minute incremental sync window
// (lastSync - 300) still catches the row on the second sync.
const future = Math.floor(Date.now() / 1000) + 600;
seedDb([
{
id: "s1",
started_at: future,
message_count: 2,
firstUserMessage: "hi",
},
]);
syncSessionCache();
// Bump message_count on the same session.
seedDb([
{
id: "s1",
started_at: future,
message_count: 9,
firstUserMessage: "hi",
},
]);
const result = syncSessionCache();
expect(result).toHaveLength(1);
expect(result[0].id).toBe("s1");
expect(result[0].messageCount).toBe(9);
});
it("appends new sessions on subsequent syncs without losing old ones", () => {
const future = Math.floor(Date.now() / 1000) + 600;
seedDb([
{
id: "s1",
started_at: future,
message_count: 1,
firstUserMessage: "a",
},
]);
syncSessionCache();
seedDb([
{
id: "s1",
started_at: future,
message_count: 1,
firstUserMessage: "a",
},
{
id: "s2",
started_at: future + 200,
message_count: 5,
firstUserMessage: "b",
},
]);
const result = syncSessionCache();
expect(result.map((r) => r.id)).toEqual(["s2", "s1"]);
});
it("refreshes messageCount for old sessions outside the lastSync window (issue #226)", () => {
// Session started well before the 5-minute incremental sync window
// looks (lastSync - 300). Without the Phase 2 refresh, the cache
// pegs messageCount at whatever was first observed — the user
// reports the symptom as "messageCount records only 15 messages
// when there are actually 200+".
const oldStart = Math.floor(Date.now() / 1000) - 86400 * 30; // 30 days ago
seedDb([
{
id: "old-session",
started_at: oldStart,
message_count: 1,
firstUserMessage: "first",
},
]);
// First sync acquires the session at messageCount: 1.
const first = syncSessionCache();
expect(first).toHaveLength(1);
expect(first[0].messageCount).toBe(1);
// Simulate the user accumulating many messages over time — the
// session's started_at stays put (well in the past) but its
// message_count grows.
seedDb([
{
id: "old-session",
started_at: oldStart,
message_count: 200,
firstUserMessage: "first",
},
]);
const second = syncSessionCache();
expect(second).toHaveLength(1);
expect(second[0].id).toBe("old-session");
expect(second[0].messageCount).toBe(200);
// Title and other metadata are preserved (Phase 2 only touches the
// count field — no re-running of title generation).
expect(second[0].title).toContain("first");
});
it("prunes cached sessions that no longer exist in state.db", () => {
const oldStart = Math.floor(Date.now() / 1000) - 86400 * 14;
seedDb([
{
id: "still-present",
started_at: oldStart,
message_count: 3,
firstUserMessage: "keep me",
},
]);
mkdirSync(join(TEST_HOME, "desktop"), { recursive: true });
writeFileSync(
CACHE_FILE,
JSON.stringify({
sessions: [
{
id: "already-deleted",
title: "Already deleted",
startedAt: oldStart,
source: "api_server",
messageCount: 1,
model: "gpt-5.5",
},
{
id: "still-present",
title: "Keep me",
startedAt: oldStart,
source: "api_server",
messageCount: 1,
model: "gpt-5.5",
},
],
lastSync: Math.floor(Date.now() / 1000),
}),
"utf-8",
);
const result = syncSessionCache();
expect(result.map((s) => s.id)).toEqual(["still-present"]);
expect(result[0].messageCount).toBe(3);
});
it("refreshes some old, leaves others untouched, all in one sync", () => {
// Mix: one session inside the lastSync window (handled by Phase 1)
// and two outside it (handled by Phase 2). All three counts grow
// between syncs; both phases should keep the cache accurate.
const now = Math.floor(Date.now() / 1000);
const oldA = now - 86400 * 7;
const oldB = now - 86400 * 3;
const future = now + 600;
seedDb([
{
id: "old-a",
started_at: oldA,
message_count: 5,
firstUserMessage: "a",
},
{
id: "old-b",
started_at: oldB,
message_count: 10,
firstUserMessage: "b",
},
{
id: "new-c",
started_at: future,
message_count: 1,
firstUserMessage: "c",
},
]);
syncSessionCache();
seedDb([
{
id: "old-a",
started_at: oldA,
message_count: 50,
firstUserMessage: "a",
},
{
id: "old-b",
started_at: oldB,
message_count: 25,
firstUserMessage: "b",
},
{
id: "new-c",
started_at: future,
message_count: 7,
firstUserMessage: "c",
},
]);
const result = syncSessionCache();
const byId = new Map(result.map((r) => [r.id, r] as const));
expect(byId.get("old-a")?.messageCount).toBe(50);
expect(byId.get("old-b")?.messageCount).toBe(25);
expect(byId.get("new-c")?.messageCount).toBe(7);
});
it("updates title and model on existing sessions when DB values change", () => {
const future = Math.floor(Date.now() / 1000) + 600;
seedDb([
{
id: "s1",
started_at: future,
message_count: 2,
model: "gpt-4o",
title: "Old title",
firstUserMessage: "hi",
},
]);
const first = syncSessionCache();
expect(first[0].title).toBe("Old title");
expect(first[0].model).toBe("gpt-4o");
seedDb([
{
id: "s1",
started_at: future,
message_count: 5,
model: "claude-sonnet-4-20250514",
title: "Updated title",
firstUserMessage: "hi",
},
]);
const second = syncSessionCache();
expect(second).toHaveLength(1);
expect(second[0].title).toBe("Updated title");
expect(second[0].model).toBe("claude-sonnet-4-20250514");
expect(second[0].messageCount).toBe(5);
});
it("handles a large existing cache without quadratic blowup (issue #16)", () => {
// 1500 existing sessions in cache, then sync sees same 1500 but with
// bumped message counts. The pre-fix O(N²) implementation took >2s here
// on commodity hardware; the O(N) implementation should finish in well
// under 500ms.
const N = 1500;
const future = Math.floor(Date.now() / 1000) + 600;
const sessions = Array.from({ length: N }, (_, i) => ({
id: `s${i}`,
started_at: future + i,
message_count: 1,
firstUserMessage: `message ${i}`,
}));
seedDb(sessions);
syncSessionCache(); // first sync — populates cache
// Bump every message_count and re-sync.
seedDb(sessions.map((s) => ({ ...s, message_count: s.message_count + 1 })));
const start = Date.now();
const result = syncSessionCache();
const elapsed = Date.now() - start;
expect(result).toHaveLength(N);
expect(result.every((r) => r.messageCount === 2)).toBe(true);
expect(elapsed).toBeLessThan(500);
}, 30000);
});