mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 00:21:12 +00:00
227 lines
7.5 KiB
TypeScript
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");
|
|
});
|
|
});
|