Files
oh-my-pi/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts
T
can1357 31ff08ef8c fix(coding-agent/modes): fixed focused left-tap handling for subagent sessions
- Added a focused-agent left-key input listener that consumes double left taps when the input is empty.
- Routed focused session left-tap behavior through #handleFocusedLeftTap so it matches the Esc-style unfocus timing.
- Updated tests/fixtures for the new listener flow and refreshed a hashline block-edit error assertion.
2026-06-12 15:41:50 +02:00

285 lines
8.7 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import { SessionSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/session-selector";
import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { FileSessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage";
type TestContext = InteractiveModeContext & {
editorContainer: {
children: unknown[];
clear: () => void;
addChild: (child: unknown) => void;
};
};
function makeSessionInfo(path: string): SessionInfo {
return {
path,
id: path,
cwd: "/tmp/project",
title: "Active session",
created: new Date("2025-01-01T00:00:00Z"),
modified: new Date("2025-01-01T00:00:00Z"),
messageCount: 1,
size: 0,
firstMessage: "hello",
allMessagesText: "hello",
};
}
function createContext(currentSessionFile: string): {
ctx: TestContext;
calls: string[];
setCurrentSessionFile: (path: string) => void;
showHookConfirm: (title: string, message: string) => Promise<boolean>;
newSession: () => Promise<boolean>;
} {
const calls: string[] = [];
let sessionFile = currentSessionFile;
const editorContainer = {
children: [] as unknown[],
clear() {
this.children = [];
calls.push("editorContainer.clear");
},
addChild(child: unknown) {
this.children.push(child);
calls.push("editorContainer.addChild");
},
};
const showHookConfirm = vi.fn(async () => true);
const newSession = vi.fn(async () => {
calls.push("session.newSession");
sessionFile = "/tmp/project/sessions/detached.jsonl";
return true;
});
const session = {
newSession,
switchSession: vi.fn(async () => true),
};
const ctx = {
editorContainer,
editor: {},
ui: {
setFocus: vi.fn(),
requestRender: vi.fn(() => {
calls.push("ui.requestRender");
}),
terminal: { columns: 120 },
},
session,
get viewSession() {
return session;
},
sessionManager: {
getCwd: () => "/tmp/project",
getSessionDir: () => "/tmp/project/sessions",
getSessionFile: () => sessionFile,
},
statusContainer: {
clear: vi.fn(() => {
calls.push("statusContainer.clear");
}),
},
pendingMessagesContainer: {
clear: vi.fn(() => {
calls.push("pendingMessagesContainer.clear");
}),
},
compactionQueuedMessages: [] as unknown[],
streamingComponent: { active: true },
streamingMessage: { active: true },
pendingTools: {
clear: vi.fn(() => {
calls.push("pendingTools.clear");
}),
},
loadingAnimation: {
stop: vi.fn(() => {
calls.push("loadingAnimation.stop");
}),
},
statusLine: {
invalidate: vi.fn(() => {
calls.push("statusLine.invalidate");
}),
setSessionStartTime: vi.fn(() => {
calls.push("statusLine.setSessionStartTime");
}),
},
updateEditorTopBorder: vi.fn(() => {
calls.push("updateEditorTopBorder");
}),
updateEditorBorderColor: vi.fn(() => {
calls.push("updateEditorBorderColor");
}),
renderInitialMessages: vi.fn(() => {
calls.push("renderInitialMessages");
}),
reloadTodos: vi.fn(async () => {
calls.push("reloadTodos");
}),
showStatus: vi.fn((message: string) => {
calls.push(`showStatus:${message}`);
}),
showError: vi.fn(),
showHookConfirm,
shutdown: vi.fn(async () => undefined),
clearTransientSessionUi() {
ctx.loadingAnimation?.stop();
ctx.statusContainer.clear();
ctx.pendingMessagesContainer.clear();
ctx.pendingTools.clear();
},
} as unknown as TestContext;
return {
ctx,
calls,
setCurrentSessionFile(path: string) {
sessionFile = path;
},
showHookConfirm,
newSession,
};
}
function renderText(selector: SessionSelectorComponent): string {
return selector.render(120).join("\n");
}
beforeAll(() => {
initTheme();
});
describe("SelectorController session deletion", () => {
beforeEach(() => {
vi.spyOn(SessionManager, "list").mockResolvedValue([]);
vi.spyOn(SessionManager, "listAll").mockResolvedValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("detaches the active session before selector deletion removes it", async () => {
const activeSession = makeSessionInfo("/tmp/project/sessions/active.jsonl");
const { ctx, calls } = createContext(activeSession.path);
vi.spyOn(SessionManager, "list").mockResolvedValue([activeSession]);
const deleteSessionWithArtifacts = vi
.spyOn(FileSessionStorage.prototype, "deleteSessionWithArtifacts")
.mockImplementation(async sessionPath => {
calls.push(`delete:${sessionPath}`);
});
const controller = new SelectorController(ctx);
await controller.showSessionSelector();
const selector = ctx.editorContainer.children[0];
if (!(selector instanceof SessionSelectorComponent)) {
throw new Error("Expected session selector component");
}
const sessionList = selector.getSessionList() as unknown as {
onDeleteRequest?: (session: SessionInfo) => void;
};
sessionList.onDeleteRequest?.(activeSession);
selector.handleInput("\n");
await Bun.sleep(0);
expect(deleteSessionWithArtifacts).toHaveBeenCalledWith(activeSession.path);
expect(calls).toEqual([
"editorContainer.clear",
"editorContainer.addChild",
"ui.requestRender",
"session.newSession",
"loadingAnimation.stop",
"statusContainer.clear",
"pendingMessagesContainer.clear",
"pendingTools.clear",
"statusLine.invalidate",
"statusLine.setSessionStartTime",
"updateEditorTopBorder",
"updateEditorBorderColor",
"renderInitialMessages",
"reloadTodos",
"ui.requestRender",
`delete:${activeSession.path}`,
"ui.requestRender",
]);
expect(ctx.sessionManager.getSessionFile()).toBe("/tmp/project/sessions/detached.jsonl");
});
it("shows inline selector errors when session deletion fails after detach", async () => {
const activeSession = makeSessionInfo("/tmp/project/sessions/active.jsonl");
const { ctx, newSession } = createContext(activeSession.path);
vi.spyOn(SessionManager, "list").mockResolvedValue([activeSession]);
const deleteSessionWithArtifacts = vi
.spyOn(FileSessionStorage.prototype, "deleteSessionWithArtifacts")
.mockRejectedValue(new Error("disk failed"));
const controller = new SelectorController(ctx);
await controller.showSessionSelector();
const selector = ctx.editorContainer.children[0];
if (!(selector instanceof SessionSelectorComponent)) {
throw new Error("Expected session selector component");
}
const sessionList = selector.getSessionList() as unknown as {
onDeleteRequest?: (session: SessionInfo) => void;
};
sessionList.onDeleteRequest?.(activeSession);
selector.handleInput("\n");
await Bun.sleep(0);
expect(newSession).toHaveBeenCalledTimes(1);
expect(deleteSessionWithArtifacts).toHaveBeenCalledWith(activeSession.path);
expect(ctx.showError).not.toHaveBeenCalled();
expect(ctx.sessionManager.getSessionFile()).toBe("/tmp/project/sessions/detached.jsonl");
expect(renderText(selector)).toContain("Error: Failed to delete session: disk failed");
});
it("creates a fresh session before deleting via slash command and then shows the selector", async () => {
const activeSessionPath = "/tmp/project/sessions/active.jsonl";
const { ctx, calls, showHookConfirm, newSession } = createContext(activeSessionPath);
const deleteSessionWithArtifacts = vi
.spyOn(FileSessionStorage.prototype, "deleteSessionWithArtifacts")
.mockImplementation(async sessionPath => {
calls.push(`delete:${sessionPath}`);
});
const exists = vi.spyOn(FileSessionStorage.prototype, "exists").mockResolvedValue(true);
const controller = new SelectorController(ctx);
await controller.handleSessionDeleteCommand();
expect(exists).toHaveBeenCalledWith(activeSessionPath);
expect(showHookConfirm).toHaveBeenCalledWith(
"Delete Session",
"This will permanently delete the current session.\nYou will be returned to the session selector.",
);
expect(newSession).toHaveBeenCalledTimes(1);
expect(deleteSessionWithArtifacts).toHaveBeenCalledWith(activeSessionPath);
expect(calls).toEqual([
"session.newSession",
"loadingAnimation.stop",
"statusContainer.clear",
"pendingMessagesContainer.clear",
"pendingTools.clear",
"statusLine.invalidate",
"statusLine.setSessionStartTime",
"updateEditorTopBorder",
"updateEditorBorderColor",
"renderInitialMessages",
"reloadTodos",
"ui.requestRender",
`delete:${activeSessionPath}`,
"showStatus:Session deleted",
"editorContainer.clear",
"editorContainer.addChild",
"ui.requestRender",
]);
});
});