mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
1065c1d697
* fix(ui): identify source builds in settings footer * fix(release): classify all official Control UI builds
778 lines
25 KiB
TypeScript
778 lines
25 KiB
TypeScript
// @vitest-environment node
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type {
|
|
GatewayBrowserClient,
|
|
GatewayBrowserClientOptions,
|
|
GatewayEventFrame,
|
|
GatewayHelloOk,
|
|
} from "../api/gateway.ts";
|
|
import { createStorageMock } from "../test-helpers/storage.ts";
|
|
import { createApplicationGateway } from "./gateway-store.ts";
|
|
import { loadSettings } from "./settings.ts";
|
|
|
|
vi.mock("../build-info.ts", () => ({
|
|
CONTROL_UI_BUILD_INFO: {
|
|
version: "2026.7.19",
|
|
commit: null,
|
|
commitAt: null,
|
|
builtAt: null,
|
|
branch: null,
|
|
dirty: null,
|
|
release: false,
|
|
buildId: "test",
|
|
},
|
|
}));
|
|
|
|
const HELLO: GatewayHelloOk = {
|
|
type: "hello-ok",
|
|
protocol: 1,
|
|
auth: { role: "operator", scopes: [] },
|
|
};
|
|
|
|
function createGatewayEvent(event = "chat", payload: unknown = {}, seq = 1): GatewayEventFrame {
|
|
return {
|
|
type: "event",
|
|
event,
|
|
payload,
|
|
seq,
|
|
stateVersion: { presence: seq, health: seq },
|
|
};
|
|
}
|
|
|
|
class FakeGatewayClient {
|
|
started = 0;
|
|
stopped = 0;
|
|
readonly instanceId: string;
|
|
|
|
constructor(readonly opts: GatewayBrowserClientOptions) {
|
|
this.instanceId = opts.instanceId ?? "";
|
|
}
|
|
|
|
start() {
|
|
this.started += 1;
|
|
}
|
|
|
|
stop() {
|
|
this.stopped += 1;
|
|
}
|
|
|
|
request = vi.fn(
|
|
(_method: string, _params: unknown): Promise<unknown> =>
|
|
Promise.reject(new Error("unexpected gateway request")),
|
|
);
|
|
|
|
addEventListener() {
|
|
return () => {};
|
|
}
|
|
}
|
|
|
|
function createStore(
|
|
params: {
|
|
settings?: ReturnType<typeof loadSettings>;
|
|
persistDefaultConnectionSettings?: boolean;
|
|
} = {},
|
|
) {
|
|
const clients: FakeGatewayClient[] = [];
|
|
const gateway = createApplicationGateway(
|
|
params.settings ?? loadSettings(),
|
|
"",
|
|
"",
|
|
(opts) => {
|
|
const client = new FakeGatewayClient(opts);
|
|
clients.push(client);
|
|
return client as unknown as GatewayBrowserClient;
|
|
},
|
|
{ persistDefaultConnectionSettings: params.persistDefaultConnectionSettings },
|
|
);
|
|
const current = () => {
|
|
const client = clients.at(-1);
|
|
if (!client) {
|
|
throw new Error("expected a gateway client");
|
|
}
|
|
return client;
|
|
};
|
|
return { gateway, clients, current };
|
|
}
|
|
|
|
describe("createApplicationGateway connection phase", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("localStorage", createStorageMock());
|
|
vi.stubGlobal("sessionStorage", createStorageMock());
|
|
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
|
|
vi.stubGlobal("location", {
|
|
protocol: "http:",
|
|
host: "127.0.0.1:18789",
|
|
hostname: "127.0.0.1",
|
|
pathname: "/",
|
|
} as Location);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("follows stopped -> connecting -> connected -> reconnecting -> offline", () => {
|
|
const { gateway, current } = createStore();
|
|
|
|
expect(gateway.snapshot.phase).toBe("stopped");
|
|
gateway.start();
|
|
|
|
expect(current().started).toBe(1);
|
|
expect(current().opts.clientVersion).toBe("2026.7.19");
|
|
expect(gateway.snapshot.phase).toBe("connecting");
|
|
|
|
current().opts.onHello?.(HELLO);
|
|
expect(gateway.snapshot.phase).toBe("connected");
|
|
|
|
current().opts.onClose?.({ code: 1006, reason: "socket lost", willRetry: true });
|
|
expect(gateway.snapshot.phase).toBe("reconnecting");
|
|
|
|
current().opts.onClose?.({ code: 4008, reason: "connect failed", willRetry: false });
|
|
expect(gateway.snapshot.phase).toBe("offline");
|
|
});
|
|
|
|
it("does not invent an assistant agent id before the gateway advertises one", () => {
|
|
const { gateway, current } = createStore();
|
|
|
|
expect(gateway.snapshot.assistantAgentId).toBeNull();
|
|
gateway.start();
|
|
current().opts.onHello?.(HELLO);
|
|
expect(gateway.snapshot.assistantAgentId).toBeNull();
|
|
|
|
gateway.connect();
|
|
current().opts.onHello?.({
|
|
...HELLO,
|
|
snapshot: { sessionDefaults: { defaultAgentId: "roboclaw" } },
|
|
});
|
|
expect(gateway.snapshot.assistantAgentId).toBe("roboclaw");
|
|
gateway.stop();
|
|
expect(gateway.snapshot.assistantAgentId).toBeNull();
|
|
});
|
|
|
|
it("publishes the hello canvas URL synchronously and clears it on disconnect", () => {
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
current().opts.onHello?.({
|
|
...HELLO,
|
|
pluginSurfaceUrls: {
|
|
canvas: "https://canvas.test/__openclaw__/cap/hello",
|
|
},
|
|
});
|
|
|
|
expect(gateway.snapshot.canvasPluginSurfaceUrl).toBe(
|
|
"https://canvas.test/__openclaw__/cap/hello",
|
|
);
|
|
|
|
current().opts.onClose?.({ code: 1006, reason: "socket lost", willRetry: true });
|
|
expect(gateway.snapshot.canvasPluginSurfaceUrl).toBeNull();
|
|
});
|
|
|
|
it("does not let a superseded canvas refresh publish into the current snapshot", async () => {
|
|
let resolveRefresh: (value: unknown) => void = () => {};
|
|
const firstRefresh = new Promise<unknown>((resolve) => {
|
|
resolveRefresh = resolve;
|
|
});
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
const first = current();
|
|
first.request.mockReturnValueOnce(firstRefresh);
|
|
first.opts.onHello?.({
|
|
...HELLO,
|
|
pluginSurfaceUrls: { canvas: "https://canvas.test/__openclaw__/cap/first" },
|
|
});
|
|
await vi.waitFor(() => expect(first.request).toHaveBeenCalledOnce());
|
|
|
|
gateway.connect();
|
|
current().opts.onHello?.({
|
|
...HELLO,
|
|
pluginSurfaceUrls: { canvas: "https://canvas.test/__openclaw__/cap/current" },
|
|
});
|
|
resolveRefresh({
|
|
surface: "canvas",
|
|
pluginSurfaceUrls: { canvas: "https://canvas.test/__openclaw__/cap/stale-refresh" },
|
|
expiresAtMs: Date.now() + 60_000,
|
|
});
|
|
await new Promise<void>((resolve) => {
|
|
globalThis.setTimeout(resolve, 0);
|
|
});
|
|
|
|
expect(gateway.snapshot.canvasPluginSurfaceUrl).toBe(
|
|
"https://canvas.test/__openclaw__/cap/current",
|
|
);
|
|
gateway.stop();
|
|
});
|
|
|
|
it("stays on the gate when the first connect fails, even with auto-retry pending", () => {
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
|
|
current().opts.onClose?.({ code: 1006, reason: "refused", willRetry: true });
|
|
|
|
expect(gateway.snapshot.phase).toBe("connecting");
|
|
expect(gateway.snapshot.lastError).toContain("1006");
|
|
});
|
|
|
|
it("returns a never-connected terminal close to stopped", () => {
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
|
|
current().opts.onClose?.({ code: 4008, reason: "connect failed", willRetry: false });
|
|
|
|
expect(gateway.snapshot.phase).toBe("stopped");
|
|
expect(gateway.snapshot.lastError).toContain("4008");
|
|
});
|
|
|
|
it.each(["stopped", "connecting", "connected", "reconnecting", "offline"] as const)(
|
|
"stop() resets %s to stopped",
|
|
(phase) => {
|
|
const { gateway, current } = createStore();
|
|
if (phase !== "stopped") {
|
|
gateway.start();
|
|
}
|
|
if (phase === "connected" || phase === "reconnecting" || phase === "offline") {
|
|
current().opts.onHello?.(HELLO);
|
|
}
|
|
if (phase === "reconnecting" || phase === "offline") {
|
|
current().opts.onClose?.({
|
|
code: 1006,
|
|
reason: "socket lost",
|
|
willRetry: phase === "reconnecting",
|
|
});
|
|
}
|
|
expect(gateway.snapshot.phase).toBe(phase);
|
|
|
|
gateway.stop();
|
|
|
|
expect(gateway.snapshot.phase).toBe("stopped");
|
|
expect(gateway.snapshot.client).toBeNull();
|
|
expect(gateway.snapshot.offlineStable).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("publishes a stable offline state only after a sustained disconnect", async () => {
|
|
vi.useFakeTimers();
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
current().opts.onHello?.(HELLO);
|
|
|
|
current().opts.onClose?.({ code: 1006, reason: "socket lost", willRetry: true });
|
|
expect(gateway.snapshot.offlineStable).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1_999);
|
|
expect(gateway.snapshot.offlineStable).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(gateway.snapshot.offlineStable).toBe(true);
|
|
});
|
|
|
|
it("does not publish offline before the gateway starts", async () => {
|
|
vi.useFakeTimers();
|
|
const { gateway } = createStore();
|
|
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
|
|
expect(gateway.snapshot.offlineStable).toBe(false);
|
|
});
|
|
|
|
it("keeps a sub-two-second connection blip quiet", async () => {
|
|
vi.useFakeTimers();
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
current().opts.onHello?.(HELLO);
|
|
|
|
current().opts.onClose?.({ code: 1006, reason: "brief blip", willRetry: true });
|
|
await vi.advanceTimersByTimeAsync(1_999);
|
|
current().opts.onHello?.(HELLO);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
expect(gateway.snapshot.offlineStable).toBe(false);
|
|
});
|
|
|
|
it("clears a stable offline state immediately on reconnect", async () => {
|
|
vi.useFakeTimers();
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
current().opts.onHello?.(HELLO);
|
|
current().opts.onClose?.({ code: 1006, reason: "socket lost", willRetry: true });
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
expect(gateway.snapshot.offlineStable).toBe(true);
|
|
|
|
current().opts.onHello?.(HELLO);
|
|
|
|
expect(gateway.snapshot.offlineStable).toBe(false);
|
|
});
|
|
|
|
it("clears the pending offline timer when stopped", async () => {
|
|
vi.useFakeTimers();
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
current().opts.onHello?.(HELLO);
|
|
current().opts.onClose?.({ code: 1006, reason: "socket lost", willRetry: true });
|
|
|
|
gateway.stop();
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
|
|
expect(gateway.snapshot.offlineStable).toBe(false);
|
|
});
|
|
|
|
it("drops back to the gate when the client gives up (credential rejection)", () => {
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
current().opts.onHello?.(HELLO);
|
|
|
|
current().opts.onClose?.({ code: 4008, reason: "connect failed", willRetry: false });
|
|
|
|
expect(gateway.snapshot.phase).toBe("offline");
|
|
});
|
|
|
|
it("keeps reconnecting across event-gap recovery with a fresh client", () => {
|
|
const { gateway, clients, current } = createStore();
|
|
gateway.start();
|
|
current().opts.onHello?.(HELLO);
|
|
|
|
current().opts.onGap?.({ expected: 2, received: 5 });
|
|
|
|
expect(clients).toHaveLength(2);
|
|
expect(clients[0]?.stopped).toBe(1);
|
|
expect(current().started).toBe(1);
|
|
expect(gateway.snapshot.phase).toBe("reconnecting");
|
|
});
|
|
|
|
it("discards the gapped frame after recovery synchronously replaces its client", () => {
|
|
const { gateway, current } = createStore();
|
|
const listener = vi.fn();
|
|
gateway.subscribeEvents(listener);
|
|
gateway.start();
|
|
const stale = current();
|
|
stale.opts.onHello?.(HELLO);
|
|
|
|
// The protocol invokes onGap before onEvent for the same received frame.
|
|
stale.opts.onGap?.({ expected: 2, received: 5 });
|
|
stale.opts.onEvent?.(createGatewayEvent("stale.gap", { stale: true }, 5));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
expect(gateway.eventLog).toEqual([]);
|
|
|
|
const activeEvent = createGatewayEvent("fresh.event", { active: true }, 6);
|
|
current().opts.onEvent?.(activeEvent);
|
|
|
|
expect(listener).toHaveBeenCalledOnce();
|
|
expect(listener).toHaveBeenCalledWith(activeEvent);
|
|
expect(gateway.eventLog).toMatchObject([{ event: "fresh.event", payload: { active: true } }]);
|
|
});
|
|
|
|
it("resets the session lineage on stop so the next start uses the gate again", () => {
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
current().opts.onHello?.(HELLO);
|
|
gateway.stop();
|
|
|
|
expect(gateway.snapshot.phase).toBe("stopped");
|
|
|
|
gateway.start();
|
|
current().opts.onClose?.({ code: 1006, reason: "refused", willRetry: true });
|
|
|
|
expect(gateway.snapshot.phase).toBe("connecting");
|
|
});
|
|
|
|
it("ignores close callbacks from superseded clients", () => {
|
|
const { gateway, clients, current } = createStore();
|
|
gateway.start();
|
|
current().opts.onHello?.(HELLO);
|
|
const stale = current();
|
|
gateway.connect();
|
|
expect(clients).toHaveLength(2);
|
|
|
|
stale.opts.onClose?.({ code: 1006, reason: "stale", willRetry: false });
|
|
|
|
// The superseded client cannot demote the fresh attempt's snapshot.
|
|
expect(gateway.snapshot.phase).toBe("reconnecting");
|
|
});
|
|
|
|
it("fans active events out once without binding subscribers to the transport", () => {
|
|
const { gateway, current } = createStore();
|
|
const first = vi.fn();
|
|
const second = vi.fn();
|
|
gateway.subscribeEvents(first);
|
|
gateway.start();
|
|
const active = current();
|
|
const addEventListener = vi.spyOn(active, "addEventListener");
|
|
const unsubscribeSecond = gateway.subscribeEvents(second);
|
|
const event = createGatewayEvent("chat", { text: "hello" });
|
|
|
|
active.opts.onEvent?.(event);
|
|
|
|
expect(first).toHaveBeenCalledExactlyOnceWith(event);
|
|
expect(second).toHaveBeenCalledExactlyOnceWith(event);
|
|
expect(addEventListener).not.toHaveBeenCalled();
|
|
expect(gateway.eventLog).toMatchObject([{ event: "chat", payload: { text: "hello" } }]);
|
|
|
|
unsubscribeSecond();
|
|
const nextEvent = createGatewayEvent("chat", { text: "next" }, 2);
|
|
active.opts.onEvent?.(nextEvent);
|
|
|
|
expect(first).toHaveBeenCalledTimes(2);
|
|
expect(second).toHaveBeenCalledOnce();
|
|
expect(addEventListener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps event subscriptions across reconnects and a stopped gateway", () => {
|
|
const { gateway, current } = createStore();
|
|
const listener = vi.fn();
|
|
gateway.subscribeEvents(listener);
|
|
gateway.start();
|
|
const first = current();
|
|
const firstEvent = createGatewayEvent("chat", { text: "first connection" });
|
|
first.opts.onEvent?.(firstEvent);
|
|
|
|
gateway.connect();
|
|
const second = current();
|
|
first.opts.onEvent?.(createGatewayEvent("chat", { text: "stale connection" }, 2));
|
|
const secondEvent = createGatewayEvent("chat", { text: "second connection" }, 3);
|
|
second.opts.onEvent?.(secondEvent);
|
|
|
|
gateway.stop();
|
|
second.opts.onEvent?.(createGatewayEvent("chat", { text: "stopped connection" }, 4));
|
|
|
|
gateway.start();
|
|
const thirdEvent = createGatewayEvent("chat", { text: "restarted connection" }, 5);
|
|
current().opts.onEvent?.(thirdEvent);
|
|
|
|
expect(listener.mock.calls).toEqual([[firstEvent], [secondEvent], [thirdEvent]]);
|
|
expect(gateway.eventLog.map((entry) => entry.payload)).toEqual([
|
|
{ text: "restarted connection" },
|
|
{ text: "second connection" },
|
|
{ text: "first connection" },
|
|
]);
|
|
});
|
|
|
|
it("snapshots subscribers when an event adds or removes another listener", () => {
|
|
const { gateway, current } = createStore();
|
|
const second = vi.fn();
|
|
const third = vi.fn();
|
|
let unsubscribeSecond = () => {};
|
|
const first = vi.fn(() => {
|
|
unsubscribeSecond();
|
|
gateway.subscribeEvents(third);
|
|
});
|
|
gateway.subscribeEvents(first);
|
|
unsubscribeSecond = gateway.subscribeEvents(second);
|
|
gateway.start();
|
|
const firstEvent = createGatewayEvent("chat", { text: "first" });
|
|
|
|
current().opts.onEvent?.(firstEvent);
|
|
|
|
expect(first).toHaveBeenCalledExactlyOnceWith(firstEvent);
|
|
expect(second).toHaveBeenCalledExactlyOnceWith(firstEvent);
|
|
expect(third).not.toHaveBeenCalled();
|
|
|
|
const secondEvent = createGatewayEvent("chat", { text: "second" }, 2);
|
|
current().opts.onEvent?.(secondEvent);
|
|
|
|
expect(first).toHaveBeenCalledTimes(2);
|
|
expect(second).toHaveBeenCalledOnce();
|
|
expect(third).toHaveBeenCalledExactlyOnceWith(secondEvent);
|
|
});
|
|
|
|
it("isolates a failing subscriber from later event subscribers", () => {
|
|
const { gateway, current } = createStore();
|
|
const failure = new Error("subscriber failed");
|
|
const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const failing = vi.fn(() => {
|
|
throw failure;
|
|
});
|
|
const healthy = vi.fn();
|
|
gateway.subscribeEvents(failing);
|
|
gateway.subscribeEvents(healthy);
|
|
gateway.start();
|
|
const event = createGatewayEvent("chat", { text: "still delivered" });
|
|
|
|
current().opts.onEvent?.(event);
|
|
|
|
expect(failing).toHaveBeenCalledExactlyOnceWith(event);
|
|
expect(healthy).toHaveBeenCalledExactlyOnceWith(event);
|
|
expect(reportError).toHaveBeenCalledExactlyOnceWith(
|
|
"[gateway] event listener handler error:",
|
|
failure,
|
|
);
|
|
expect(gateway.eventLog).toMatchObject([
|
|
{ event: "chat", payload: { text: "still delivered" } },
|
|
]);
|
|
});
|
|
|
|
it("delivers active events when an event-log subscriber throws", () => {
|
|
const { gateway, current } = createStore();
|
|
const failure = new Error("event log subscriber failed");
|
|
const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
gateway.subscribeEventLog(() => {
|
|
throw failure;
|
|
});
|
|
const listener = vi.fn();
|
|
gateway.subscribeEvents(listener);
|
|
gateway.start();
|
|
const event = createGatewayEvent("chat", { text: "still delivered" });
|
|
|
|
current().opts.onEvent?.(event);
|
|
|
|
expect(listener).toHaveBeenCalledExactlyOnceWith(event);
|
|
expect(reportError).toHaveBeenCalledExactlyOnceWith("[gateway] event handler error:", failure);
|
|
expect(gateway.eventLog).toMatchObject([
|
|
{ event: "chat", payload: { text: "still delivered" } },
|
|
]);
|
|
});
|
|
|
|
it("stops delivering a replaced client's event to remaining subscribers", () => {
|
|
const { gateway, clients, current } = createStore();
|
|
const first = vi.fn(() => gateway.connect());
|
|
const second = vi.fn();
|
|
gateway.subscribeEvents(first);
|
|
gateway.subscribeEvents(second);
|
|
gateway.start();
|
|
const stale = current();
|
|
const event = createGatewayEvent("chat", { text: "replace connection" });
|
|
|
|
stale.opts.onEvent?.(event);
|
|
|
|
expect(clients).toHaveLength(2);
|
|
expect(first).toHaveBeenCalledExactlyOnceWith(event);
|
|
expect(second).not.toHaveBeenCalled();
|
|
|
|
const activeEvent = createGatewayEvent("chat", { text: "fresh connection" }, 2);
|
|
current().opts.onEvent?.(activeEvent);
|
|
|
|
expect(first).toHaveBeenCalledTimes(2);
|
|
expect(second).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores queued events after the gateway is stopped", () => {
|
|
const { gateway, current } = createStore();
|
|
const listener = vi.fn();
|
|
gateway.subscribeEvents(listener);
|
|
gateway.start();
|
|
const stale = current();
|
|
|
|
gateway.stop();
|
|
stale.opts.onEvent?.(createGatewayEvent("chat", { text: "stopped" }));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
expect(gateway.eventLog).toEqual([]);
|
|
});
|
|
|
|
it("ignores presence and event-log callbacks from superseded clients", () => {
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
current().opts.onHello?.(HELLO);
|
|
const stale = current();
|
|
|
|
gateway.connect();
|
|
const active = current();
|
|
active.opts.onHello?.({
|
|
...HELLO,
|
|
snapshot: {
|
|
presence: [
|
|
{
|
|
instanceId: active.instanceId,
|
|
user: { id: "current-user", name: "Current user" },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
stale.opts.onEvent?.({
|
|
type: "event",
|
|
event: "presence",
|
|
payload: {
|
|
presence: [
|
|
{
|
|
instanceId: active.instanceId,
|
|
user: { id: "stale-user", name: "Stale user" },
|
|
},
|
|
],
|
|
},
|
|
seq: 1,
|
|
stateVersion: { presence: 1, health: 1 },
|
|
});
|
|
|
|
expect(gateway.snapshot.selfUser).toEqual({ id: "current-user", name: "Current user" });
|
|
expect(gateway.eventLog).toEqual([]);
|
|
|
|
active.opts.onEvent?.({
|
|
type: "event",
|
|
event: "presence",
|
|
payload: {
|
|
presence: [
|
|
{
|
|
instanceId: active.instanceId,
|
|
user: { id: "current-user", name: "Updated current user" },
|
|
},
|
|
],
|
|
},
|
|
seq: 2,
|
|
stateVersion: { presence: 2, health: 1 },
|
|
});
|
|
|
|
expect(gateway.snapshot.selfUser).toEqual({
|
|
id: "current-user",
|
|
name: "Updated current user",
|
|
});
|
|
expect(gateway.eventLog).toHaveLength(1);
|
|
});
|
|
|
|
it("projects only this browser connection's optional presence identity", () => {
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
const instanceId = current().opts.instanceId;
|
|
current().opts.onHello?.({
|
|
...HELLO,
|
|
snapshot: {
|
|
presence: [
|
|
{ instanceId: "someone-else", user: { id: "other", name: "Other" } },
|
|
{
|
|
instanceId,
|
|
user: { id: "profile-1", email: "ada@example.test", name: "Ada" },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(gateway.snapshot.selfUser).toEqual({
|
|
id: "profile-1",
|
|
email: "ada@example.test",
|
|
name: "Ada",
|
|
});
|
|
|
|
gateway.updateSelfUser?.({ name: "Augusta Ada", avatarUrl: "/api/users/profile-1/avatar?v=2" });
|
|
expect(gateway.snapshot.selfUser).toMatchObject({
|
|
id: "profile-1",
|
|
name: "Augusta Ada",
|
|
avatarUrl: "/api/users/profile-1/avatar?v=2",
|
|
});
|
|
|
|
current().opts.onEvent?.({
|
|
type: "event",
|
|
event: "presence",
|
|
payload: {
|
|
presence: [
|
|
{
|
|
instanceId,
|
|
user: {
|
|
id: "profile-1",
|
|
email: "ada@example.test",
|
|
name: "Ada Lovelace",
|
|
avatarUrl: "/api/users/profile-1/avatar?v=3",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
seq: 1,
|
|
stateVersion: { presence: 1, health: 1 },
|
|
});
|
|
expect(gateway.snapshot.selfUser).toMatchObject({
|
|
id: "profile-1",
|
|
name: "Ada Lovelace",
|
|
avatarUrl: "/api/users/profile-1/avatar?v=3",
|
|
});
|
|
|
|
current().opts.onEvent?.({
|
|
type: "event",
|
|
event: "presence",
|
|
payload: { presence: [{ instanceId: "anonymous" }] },
|
|
seq: 2,
|
|
stateVersion: { presence: 2, health: 1 },
|
|
});
|
|
expect(gateway.snapshot.selfUser).toMatchObject({
|
|
id: "profile-1",
|
|
name: "Ada Lovelace",
|
|
avatarUrl: "/api/users/profile-1/avatar?v=3",
|
|
});
|
|
});
|
|
|
|
it("clears identity while disconnected", () => {
|
|
const { gateway, current } = createStore();
|
|
gateway.start();
|
|
current().opts.onHello?.({
|
|
...HELLO,
|
|
snapshot: {
|
|
presence: [
|
|
{ instanceId: current().opts.instanceId, user: { id: "profile-1", name: "Ada" } },
|
|
],
|
|
},
|
|
});
|
|
|
|
current().opts.onClose?.({ code: 1006, reason: "socket lost", willRetry: true });
|
|
|
|
expect(gateway.snapshot.selfUser).toBeNull();
|
|
});
|
|
|
|
it("does not copy selected-remote settings into an ephemeral document Gateway", () => {
|
|
const pageGateway = "ws://127.0.0.1:18789";
|
|
const remoteGateway = "wss://saved-remote.example.test";
|
|
const pageSettingsKey = `openclaw.control.settings.v1:${pageGateway}`;
|
|
const selectionKey = `openclaw.control.currentGateway.v1:${pageGateway}`;
|
|
const storedPageSettings = JSON.stringify({
|
|
gatewayUrl: pageGateway,
|
|
theme: "claw",
|
|
sessionKey: "agent:page:saved",
|
|
});
|
|
const settings = {
|
|
...loadSettings(),
|
|
gatewayUrl: pageGateway,
|
|
token: "page-token",
|
|
theme: "dash" as const,
|
|
sessionKey: "agent:page:document",
|
|
lastActiveSessionKey: "agent:page:document",
|
|
};
|
|
localStorage.setItem(pageSettingsKey, storedPageSettings);
|
|
localStorage.setItem(selectionKey, remoteGateway);
|
|
const { gateway, current } = createStore({
|
|
settings,
|
|
persistDefaultConnectionSettings: false,
|
|
});
|
|
|
|
gateway.start();
|
|
expect(current().opts.token).toBe("page-token");
|
|
current().opts.onHello?.(HELLO);
|
|
gateway.connect({ token: "replacement-page-token" });
|
|
|
|
expect(current().opts.token).toBe("replacement-page-token");
|
|
expect(localStorage.getItem(pageSettingsKey)).toBe(storedPageSettings);
|
|
expect(localStorage.getItem(selectionKey)).toBe(remoteGateway);
|
|
});
|
|
|
|
it("keeps ephemeral login on the serving gateway from persisting the selection", () => {
|
|
const pageGateway = "ws://127.0.0.1:18789";
|
|
const remoteGateway = "wss://saved-remote.example.test";
|
|
const otherGateway = "wss://other-remote.example.test";
|
|
const pageSettingsKey = `openclaw.control.settings.v1:${pageGateway}`;
|
|
const selectionKey = `openclaw.control.currentGateway.v1:${pageGateway}`;
|
|
const settings = {
|
|
...loadSettings(),
|
|
gatewayUrl: pageGateway,
|
|
token: "",
|
|
};
|
|
localStorage.setItem(selectionKey, remoteGateway);
|
|
const { gateway, current } = createStore({
|
|
settings,
|
|
persistDefaultConnectionSettings: false,
|
|
});
|
|
|
|
gateway.start();
|
|
// The login gate always resubmits its prefilled (serving) gateway URL;
|
|
// an unchanged URL must not count as an explicit gateway selection.
|
|
gateway.connect({ gatewayUrl: pageGateway, token: "approval-token", password: "pw" });
|
|
|
|
expect(current().opts.url).toBe(pageGateway);
|
|
expect(current().opts.token).toBe("approval-token");
|
|
expect(localStorage.getItem(pageSettingsKey)).toBeNull();
|
|
expect(localStorage.getItem(selectionKey)).toBe(remoteGateway);
|
|
|
|
// A genuinely changed URL is an explicit selection and persists.
|
|
gateway.connect({ gatewayUrl: otherGateway });
|
|
|
|
expect(current().opts.url).toBe(otherGateway);
|
|
expect(localStorage.getItem(selectionKey)).toBe(otherGateway);
|
|
});
|
|
});
|