test(ui): cover terminal picker pointer paths

This commit is contained in:
Vincent Koc
2026-07-30 02:46:37 +08:00
parent 925bea1fd0
commit 8d6dae1e81
2 changed files with 113 additions and 1 deletions
@@ -709,6 +709,115 @@ describe("OpenClawTerminalPanel", () => {
expect(panel.renderRoot.querySelector(".tp-session-menu")).toBeNull();
});
it("keeps a trigger pointerdown inside the picker before toggling closed", async () => {
createGhosttyTerminalMock.mockResolvedValue(createTerminalController());
const client: TerminalGatewayClient = {
forceReconnect: () => {},
request: async <T>(method: string) =>
(method === "terminal.open"
? terminalOpenResult("current-1")
: method === "terminal.list"
? { sessions: [] }
: {}) as T,
addEventListener: () => () => {},
};
const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel;
panel.client = client;
panel.available = true;
document.body.append(panel);
panel.toggle();
await waitForFast(() => expect(panel.renderRoot.querySelector(".tp-actions")).not.toBeNull());
const trigger = panel.renderRoot.querySelector<HTMLButtonElement>(
'[aria-label="Terminal sessions"]',
)!;
trigger.click();
await waitForFast(() =>
expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull(),
);
trigger.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true }));
expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull();
trigger.click();
await panel.updateComplete;
expect(panel.renderRoot.querySelector(".tp-session-menu")).toBeNull();
});
it("keeps shadow-DOM pointer actions inside the terminal session picker", async () => {
createGhosttyTerminalMock.mockResolvedValue(createTerminalController());
const requests: Array<{ method: string; params: unknown }> = [];
const client: TerminalGatewayClient = {
forceReconnect: () => {},
request: async <T>(method: string, params?: unknown) => {
requests.push({ method, params });
if (method === "terminal.open") {
return terminalOpenResult("current-1") as T;
}
if (method === "terminal.list") {
return {
sessions: [
{
...terminalOpenResult("detached-1"),
agentId: "detached-agent",
attached: false,
createdAtMs: 1,
},
],
} as T;
}
if (method === "terminal.attach") {
return {
...terminalOpenResult("detached-1"),
agentId: "detached-agent",
buffer: "",
seq: 0,
} as T;
}
return {} as T;
},
addEventListener: () => () => {},
};
const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel;
panel.client = client;
panel.available = true;
document.body.append(panel);
panel.toggle();
await waitForFast(() => expect(panel.renderRoot.querySelector(".tp-actions")).not.toBeNull());
const trigger = panel.renderRoot.querySelector<HTMLButtonElement>(
'[aria-label="Terminal sessions"]',
)!;
trigger.click();
await waitForFast(() =>
expect(panel.renderRoot.querySelector(".tp-session")?.textContent).toContain(
"detached-agent",
),
);
const refresh = panel.renderRoot.querySelector<HTMLButtonElement>(".tp-session-refresh")!;
refresh.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true }));
expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull();
refresh.click();
await waitForFast(() => {
expect(requests.filter(({ method }) => method === "terminal.list")).toHaveLength(2);
});
await waitForFast(() =>
expect(panel.renderRoot.querySelector(".tp-session")?.textContent).toContain(
"detached-agent",
),
);
const attach = panel.renderRoot.querySelector<HTMLButtonElement>(".tp-session")!;
attach.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true }));
expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull();
attach.click();
await waitForFast(() =>
expect(requests).toContainEqual({
method: "terminal.attach",
params: { sessionId: "detached-1" },
}),
);
});
it("shows a picker attach failure after the listed session disappears", async () => {
createGhosttyTerminalMock
.mockResolvedValueOnce(createTerminalController())
+4 -1
View File
@@ -268,7 +268,10 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
return;
}
const picker = this.renderRoot.querySelector(".tp-session-picker");
if (picker && !event.composedPath().includes(picker)) {
// Document capture sees retargeted shadow-DOM events. The composed path
// preserves the picker wrapper so its trigger and actions stay clickable.
const path = event.composedPath();
if (picker && !path.includes(picker)) {
this.closeSessionPicker(false);
}
}