// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from
"vitest" ;
import { createStorageMock } from
"../test-helpers/storage.ts" ;
import {
loadLocalUserIdentity,
loadSettings,
saveLocalUserIdentity,
saveSettings,
} from
"./storage.ts" ;
import { normalizeImportedCustomTheme } from
"./custom-theme.ts" ;
function setTestLocation(params: { protocol: string; host: string; pathname: string }) {
vi.stubGlobal(
"location" , {
protocol: params.protocol,
host: params.host,
hostname: params.host.replace(/:\d+$/,
"" ),
pathname: params.pathname,
} as Location);
}
function setControlUiBasePath(value: string | undefined) {
if (
typeof window ===
"undefined" ) {
vi.stubGlobal(
"window" ,
value ==
null
? ({} as Window &
typeof globalThis)
: ({ __OPENCLAW_CONTROL_UI_BASE_PATH__: value } as Window &
typeof globalThis),
);
return ;
}
if (value ==
null ) {
delete window.__OPENCLAW_CONTROL_UI_BASE_PATH__;
return ;
}
Object.defineProperty(window,
"__OPENCLAW_CONTROL_UI_BASE_PATH__" , {
value,
writable:
true ,
configurable:
true ,
});
}
function expectedGatewayUrl(basePath: string): string {
const proto = location.protocol ===
"https:" ?
"wss" :
"ws" ;
return `${proto}:
//${location.host}${basePath}`;
}
function createCustomThemeFixture() {
return normalizeImportedCustomTheme(
{
name:
"Light Green" ,
cssVars: {
theme: {
"font-sans" :
"Inter, system-ui, sans-serif" ,
"font-mono" :
"JetBrains Mono, monospace" ,
},
light: {
background:
"oklch(0.98 0.01 120)" ,
foreground:
"oklch(0.2 0.03 265)" ,
card:
"oklch(1 0 0)" ,
"card-foreground" :
"oklch(0.2 0.03 265)" ,
popover:
"oklch(1 0 0)" ,
"popover-foreground" :
"oklch(0.2 0.03 265)" ,
primary:
"oklch(0.8 0.2 128)" ,
"primary-foreground" :
"oklch(0 0 0)" ,
secondary:
"oklch(0.35 0.03 257)" ,
"secondary-foreground" :
"oklch(0.98 0.01 248)" ,
muted:
"oklch(0.96 0.01 248)" ,
"muted-foreground" :
"oklch(0.55 0.04 257)" ,
accent:
"oklch(0.98 0.02 155)" ,
"accent-foreground" :
"oklch(0.45 0.1 151)" ,
destructive:
"oklch(0.64 0.2 25)" ,
"destructive-foreground" :
"oklch(1 0 0)" ,
border:
"oklch(0.92 0.01 255)" ,
input:
"oklch(0.92 0.01 255)" ,
ring:
"oklch(0.8 0.2 128)" ,
},
dark: {
background:
"oklch(0.12 0.04 265)" ,
foreground:
"oklch(0.98 0.01 248)" ,
card:
"oklch(0.2 0.04 266)" ,
"card-foreground" :
"oklch(0.98 0.01 248)" ,
popover:
"oklch(0.2 0.04 266)" ,
"popover-foreground" :
"oklch(0.98 0.01 248)" ,
primary:
"oklch(0.8 0.2 128)" ,
"primary-foreground" :
"oklch(0 0 0)" ,
secondary:
"oklch(0.28 0.04 260)" ,
"secondary-foreground" :
"oklch(0.98 0.01 248)" ,
muted:
"oklch(0.28 0.04 260)" ,
"muted-foreground" :
"oklch(0.71 0.03 257)" ,
accent:
"oklch(0.39 0.09 152)" ,
"accent-foreground" :
"oklch(0.8 0.2 128)" ,
destructive:
"oklch(0.44 0.16 27)" ,
"destructive-foreground" :
"oklch(1 0 0)" ,
border:
"oklch(0.28 0.04 260)" ,
input:
"oklch(0.28 0.04 260)" ,
ring:
"oklch(0.8 0.2 128)" ,
},
},
},
{
sourceUrl:
"https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z ",
themeId:
"cmlhfpjhw000004l4f4ax3m7z" ,
},
);
}
describe(
"loadSettings default gateway URL derivation" , () => {
beforeEach(() => {
vi.stubGlobal(
"localStorage" , createStorageMock());
vi.stubGlobal(
"sessionStorage" , createStorageMock());
vi.stubGlobal(
"navigator" , { language:
"en-US" } as Navigator);
localStorage.clear();
sessionStorage.clear();
setControlUiBasePath(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
setControlUiBasePath(undefined);
vi.unstubAllGlobals();
});
it(
"uses configured base path and normalizes trailing slash" , async () => {
setTestLocation({
protocol:
"https:" ,
host:
"gateway.example:8443" ,
pathname:
"/ignored/path" ,
});
setControlUiBasePath(
" /openclaw/ " );
expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl(
"/openclaw" ));
});
it(
"infers base path from nested pathname when configured base path is not set" , async () => {
setTestLocation({
protocol:
"http:" ,
host:
"gateway.example:18789" ,
pathname:
"/apps/openclaw/chat" ,
});
expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl(
"/apps/openclaw" ));
});
it(
"skips node sessionStorage accessors that warn without a storage file" , async () => {
vi.unstubAllGlobals();
vi.stubGlobal(
"localStorage" , createStorageMock());
vi.stubGlobal(
"navigator" , { language:
"en-US" } as Navigator);
setTestLocation({
protocol:
"https:" ,
host:
"gateway.example:8443" ,
pathname:
"/" ,
});
setControlUiBasePath(undefined);
const warningSpy = vi.spyOn(process,
"emitWarning" ).mockImplementation(() => undefined)
;
expect(loadSettings()).toMatchObject({
gatewayUrl: expectedGatewayUrl("" ),
token: "" ,
});
expect(warningSpy).not.toHaveBeenCalledWith(
"`--localstorage-file` was provided without a valid path" ,
expect.anything(),
expect.anything(),
);
});
it("ignores and scrubs legacy persisted tokens" , async () => {
setTestLocation({
protocol: "https:" ,
host: "gateway.example:8443" ,
pathname: "/" ,
});
sessionStorage.setItem("openclaw.control.token.v1" , "legacy-session-token" );
localStorage.setItem(
"openclaw.control.settings.v1" ,
JSON.stringify({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "persisted-token" ,
sessionKey: "agent" ,
}),
);
expect(loadSettings()).toMatchObject({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "" ,
sessionKey: "agent" ,
});
const scopedKey = "openclaw.control.settings.v1:wss://gateway.example:8443/openclaw";
expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}" )).toEqual({
gatewayUrl: "wss://gateway.example:8443/openclaw",
theme: "claw" ,
themeMode: "system" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 220 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
sessionsByGateway: {
"wss://gateway.example:8443/openclaw": {
sessionKey: "agent" ,
lastActiveSessionKey: "agent" ,
},
},
});
expect(sessionStorage.length).toBe(0 );
});
it("loads the current-tab token from sessionStorage" , async () => {
setTestLocation({
protocol: "https:" ,
host: "gateway.example:8443" ,
pathname: "/" ,
});
const gwUrl = expectedGatewayUrl("" );
saveSettings({
gatewayUrl: gwUrl,
token: "session-token" ,
sessionKey: "main" ,
lastActiveSessionKey: "main" ,
theme: "claw" ,
themeMode: "system" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 220 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
});
expect(loadSettings()).toMatchObject({
gatewayUrl: gwUrl,
token: "session-token" ,
});
});
it("does not reuse a session token for a different gatewayUrl" , async () => {
setTestLocation({
protocol: "https:" ,
host: "gateway.example:8443" ,
pathname: "/" ,
});
const gwUrl = expectedGatewayUrl("" );
const otherUrl = "wss://other-gateway.example:8443";
saveSettings({
gatewayUrl: gwUrl,
token: "gateway-a-token" ,
sessionKey: "main" ,
lastActiveSessionKey: "main" ,
theme: "claw" ,
themeMode: "system" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 220 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
});
saveSettings({
gatewayUrl: otherUrl,
token: "" ,
sessionKey: "main" ,
lastActiveSessionKey: "main" ,
theme: "claw" ,
themeMode: "system" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 220 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
});
expect(loadSettings()).toMatchObject({
gatewayUrl: gwUrl,
token: "gateway-a-token" ,
});
});
it("does not persist gateway tokens when saving settings" , async () => {
setTestLocation({
protocol: "https:" ,
host: "gateway.example:8443" ,
pathname: "/" ,
});
const gwUrl = expectedGatewayUrl("" );
saveSettings({
gatewayUrl: gwUrl,
token: "memory-only-token" ,
sessionKey: "main" ,
lastActiveSessionKey: "main" ,
theme: "claw" ,
themeMode: "system" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 220 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
});
expect(loadSettings()).toMatchObject({
gatewayUrl: gwUrl,
token: "memory-only-token" ,
});
const scopedKey = `openclaw.control.settings.v1:${gwUrl}`;
expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}" )).toEqual({
gatewayUrl: gwUrl,
theme: "claw" ,
themeMode: "system" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 220 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
sessionsByGateway: {
[gwUrl]: {
sessionKey: "main" ,
lastActiveSessionKey: "main" ,
},
},
});
expect(sessionStorage.length).toBe(1 );
});
it("clears the current-tab token when saving an empty token" , async () => {
setTestLocation({
protocol: "https:" ,
host: "gateway.example:8443" ,
pathname: "/" ,
});
const gwUrl = expectedGatewayUrl("" );
saveSettings({
gatewayUrl: gwUrl,
token: "stale-token" ,
sessionKey: "main" ,
lastActiveSessionKey: "main" ,
theme: "claw" ,
themeMode: "system" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 220 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
});
saveSettings({
gatewayUrl: gwUrl,
token: "" ,
sessionKey: "main" ,
lastActiveSessionKey: "main" ,
theme: "claw" ,
themeMode: "system" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 220 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
});
expect(loadSettings().token).toBe("" );
expect(sessionStorage.length).toBe(0 );
});
it("persists themeMode and navWidth alongside the selected theme" , async () => {
setTestLocation({
protocol: "https:" ,
host: "gateway.example:8443" ,
pathname: "/" ,
});
const gwUrl = expectedGatewayUrl("" );
saveSettings({
gatewayUrl: gwUrl,
token: "" ,
sessionKey: "main" ,
lastActiveSessionKey: "main" ,
theme: "dash" ,
themeMode: "light" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 320 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
});
const scopedKey = `openclaw.control.settings.v1:${gwUrl}`;
expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}" )).toMatchObject({
theme: "dash" ,
themeMode: "light" ,
navWidth: 320 ,
});
});
it("persists the browser-local custom theme payload when present" , async () => {
setTestLocation({
protocol: "https:" ,
host: "gateway.example:8443" ,
pathname: "/" ,
});
const gwUrl = expectedGatewayUrl("" );
const customTheme = createCustomThemeFixture();
saveSettings({
gatewayUrl: gwUrl,
token: "" ,
sessionKey: "main" ,
lastActiveSessionKey: "main" ,
theme: "custom" ,
themeMode: "system" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 220 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
customTheme,
});
expect(loadSettings()).toMatchObject({
theme: "custom" ,
customTheme: {
label: "Light Green" ,
themeId: "cmlhfpjhw000004l4f4ax3m7z" ,
},
});
});
it("falls back to claw when persisted custom theme data is invalid" , async () => {
setTestLocation({
protocol: "https:" ,
host: "gateway.example:8443" ,
pathname: "/" ,
});
const gwUrl = expectedGatewayUrl("" );
localStorage.setItem(
`openclaw.control.settings.v1:${gwUrl}`,
JSON.stringify({
gatewayUrl: gwUrl,
theme: "custom" ,
themeMode: "dark" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 220 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
customTheme: {
sourceUrl: "https://tweakcn.com/themes/broken ",
themeId: "broken" ,
label: "Broken" ,
importedAt: "2026-04-22T00:00:00.000Z" ,
light: {},
dark: {},
},
sessionsByGateway: {
[gwUrl]: {
sessionKey: "main" ,
lastActiveSessionKey: "main" ,
},
},
}),
);
expect(loadSettings()).toMatchObject({
theme: "claw" ,
themeMode: "dark" ,
});
});
it("scopes persisted session selection per gateway" , async () => {
setTestLocation({
protocol: "https:" ,
host: "gateway-a.example:8443" ,
pathname: "/" ,
});
const gwUrl = expectedGatewayUrl("" );
saveSettings({
gatewayUrl: gwUrl,
token: "" ,
sessionKey: "agent:test_old:main" ,
lastActiveSessionKey: "agent:test_old:main" ,
theme: "claw" ,
themeMode: "system" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 220 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
});
expect(loadSettings()).toMatchObject({
gatewayUrl: gwUrl,
sessionKey: "agent:test_old:main" ,
lastActiveSessionKey: "agent:test_old:main" ,
});
});
it("caps persisted session scopes to the most recent gateways" , async () => {
setTestLocation({
protocol: "https:" ,
host: "gateway.example:8443" ,
pathname: "/" ,
});
const gwUrl = expectedGatewayUrl("" );
const scopedKey = `openclaw.control.settings.v1:wss://gateway.example:8443`;
// Pre-seed sessionsByGateway with 11 stale gateway entries so the next
// saveSettings call pushes the total to 12 and triggers the cap (10).
const staleEntries: Record<string, { sessionKey: string; lastActiveSessionKey: string }> = {};
for (let i = 0 ; i < 11 ; i += 1 ) {
staleEntries[`wss://stale-${i}.example:8443`] = {
sessionKey: `agent:stale_${i}:main`,
lastActiveSessionKey: `agent:stale_${i}:main`,
};
}
localStorage.setItem(scopedKey, JSON.stringify({ sessionsByGateway: staleEntries }));
saveSettings({
gatewayUrl: gwUrl,
token: "" ,
sessionKey: "agent:current:main" ,
lastActiveSessionKey: "agent:current:main" ,
theme: "claw" ,
themeMode: "system" ,
chatFocusMode: false ,
chatShowThinking: true ,
chatShowToolCalls: true ,
splitRatio: 0 .6 ,
navCollapsed: false ,
navWidth: 220 ,
navGroupsCollapsed: {},
borderRadius: 50 ,
});
const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}" );
expect(persisted.sessionsByGateway).toBeDefined();
const scopes = Object.keys(persisted.sessionsByGateway);
expect(scopes).toHaveLength(10 );
// oldest stale entries should be evicted
expect(scopes).not.toContain("wss://stale-0.example:8443");
expect(scopes).not.toContain("wss://stale-1.example:8443");
// newest stale entries and the current gateway should be retained
expect(scopes).toContain("wss://stale-10.example:8443");
expect(scopes).toContain("wss://gateway.example:8443");
expect(persisted.sessionsByGateway["wss://gateway.example:8443"]).toEqual({
sessionKey: "agent:current:main" ,
lastActiveSessionKey: "agent:current:main" ,
});
});
it("persists local user identity separately from gateway settings" , async () => {
setTestLocation({
protocol: "https:" ,
host: "gateway.example:8443" ,
pathname: "/" ,
});
saveLocalUserIdentity({ name: "Buns" , avatar: "" });
expect(loadLocalUserIdentity()).toEqual({
name: "Buns" ,
avatar: "" ,
});
expect(JSON.parse(localStorage.getItem("openclaw.control.user.v1" ) ?? "{}" )).toEqual({
name: "Buns" ,
avatar: "" ,
});
});
it("normalizes invalid local user identity values on load" , async () => {
localStorage.setItem(
"openclaw.control.user.v1" ,
JSON.stringify({
name: " " ,
avatar: "https://example.com/avatar.png ",
}),
);
expect(loadLocalUserIdentity()).toEqual({
name: null ,
avatar: null ,
});
});
it("removes the persisted local user identity when cleared" , async () => {
saveLocalUserIdentity({ name: "Buns" , avatar: "data:image/png;base64,AAA" });
saveLocalUserIdentity({ name: null , avatar: null });
expect(loadLocalUserIdentity()).toEqual({
name: null ,
avatar: null ,
});
expect(localStorage.getItem("openclaw.control.user.v1" )).toBeNull();
});
});
Messung V0.5 in Prozent C=97 H=99 G=97
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-06-09)
¤
*© Formatika GbR, Deutschland