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

585 lines
17 KiB
TypeScript

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<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,
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<T extends (...args: never[]) => 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);
});
});