|
| 1 | +/* @vitest-environment jsdom */ |
| 2 | + |
| 3 | +import { html, render } from "lit"; |
| 4 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 5 | +import "./session-menu.ts"; |
| 6 | +import type { SessionMenuAction, SessionMenuData } from "./session-menu.ts"; |
| 7 | + |
| 8 | +type SessionMenuElement = HTMLElement & { updateComplete: Promise<boolean> }; |
| 9 | + |
| 10 | +const containers: HTMLElement[] = []; |
| 11 | + |
| 12 | +afterEach(() => { |
| 13 | + for (const container of containers.splice(0)) { |
| 14 | + container.remove(); |
| 15 | + } |
| 16 | +}); |
| 17 | + |
| 18 | +async function mountMenu( |
| 19 | + options: { |
| 20 | + session?: Partial<SessionMenuData>; |
| 21 | + canOpenChat?: boolean; |
| 22 | + workboard?: { captured: boolean; busy: boolean } | null; |
| 23 | + archiveAllowed?: boolean; |
| 24 | + groups?: readonly string[]; |
| 25 | + trigger?: HTMLElement | null; |
| 26 | + onAction?: (action: SessionMenuAction) => void; |
| 27 | + onClose?: () => void; |
| 28 | + } = {}, |
| 29 | +): Promise<SessionMenuElement> { |
| 30 | + const container = document.createElement("div"); |
| 31 | + containers.push(container); |
| 32 | + document.body.append(container); |
| 33 | + const session: SessionMenuData = { |
| 34 | + key: "agent:main:test", |
| 35 | + label: "Test session", |
| 36 | + pinned: false, |
| 37 | + unread: false, |
| 38 | + archived: false, |
| 39 | + category: null, |
| 40 | + ...options.session, |
| 41 | + }; |
| 42 | + render( |
| 43 | + html`<openclaw-session-menu |
| 44 | + .session=${session} |
| 45 | + .x=${100} |
| 46 | + .y=${100} |
| 47 | + .trigger=${options.trigger ?? null} |
| 48 | + .disabled=${false} |
| 49 | + .forkDisabled=${false} |
| 50 | + .archiveAllowed=${options.archiveAllowed ?? true} |
| 51 | + .groups=${options.groups ?? []} |
| 52 | + .canOpenChat=${options.canOpenChat ?? true} |
| 53 | + .workboard=${options.workboard === undefined |
| 54 | + ? { captured: false, busy: false } |
| 55 | + : options.workboard} |
| 56 | + .onAction=${options.onAction ?? (() => {})} |
| 57 | + .onClose=${options.onClose ?? (() => {})} |
| 58 | + ></openclaw-session-menu>`, |
| 59 | + container, |
| 60 | + ); |
| 61 | + const element = container.querySelector("openclaw-session-menu") as SessionMenuElement | null; |
| 62 | + if (!element) { |
| 63 | + throw new Error("Expected session menu"); |
| 64 | + } |
| 65 | + await element.updateComplete; |
| 66 | + return element; |
| 67 | +} |
| 68 | + |
| 69 | +function menuItemLabels(menu: ParentNode): string[] { |
| 70 | + return Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]')).map( |
| 71 | + (item) => item.textContent?.trim() ?? "", |
| 72 | + ); |
| 73 | +} |
| 74 | + |
| 75 | +function menuItem(menu: ParentNode, label: string): HTMLButtonElement { |
| 76 | + const item = Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')).find( |
| 77 | + (candidate) => candidate.textContent?.trim() === label, |
| 78 | + ); |
| 79 | + if (!item) { |
| 80 | + throw new Error(`Expected menu item: ${label}`); |
| 81 | + } |
| 82 | + return item; |
| 83 | +} |
| 84 | + |
| 85 | +describe("session menu", () => { |
| 86 | + it("renders the full plain-session item set in order", async () => { |
| 87 | + const menu = await mountMenu(); |
| 88 | + |
| 89 | + expect(menuItemLabels(menu)).toEqual([ |
| 90 | + "Open chat", |
| 91 | + "Pin session", |
| 92 | + "Mark as unread", |
| 93 | + "Rename…", |
| 94 | + "Fork", |
| 95 | + "Add to Workboard", |
| 96 | + "Move to group", |
| 97 | + "Archive session", |
| 98 | + "Delete…", |
| 99 | + ]); |
| 100 | + }); |
| 101 | + |
| 102 | + it("omits Open chat and Workboard when unavailable", async () => { |
| 103 | + const menu = await mountMenu({ canOpenChat: false, workboard: null }); |
| 104 | + |
| 105 | + expect(menuItemLabels(menu)).not.toContain("Open chat"); |
| 106 | + expect(menuItemLabels(menu)).not.toContain("Add to Workboard"); |
| 107 | + }); |
| 108 | + |
| 109 | + it("restores archived sessions while keeping delete enabled and pin disabled", async () => { |
| 110 | + const menu = await mountMenu({ |
| 111 | + archiveAllowed: false, |
| 112 | + session: { archived: true }, |
| 113 | + }); |
| 114 | + |
| 115 | + expect(menuItem(menu, "Restore session").disabled).toBe(false); |
| 116 | + expect(menuItem(menu, "Delete…").disabled).toBe(false); |
| 117 | + expect(menuItem(menu, "Pin session").disabled).toBe(true); |
| 118 | + }); |
| 119 | + |
| 120 | + it("disables archive and delete when an active session cannot be archived", async () => { |
| 121 | + const menu = await mountMenu({ archiveAllowed: false }); |
| 122 | + |
| 123 | + expect(menuItem(menu, "Archive session").disabled).toBe(true); |
| 124 | + expect(menuItem(menu, "Delete…").disabled).toBe(true); |
| 125 | + }); |
| 126 | + |
| 127 | + it("closes before dispatching Pin", async () => { |
| 128 | + const calls: string[] = []; |
| 129 | + const menu = await mountMenu({ |
| 130 | + onClose: () => calls.push("close"), |
| 131 | + onAction: (action) => calls.push(action.kind), |
| 132 | + }); |
| 133 | + |
| 134 | + menuItem(menu, "Pin session").click(); |
| 135 | + |
| 136 | + expect(calls).toEqual(["close", "toggle-pin"]); |
| 137 | + }); |
| 138 | + |
| 139 | + it("opens group actions and dispatches group, removal, and creation choices", async () => { |
| 140 | + const onAction = vi.fn<(action: SessionMenuAction) => void>(); |
| 141 | + const menu = await mountMenu({ |
| 142 | + session: { category: "Research" }, |
| 143 | + groups: ["Research", "Projects"], |
| 144 | + onAction, |
| 145 | + }); |
| 146 | + |
| 147 | + menuItem(menu, "Move to group").click(); |
| 148 | + await menu.updateComplete; |
| 149 | + |
| 150 | + expect(menuItemLabels(menu)).toContain("Research"); |
| 151 | + expect(menuItemLabels(menu)).toContain("Projects"); |
| 152 | + expect(menuItem(menu, "Research").querySelector(".session-menu__check svg")).not.toBeNull(); |
| 153 | + |
| 154 | + menuItem(menu, "Projects").click(); |
| 155 | + expect(onAction).toHaveBeenCalledWith({ kind: "move-to-group", category: "Projects" }); |
| 156 | + |
| 157 | + menuItem(menu, "Remove from group").click(); |
| 158 | + expect(onAction).toHaveBeenCalledWith({ kind: "move-to-group", category: null }); |
| 159 | + |
| 160 | + menuItem(menu, "New group…").click(); |
| 161 | + expect(onAction).toHaveBeenCalledWith({ kind: "new-group" }); |
| 162 | + }); |
| 163 | + |
| 164 | + it("omits Remove from group when the session has no category", async () => { |
| 165 | + const menu = await mountMenu({ groups: ["Research"] }); |
| 166 | + |
| 167 | + menuItem(menu, "Move to group").click(); |
| 168 | + await menu.updateComplete; |
| 169 | + |
| 170 | + expect(menuItemLabels(menu)).not.toContain("Remove from group"); |
| 171 | + }); |
| 172 | + |
| 173 | + it("closes on Escape and outside pointerdown but ignores its trigger", async () => { |
| 174 | + const trigger = document.createElement("button"); |
| 175 | + document.body.append(trigger); |
| 176 | + containers.push(trigger); |
| 177 | + const onClose = vi.fn(); |
| 178 | + await mountMenu({ trigger, onClose }); |
| 179 | + |
| 180 | + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); |
| 181 | + expect(onClose).toHaveBeenCalledTimes(1); |
| 182 | + |
| 183 | + document.body.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); |
| 184 | + expect(onClose).toHaveBeenCalledTimes(2); |
| 185 | + |
| 186 | + trigger.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); |
| 187 | + expect(onClose).toHaveBeenCalledTimes(2); |
| 188 | + }); |
| 189 | +}); |
0 commit comments