Files
openclaw/ui/src/components/command-palette.test.ts
2026-07-14 08:07:07 -07:00

227 lines
7.5 KiB
TypeScript

/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { SessionsListResult } from "../api/types.ts";
import type { RouteId } from "../app-route-paths.ts";
import type {
ApplicationContext,
ApplicationGateway,
ApplicationGatewaySnapshot,
} from "../app/context.ts";
import { createApplicationContextProvider } from "../test-helpers/application-context.ts";
import { installDialogPolyfill } from "../test-helpers/modal-dialog.ts";
import { CommandPalette } from "./command-palette.ts";
type GatewayHarness = {
gateway: ApplicationGateway;
setConnected: (connected: boolean) => void;
};
function createGateway(connected: boolean): GatewayHarness {
const client = {} as GatewayBrowserClient;
let snapshot: ApplicationGatewaySnapshot = {
client,
connected,
reconnecting: !connected,
hello: null,
assistantAgentId: "main",
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const gateway = {
get snapshot() {
return snapshot;
},
connection: { gatewayUrl: "ws://localhost", token: "", bootstrapToken: "", password: "" },
eventLog: [],
connect: () => undefined,
setSessionKey: () => undefined,
start: () => undefined,
stop: () => undefined,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeEventLog: () => () => undefined,
subscribeEvents: () => () => undefined,
} satisfies ApplicationGateway;
return {
gateway,
setConnected(nextConnected) {
snapshot = {
...snapshot,
connected: nextConnected,
reconnecting: !nextConnected,
};
for (const listener of listeners) {
listener(snapshot);
}
},
};
}
function createContext(
gateway: ApplicationGateway,
list: ApplicationContext<RouteId>["sessions"]["list"],
): ApplicationContext<RouteId> {
return {
gateway,
sessions: {
list,
},
} as unknown as ApplicationContext<RouteId>;
}
function createSessionResult(key: string, displayName: string): SessionsListResult {
return {
ts: 1,
path: "",
count: 1,
defaults: {},
sessions: [{ key, kind: "direct", displayName, updatedAt: 1 }],
} as SessionsListResult;
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
async function mountPalette(context: ApplicationContext<RouteId>) {
const provider = createApplicationContextProvider(context);
const palette = document.createElement("openclaw-command-palette") as CommandPalette;
palette.onNavigate = vi.fn();
palette.onSelectSession = vi.fn();
provider.append(palette);
document.body.append(provider);
await palette.updateComplete;
return { palette, provider };
}
async function enterQuery(palette: CommandPalette, query: string) {
palette.openPalette();
await palette.updateComplete;
const input = palette.querySelector<HTMLInputElement>(".cmd-palette__input");
if (!input) {
throw new Error("Expected command palette input");
}
input.value = query;
input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
await palette.updateComplete;
}
describe("CommandPalette lifecycle", () => {
let restoreDialogPolyfill: () => void;
beforeEach(() => {
vi.useFakeTimers();
restoreDialogPolyfill = installDialogPolyfill();
});
afterEach(() => {
document.body.replaceChildren();
restoreDialogPolyfill();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("closes and clears its query before a retained element reconnects", async () => {
const { gateway } = createGateway(true);
const list = vi.fn(async () => createSessionResult("agent:main:old", "Old chat"));
const { palette, provider } = await mountPalette(createContext(gateway, list));
await enterQuery(palette, "old");
await vi.advanceTimersByTimeAsync(250);
await palette.updateComplete;
expect(palette.textContent).toContain("Old chat");
palette.remove();
provider.append(palette);
const modal = palette.querySelector("openclaw-modal-dialog");
const dialog = modal?.shadowRoot
?.querySelector("wa-dialog")
?.shadowRoot?.querySelector("dialog");
expect(dialog?.open).toBe(false);
await palette.updateComplete;
expect(palette.querySelector("dialog")).toBeNull();
palette.openPalette();
await palette.updateComplete;
expect(palette.querySelector<HTMLInputElement>(".cmd-palette__input")?.value).toBe("");
expect(palette.textContent).not.toContain("Old chat");
});
it("retries the pending query after the gateway reconnects", async () => {
const harness = createGateway(true);
const stale = createDeferred<SessionsListResult | null>();
const list = vi
.fn<ApplicationContext<RouteId>["sessions"]["list"]>()
.mockImplementationOnce(() => stale.promise)
.mockResolvedValueOnce(createSessionResult("agent:main:retry", "Retry chat"));
const { palette } = await mountPalette(createContext(harness.gateway, list));
await enterQuery(palette, "retry");
await vi.advanceTimersByTimeAsync(250);
expect(list).toHaveBeenCalledOnce();
harness.setConnected(false);
stale.resolve(createSessionResult("agent:main:stale", "Stale chat"));
await Promise.resolve();
expect(palette.textContent).not.toContain("Stale chat");
harness.setConnected(true);
await palette.updateComplete;
await vi.advanceTimersByTimeAsync(250);
await palette.updateComplete;
expect(list).toHaveBeenCalledTimes(2);
expect(list).toHaveBeenLastCalledWith(expect.objectContaining({ search: "retry" }));
expect(palette.textContent).toContain("Retry chat");
});
it("drops an old provider response and searches the replacement context", async () => {
const initial = createGateway(true);
const replacement = createGateway(true);
const stale = createDeferred<SessionsListResult | null>();
const initialList = vi.fn(() => stale.promise);
const replacementList = vi.fn(async () =>
createSessionResult("agent:main:fresh", "Fresh chat"),
);
const { palette, provider } = await mountPalette(createContext(initial.gateway, initialList));
await enterQuery(palette, "chat");
await vi.advanceTimersByTimeAsync(250);
expect(initialList).toHaveBeenCalledOnce();
stale.resolve(createSessionResult("agent:main:stale", "Stale chat"));
provider.setContext(createContext(replacement.gateway, replacementList));
await palette.updateComplete;
await vi.advanceTimersByTimeAsync(250);
await palette.updateComplete;
expect(replacementList).toHaveBeenCalledOnce();
expect(palette.textContent).toContain("Fresh chat");
expect(palette.textContent).not.toContain("Stale chat");
});
it("navigates to the plugin manager from search", async () => {
const { gateway } = createGateway(true);
const { palette } = await mountPalette(
createContext(
gateway,
vi.fn(async () => createSessionResult("agent:main:test", "Test")),
),
);
await enterQuery(palette, "plugins");
const item = palette.querySelector<HTMLButtonElement>("#cmd-palette-option-nav-plugins");
expect(item?.textContent).toContain("Plugins");
item?.click();
expect(palette.onNavigate).toHaveBeenCalledWith("plugins");
});
});