Right-clicking a card opens a custom context menu (rename, spawn similar, kill). Rename swaps the name for an inline field; Enter commits, Escape and blur cancel. The tree stops rebuilding while the field is open, so a catalog delta cannot replace what is being typed into. Refs #125 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
6.3 KiB
TypeScript
179 lines
6.3 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import { renderSidebar } from "./catalog.js";
|
|
import { closeContextMenu } from "./contextMenu.js";
|
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
|
import type { Session } from "./types.js";
|
|
import { normalizeSessionName } from "./types.js";
|
|
|
|
beforeAll(() => {
|
|
Object.defineProperty(window, "localStorage", {
|
|
value: { getItem: () => null, setItem: () => {} },
|
|
});
|
|
});
|
|
|
|
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
|
|
|
|
afterEach(() => {
|
|
closeContextMenu();
|
|
mounted?.dispose();
|
|
mounted = null;
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function session(overrides: Partial<Session> = {}): Session {
|
|
return {
|
|
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
|
|
cwd: "/home/naps/tea/maestro",
|
|
command: "claude",
|
|
env: {},
|
|
metadata: { name: "amber-falcon" },
|
|
status: "Running",
|
|
created_at: "2026-08-24T10:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function storeWith(sessions: Session[]): CatalogStore {
|
|
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
|
return {
|
|
snapshot: () => state,
|
|
subscribe: (fn: CatalogListener) => {
|
|
fn(state);
|
|
return () => {};
|
|
},
|
|
} as unknown as CatalogStore;
|
|
}
|
|
|
|
/** Mount a sidebar with one session; the handle is disposed after each test. */
|
|
function mount(s: Session): HTMLElement {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(() => Promise.reject(new Error("offline"))),
|
|
);
|
|
const handle = renderSidebar(storeWith([s]));
|
|
mounted = handle;
|
|
return handle.el;
|
|
}
|
|
|
|
function cardIn(el: HTMLElement, s: Session): HTMLElement {
|
|
return el.querySelector<HTMLElement>(`[data-session-id="${s.id}"]`) as HTMLElement;
|
|
}
|
|
|
|
/** Mount a sidebar with one session and right-click its card. */
|
|
function openCardMenu(s: Session): { el: HTMLElement; row: HTMLElement } {
|
|
const el = mount(s);
|
|
const row = cardIn(el, s);
|
|
row.dispatchEvent(new Event("contextmenu", { bubbles: true, cancelable: true }));
|
|
return { el, row };
|
|
}
|
|
|
|
function menuItem(label: string): HTMLButtonElement {
|
|
const found = [...document.querySelectorAll<HTMLButtonElement>(".context-menu-item")].find(
|
|
(b) => b.textContent === label,
|
|
);
|
|
if (!found) throw new Error(`no "${label}" entry in the context menu`);
|
|
return found;
|
|
}
|
|
|
|
describe("session name normalisation (#125)", () => {
|
|
it("trims, and treats a blank name as a clear", () => {
|
|
expect(normalizeSessionName(" amber-falcon ")).toBe("amber-falcon");
|
|
expect(normalizeSessionName(" ")).toBe("");
|
|
});
|
|
|
|
it("caps at the daemon's limit, counting code points", () => {
|
|
expect(normalizeSessionName("x".repeat(80))).toHaveLength(64);
|
|
expect([...normalizeSessionName("é".repeat(80))]).toHaveLength(64);
|
|
});
|
|
});
|
|
|
|
describe("sidebar card context menu (#125)", () => {
|
|
it("suppresses the browser menu and offers rename, spawn similar and kill", () => {
|
|
const s = session();
|
|
const row = cardIn(mount(s), s);
|
|
const ev = new Event("contextmenu", { bubbles: true, cancelable: true });
|
|
row.dispatchEvent(ev);
|
|
expect(ev.defaultPrevented).toBe(true);
|
|
expect([...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent)).toEqual([
|
|
"Rename…",
|
|
"Spawn similar",
|
|
"Kill session",
|
|
]);
|
|
});
|
|
|
|
it("spawn similar dispatches the same event the ⧉ button does", () => {
|
|
const s = session();
|
|
openCardMenu(s);
|
|
const seen = vi.fn();
|
|
document.addEventListener("catalog-spawn-similar", seen);
|
|
menuItem("Spawn similar").click();
|
|
document.removeEventListener("catalog-spawn-similar", seen);
|
|
expect(seen).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("inline rename (#125)", () => {
|
|
it("pre-fills the field with the session's own name", () => {
|
|
const { row } = openCardMenu(session());
|
|
menuItem("Rename…").click();
|
|
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
|
|
expect(input?.value).toBe("amber-falcon");
|
|
expect(row.querySelector("span.leaf-name")).toBeNull();
|
|
});
|
|
|
|
it("starts empty for a session that has no name of its own", () => {
|
|
const { row } = openCardMenu(session({ metadata: {} }));
|
|
menuItem("Rename…").click();
|
|
expect(row.querySelector<HTMLInputElement>("input.leaf-rename")?.value).toBe("");
|
|
});
|
|
|
|
it("holds the tree still while the field is open", () => {
|
|
const s = session();
|
|
const { el, row } = openCardMenu(s);
|
|
menuItem("Rename…").click();
|
|
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
|
|
// A catalog delta mid-edit must not rebuild the row out from under it.
|
|
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
|
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBe(input);
|
|
});
|
|
|
|
it("Enter PATCHes the session with the typed name", () => {
|
|
const s = session();
|
|
const { row } = openCardMenu(s);
|
|
menuItem("Rename…").click();
|
|
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
|
|
input.value = "crimson-otter";
|
|
|
|
const fetchMock = vi.fn(() =>
|
|
Promise.resolve(new Response(JSON.stringify(s), { status: 200 })),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
expect(url).toBe(`/api/sessions/${s.id}`);
|
|
expect(init.method).toBe("PATCH");
|
|
expect(JSON.parse(init.body as string)).toEqual({ name: "crimson-otter" });
|
|
});
|
|
|
|
it("Escape cancels without a request and restores the name", () => {
|
|
const s = session();
|
|
const { el, row } = openCardMenu(s);
|
|
menuItem("Rename…").click();
|
|
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
|
|
input.value = "discarded";
|
|
|
|
const fetchMock = vi.fn(() => Promise.reject(new Error("offline")));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
// The row is rebuilt from the store, so the old name comes back.
|
|
expect(cardIn(el, s).querySelector(".leaf-name")?.textContent).toBe("amber-falcon");
|
|
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBeNull();
|
|
});
|
|
});
|