Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion packages/sunpeak/src/inspector/use-inspector-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { useInspectorState } from './use-inspector-state';
import type { Simulation } from '../types/simulation';

Expand All @@ -14,7 +14,14 @@ function createSim(name: string, hasResource: boolean): Simulation {
};
}

const PREFS_KEY = 'sunpeak-inspector-prefs';

describe('useInspectorState', () => {
beforeEach(() => {
localStorage.clear();
window.history.replaceState({}, '', '/');
});

it('filters out backend-only simulations', () => {
const simulations = {
'ui-tool': createSim('ui-tool', true),
Expand All @@ -26,4 +33,92 @@ describe('useInspectorState', () => {
expect(result.current.simulationNames).toContain('ui-tool');
expect(result.current.simulationNames).not.toContain('backend-tool');
});

describe('preference persistence', () => {
const simulations = { 'ui-tool': createSim('ui-tool', true) };

it('restores valid stored preferences', () => {
localStorage.setItem(
PREFS_KEY,
JSON.stringify({
theme: 'light',
locale: 'ja-JP',
activeHost: 'claude',
containerMaxHeight: 600,
containerMaxWidth: 800,
screenWidth: 'tablet',
})
);

const { result } = renderHook(() => useInspectorState({ simulations }));

expect(result.current.theme).toBe('light');
expect(result.current.locale).toBe('ja-JP');
expect(result.current.activeHost).toBe('claude');
expect(result.current.containerMaxHeight).toBe(600);
expect(result.current.containerMaxWidth).toBe(800);
expect(result.current.screenWidth).toBe('tablet');
});

it('ignores invalid values in stored preferences', () => {
localStorage.setItem(
PREFS_KEY,
JSON.stringify({
theme: 'neon-pink',
displayMode: 'magic',
platform: 'console',
screenWidth: 'enormous',
containerMaxHeight: 'tall',
hover: 'yes',
safeAreaInsets: { top: 10 },
})
);

const { result } = renderHook(() => useInspectorState({ simulations }));

// Bad values are dropped; defaults are used.
expect(result.current.theme).toBe('dark');
expect(result.current.displayMode).toBe('inline');
expect(result.current.platform).toBe('desktop');
expect(result.current.screenWidth).toBe('full');
expect(result.current.containerMaxHeight).toBeUndefined();
expect(result.current.hover).toBe(true);
expect(result.current.safeAreaInsets).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
});

it('falls back to defaults when stored JSON is corrupt', () => {
localStorage.setItem(PREFS_KEY, 'not-json{');

const { result } = renderHook(() => useInspectorState({ simulations }));

expect(result.current.theme).toBe('dark');
expect(result.current.activeHost).toBe('chatgpt');
});

it('skips persistence when autoRun=true (test fixture mode)', () => {
window.history.replaceState({}, '', '/?autoRun=true');
localStorage.setItem(PREFS_KEY, JSON.stringify({ theme: 'light', activeHost: 'claude' }));

const { result } = renderHook(() => useInspectorState({ simulations }));

// autoRun mode ignores storage entirely.
expect(result.current.theme).toBe('dark');
expect(result.current.activeHost).toBe('chatgpt');
});

it('URL params take precedence over stored preferences', () => {
window.history.replaceState({}, '', '/?theme=light&host=claude');
localStorage.setItem(
PREFS_KEY,
JSON.stringify({ theme: 'dark', activeHost: 'chatgpt', locale: 'fr-FR' })
);

const { result } = renderHook(() => useInspectorState({ simulations }));

expect(result.current.theme).toBe('light');
expect(result.current.activeHost).toBe('claude');
// Storage still wins for fields not in the URL.
expect(result.current.locale).toBe('fr-FR');
});
});
});
78 changes: 76 additions & 2 deletions packages/sunpeak/src/inspector/use-inspector-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ interface StoredPrefs {
locale?: string;
displayMode?: McpUiDisplayMode;
containerMaxHeight?: number;
containerMaxWidth?: number;
safeAreaInsets?: { top: number; bottom: number; left: number; right: number };
activeHost?: HostId;
platform?: Platform;
Expand All @@ -275,10 +276,81 @@ interface StoredPrefs {
screenWidth?: ScreenWidth;
}

const VALID_THEMES: ReadonlySet<McpUiTheme> = new Set(['light', 'dark']);
const VALID_DISPLAY_MODES: ReadonlySet<McpUiDisplayMode> = new Set(['inline', 'pip', 'fullscreen']);
const VALID_PLATFORMS: ReadonlySet<Platform> = new Set(['web', 'desktop', 'mobile']);
const VALID_SCREEN_WIDTHS: ReadonlySet<ScreenWidth> = new Set([
'mobile-s',
'mobile-l',
'tablet',
'full',
]);

// Validate the parsed JSON one field at a time so a corrupt or stale entry
// (older sunpeak version, manual edit) can't seed bad values into state.
function sanitizeStoredPrefs(raw: unknown): StoredPrefs {
if (!raw || typeof raw !== 'object') return {};
const obj = raw as Record<string, unknown>;
const prefs: StoredPrefs = {};

if (typeof obj.theme === 'string' && VALID_THEMES.has(obj.theme as McpUiTheme)) {
prefs.theme = obj.theme as McpUiTheme;
}
if (typeof obj.locale === 'string') {
prefs.locale = obj.locale;
}
if (
typeof obj.displayMode === 'string' &&
VALID_DISPLAY_MODES.has(obj.displayMode as McpUiDisplayMode)
) {
prefs.displayMode = obj.displayMode as McpUiDisplayMode;
}
if (typeof obj.containerMaxHeight === 'number' && Number.isFinite(obj.containerMaxHeight)) {
prefs.containerMaxHeight = obj.containerMaxHeight;
}
if (typeof obj.containerMaxWidth === 'number' && Number.isFinite(obj.containerMaxWidth)) {
prefs.containerMaxWidth = obj.containerMaxWidth;
}
if (obj.safeAreaInsets && typeof obj.safeAreaInsets === 'object') {
const insets = obj.safeAreaInsets as Record<string, unknown>;
if (
typeof insets.top === 'number' &&
typeof insets.bottom === 'number' &&
typeof insets.left === 'number' &&
typeof insets.right === 'number'
) {
prefs.safeAreaInsets = {
top: insets.top,
bottom: insets.bottom,
left: insets.left,
right: insets.right,
};
}
}
if (typeof obj.activeHost === 'string') {
prefs.activeHost = obj.activeHost;
}
if (typeof obj.platform === 'string' && VALID_PLATFORMS.has(obj.platform as Platform)) {
prefs.platform = obj.platform as Platform;
}
if (typeof obj.hover === 'boolean') prefs.hover = obj.hover;
if (typeof obj.touch === 'boolean') prefs.touch = obj.touch;
if (
typeof obj.screenWidth === 'string' &&
VALID_SCREEN_WIDTHS.has(obj.screenWidth as ScreenWidth)
) {
prefs.screenWidth = obj.screenWidth as ScreenWidth;
}

return prefs;
}

function readStoredPrefs(): StoredPrefs {
if (typeof window === 'undefined') return {};
try {
const raw = localStorage.getItem(PREFS_KEY);
return raw ? (JSON.parse(raw) as StoredPrefs) : {};
if (!raw) return {};
return sanitizeStoredPrefs(JSON.parse(raw));
} catch {
return {};
}
Expand Down Expand Up @@ -339,7 +411,7 @@ export function useInspectorState({
urlParams.containerMaxHeight ?? storedPrefs.containerMaxHeight
);
const [containerMaxWidth, setContainerMaxWidth] = useState<number | undefined>(
urlParams.containerMaxWidth
urlParams.containerMaxWidth ?? storedPrefs.containerMaxWidth
);
const [platform, setPlatform] = useState<Platform>(
urlParams.platform ?? storedPrefs.platform ?? DEFAULT_PLATFORM
Expand Down Expand Up @@ -370,6 +442,7 @@ export function useInspectorState({
locale,
displayMode,
containerMaxHeight,
containerMaxWidth,
safeAreaInsets,
activeHost,
platform,
Expand All @@ -387,6 +460,7 @@ export function useInspectorState({
locale,
displayMode,
containerMaxHeight,
containerMaxWidth,
safeAreaInsets,
activeHost,
platform,
Expand Down
27 changes: 27 additions & 0 deletions packages/sunpeak/src/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,30 @@ Object.defineProperty(window, 'ResizeObserver', {

// Mock scrollIntoView for Select component
Element.prototype.scrollIntoView = () => {};

// Node 22+ ships a stubbed `localStorage` on globalThis with no methods, and
// happy-dom doesn't override it. Install a Map-backed shim so tests can use
// localStorage normally.
const localStorageStore = new Map<string, string>();
const localStorageShim: Storage = {
get length() {
return localStorageStore.size;
},
clear: () => localStorageStore.clear(),
getItem: (key) => localStorageStore.get(key) ?? null,
key: (index) => Array.from(localStorageStore.keys())[index] ?? null,
removeItem: (key) => {
localStorageStore.delete(key);
},
setItem: (key, value) => {
localStorageStore.set(key, String(value));
},
};
Object.defineProperty(globalThis, 'localStorage', {
value: localStorageShim,
configurable: true,
});
Object.defineProperty(window, 'localStorage', {
value: localStorageShim,
configurable: true,
});
Loading