import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdirSync, rmSync, existsSync, writeFileSync } from "fs"; import { join } from "path"; const { TEST_HOME, DB_PATH } = 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"); const home = path.join( os.tmpdir(), `hermes-delete-session-test-${Date.now()}`, ); return { TEST_HOME: home, DB_PATH: path.join(home, "state.db"), }; }); vi.mock("../src/main/installer", () => ({ HERMES_HOME: TEST_HOME, })); vi.mock("../src/main/utils", () => ({ activeStateDbPath: () => DB_PATH, 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"); }, })); // Simulate better-sqlite3 faithfully: readonly connections reject writes // with SQLITE_READONLY, just like the real native module. vi.mock("better-sqlite3", () => { // 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; messages: MessageRow[]; nextMessageId: number; } const stores = new Map(); function getStore(dbPath: string): Store { if (!fs.existsSync(dbPath)) { stores.set(dbPath, { sessions: new Map(), messages: [], nextMessageId: 1, }); fs.mkdirSync(path.dirname(dbPath), { recursive: true }); fs.writeFileSync(dbPath, ""); } let store = stores.get(dbPath); if (!store) { store = { sessions: new Map(), messages: [], nextMessageId: 1, }; stores.set(dbPath, store); } return store; } class FakeStatement { constructor( private readonly sql: string, private readonly store: Store, private readonly readonlyMode: boolean, ) {} run(...args: unknown[]): { changes: number } { if (this.readonlyMode) { const isWrite = /\b(DELETE|INSERT|UPDATE|REPLACE|DROP|CREATE|ALTER)\b/i.test( this.sql, ); if (isWrite) { const err = new Error( "attempt to write a readonly database", ) as Error & { code: string }; err.code = "SQLITE_READONLY"; throw err; } } // INSERT / REPLACE handlers (used by seedDb and production code) if ( this.sql.includes("INSERT OR REPLACE INTO sessions") || this.sql.includes("INSERT 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 }; } if (this.sql.includes("DELETE FROM messages")) { const sessionId = String(args[0]); const before = this.store.messages.length; this.store.messages = this.store.messages.filter( (m) => m.session_id !== sessionId, ); return { changes: before - this.store.messages.length }; } if (this.sql.includes("DELETE FROM sessions")) { const sessionId = String(args[0]); const existed = this.store.sessions.has(sessionId); this.store.sessions.delete(sessionId); return { changes: existed ? 1 : 0 }; } throw new Error(`Unhandled fake run SQL: ${this.sql}`); } all(...args: unknown[]): unknown[] { if ( this.sql.includes("FROM sessions s") && this.sql.includes("LOWER(COALESCE(s.title") ) { const titlePattern = String(args[0]) .replace(/^%/, "") .replace(/%$/, "") .replace(/\\/g, "") .toLowerCase(); const idPattern = String(args[1]) .replace(/^%/, "") .replace(/%$/, "") .replace(/\\/g, "") .toLowerCase(); const limit = Number(args[2]); return Array.from(this.store.sessions.values()) .filter( (session) => (session.title || "").toLowerCase().includes(titlePattern) || session.id.toLowerCase().includes(idPattern), ) .sort((a, b) => b.started_at - a.started_at) .slice(0, limit) .map((session) => ({ session_id: session.id, title: session.title, started_at: session.started_at, source: session.source, message_count: session.message_count, model: session.model, })); } if (this.sql.includes("FROM sessions s")) { const [limit, offset] = args.map(Number); return Array.from(this.store.sessions.values()) .sort((a, b) => b.started_at - a.started_at) .slice(offset, offset + limit); } if (this.sql.includes("FROM messages")) { const sessionId = String(args[0]); return this.store.messages .filter( (m) => m.session_id === sessionId && ["user", "assistant"].includes(m.role) && m.content !== null, ) .sort((a, b) => a.timestamp - b.timestamp || a.id - b.id); } throw new Error(`Unhandled fake all SQL: ${this.sql}`); } get(): unknown { if (this.sql.includes("FROM sqlite_master")) { return undefined; } throw new Error(`Unhandled fake get SQL: ${this.sql}`); } } class FakeDatabase { private readonly store: Store; private readonly readonlyMode: boolean; constructor(dbPath: string, options?: { readonly?: boolean }) { this.store = getStore(dbPath); this.readonlyMode = options?.readonly === true; } exec(): void { /* no-op */ } prepare(sql: string): FakeStatement { return new FakeStatement(sql, this.store, this.readonlyMode); } // better-sqlite3's `transaction(fn)` returns a callable that runs // `fn` inside a transaction. The fake doesn't need real atomicity — // a synchronous passthrough is enough for deleteSession's two-step // delete. Previously absent, which broke deleteSession's tests. transaction unknown>(fn: T): T { return ((...args: never[]) => fn(...args)) as T; } close(): void { /* no-op */ } } return { default: FakeDatabase }; }); import Database from "better-sqlite3"; import { deleteSession, deleteSessions, listSessions, getSessionMessages, searchSessions, } from "../src/main/sessions"; import { closeDbConnection } from "../src/main/db"; function seedDb( sessions: Array<{ id: string; started_at: number; source?: string; message_count?: number; model?: string; title?: string | null; messages?: Array<{ role: "user" | "assistant"; content: string; timestamp: number; }>; }>, ): void { mkdirSync(TEST_HOME, { recursive: true }); 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, tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, timestamp INTEGER, reasoning TEXT, reasoning_content TEXT, reasoning_details TEXT ); `); 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 ?? s.messages?.length ?? 0, s.model ?? "gpt-4o", s.title ?? null, ); if (s.messages) { for (const msg of s.messages) { insMessage.run(s.id, msg.role, msg.content, msg.timestamp); } } } db.close(); } beforeEach(() => { mkdirSync(TEST_HOME, { recursive: true }); }); afterEach(() => { closeDbConnection(); if (existsSync(TEST_HOME)) { rmSync(TEST_HOME, { recursive: true, force: true }); } }); describe("deleteSession", () => { it("deletes the session and all its messages from the database", () => { const now = Math.floor(Date.now() / 1000); seedDb([ { id: "session-to-delete", started_at: now, message_count: 2, messages: [ { role: "user", content: "hello", timestamp: now }, { role: "assistant", content: "hi there", timestamp: now + 1 }, ], }, { id: "session-to-keep", started_at: now + 10, message_count: 1, messages: [{ role: "user", content: "keep me", timestamp: now + 10 }], }, ]); // Verify preconditions: both sessions exist const beforeSessions = listSessions(); expect(beforeSessions).toHaveLength(2); expect(beforeSessions.map((s) => s.id).sort()).toEqual([ "session-to-delete", "session-to-keep", ]); const beforeMessages = getSessionMessages("session-to-delete"); expect(beforeMessages).toHaveLength(2); // Act: delete the session expect(() => deleteSession("session-to-delete")).not.toThrow(); // Assert: target session gone, other session untouched const afterSessions = listSessions(); expect(afterSessions).toHaveLength(1); expect(afterSessions[0].id).toBe("session-to-keep"); const deletedMessages = getSessionMessages("session-to-delete"); expect(deletedMessages).toHaveLength(0); }); it("clears staged attachment files for the deleted session", () => { const now = Math.floor(Date.now() / 1000); seedDb([ { id: "session-with-staged-files", started_at: now, message_count: 1, messages: [{ role: "user", content: "see attached", timestamp: now }], }, ]); const stagingDir = join( TEST_HOME, "desktop-staging", "session-with-staged-files", ); mkdirSync(stagingDir, { recursive: true }); writeFileSync(join(stagingDir, "pasted.png"), "image bytes"); expect(existsSync(stagingDir)).toBe(true); deleteSession("session-with-staged-files"); expect(existsSync(stagingDir)).toBe(false); expect(getSessionMessages("session-with-staged-files")).toHaveLength(0); }); it("does nothing when deleting a non-existent session", () => { const now = Math.floor(Date.now() / 1000); seedDb([ { id: "real-session", started_at: now, message_count: 1, messages: [{ role: "user", content: "real", timestamp: now }], }, ]); const beforeSessions = listSessions(); expect(beforeSessions).toHaveLength(1); // Deleting a non-existent session should not throw expect(() => deleteSession("nonexistent")).not.toThrow(); // Existing session should still be there const afterSessions = listSessions(); expect(afterSessions).toHaveLength(1); expect(afterSessions[0].id).toBe("real-session"); }); it("returns early when the database file does not exist", () => { // No DB seeded — HERMES_HOME/state.db doesn't exist expect(() => deleteSession("any-session")).not.toThrow(); }); }); describe("searchSessions", () => { it("finds title-only matches when the message body does not contain the query", () => { const now = Math.floor(Date.now() / 1000); seedDb([ { id: "title-only-match", title: "Manual bulk delete search target 1780363992423", started_at: now, message_count: 1, messages: [ { role: "user", content: "temporary manual bulk delete test row", timestamp: now, }, ], }, ]); const results = searchSessions("1780363992423"); expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ sessionId: "title-only-match", title: "Manual bulk delete search target 1780363992423", }); expect(results[0].snippet).toContain("<<1780363992423>>"); }); it("finds sessions by id suffix when the UI displays a fallback title", () => { const now = Math.floor(Date.now() / 1000); seedDb([ { id: "desk-old-session-295d8a", title: null, started_at: now, message_count: 1, messages: [ { role: "user", content: "body does not include the id suffix", timestamp: now, }, ], }, ]); const results = searchSessions("295d"); expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ sessionId: "desk-old-session-295d8a", title: null, }); expect(results[0].snippet).toContain("Sessions <<295d>>8a"); }); }); describe("deleteSessions", () => { it("deletes multiple sessions and all of their messages", () => { const now = Math.floor(Date.now() / 1000); seedDb([ { id: "bulk-delete-a", started_at: now, message_count: 1, messages: [{ role: "user", content: "delete a", timestamp: now }], }, { id: "bulk-delete-b", started_at: now + 1, message_count: 1, messages: [ { role: "assistant", content: "delete b", timestamp: now + 1 }, ], }, { id: "bulk-keep", started_at: now + 2, message_count: 1, messages: [{ role: "user", content: "keep", timestamp: now + 2 }], }, ]); const result = deleteSessions(["bulk-delete-a", "bulk-delete-b"]); expect(result).toEqual({ requested: 2, deleted: 2 }); expect(listSessions().map((s) => s.id)).toEqual(["bulk-keep"]); expect(getSessionMessages("bulk-delete-a")).toHaveLength(0); expect(getSessionMessages("bulk-delete-b")).toHaveLength(0); expect(getSessionMessages("bulk-keep")).toHaveLength(1); }); it("dedupes and ignores blank session ids", () => { const now = Math.floor(Date.now() / 1000); seedDb([ { id: "dedupe-me", started_at: now, message_count: 1, messages: [{ role: "user", content: "delete", timestamp: now }], }, ]); const result = deleteSessions([ "", " ", "dedupe-me", "dedupe-me", " missing ", ]); expect(result).toEqual({ requested: 2, deleted: 1 }); expect(listSessions()).toHaveLength(0); }); it("clears staged attachment files for every deleted id", () => { const now = Math.floor(Date.now() / 1000); seedDb([ { id: "bulk-staged-a", started_at: now }, { id: "bulk-staged-b", started_at: now + 1 }, ]); const stagingA = join(TEST_HOME, "desktop-staging", "bulk-staged-a"); const stagingB = join(TEST_HOME, "desktop-staging", "bulk-staged-b"); mkdirSync(stagingA, { recursive: true }); mkdirSync(stagingB, { recursive: true }); writeFileSync(join(stagingA, "a.txt"), "a"); writeFileSync(join(stagingB, "b.txt"), "b"); deleteSessions(["bulk-staged-a", "bulk-staged-b"]); expect(existsSync(stagingA)).toBe(false); expect(existsSync(stagingB)).toBe(false); }); });