631 lines
18 KiB
TypeScript
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);
|
|
});
|