From 596d580db496b0e2f342170e2c9bd195ebe3afbc Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Sat, 2 May 2026 12:41:25 +1000 Subject: [PATCH] Validate stored inspector prefs and persist containerMaxWidth Sanitize the parsed JSON one field at a time in readStoredPrefs so a corrupt or stale localStorage entry can't seed bad values like an unknown theme or platform into state. Add containerMaxWidth to the persisted preferences so manual sidebar widths survive a refresh, like containerMaxHeight already does. Install a Map-backed localStorage shim in the test setup. Node 22+ ships a stubbed globalThis.localStorage that lacks setItem/getItem, and happy-dom doesn't override it. --- .../src/inspector/use-inspector-state.test.ts | 97 ++++++++++++++++++- .../src/inspector/use-inspector-state.ts | 78 ++++++++++++++- packages/sunpeak/src/test/setup.ts | 27 ++++++ 3 files changed, 199 insertions(+), 3 deletions(-) diff --git a/packages/sunpeak/src/inspector/use-inspector-state.test.ts b/packages/sunpeak/src/inspector/use-inspector-state.test.ts index 6e82a00f..3d30381d 100644 --- a/packages/sunpeak/src/inspector/use-inspector-state.test.ts +++ b/packages/sunpeak/src/inspector/use-inspector-state.test.ts @@ -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'; @@ -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), @@ -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'); + }); + }); }); diff --git a/packages/sunpeak/src/inspector/use-inspector-state.ts b/packages/sunpeak/src/inspector/use-inspector-state.ts index 31a7222c..6193ef57 100644 --- a/packages/sunpeak/src/inspector/use-inspector-state.ts +++ b/packages/sunpeak/src/inspector/use-inspector-state.ts @@ -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; @@ -275,10 +276,81 @@ interface StoredPrefs { screenWidth?: ScreenWidth; } +const VALID_THEMES: ReadonlySet = new Set(['light', 'dark']); +const VALID_DISPLAY_MODES: ReadonlySet = new Set(['inline', 'pip', 'fullscreen']); +const VALID_PLATFORMS: ReadonlySet = new Set(['web', 'desktop', 'mobile']); +const VALID_SCREEN_WIDTHS: ReadonlySet = 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; + 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; + 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 {}; } @@ -339,7 +411,7 @@ export function useInspectorState({ urlParams.containerMaxHeight ?? storedPrefs.containerMaxHeight ); const [containerMaxWidth, setContainerMaxWidth] = useState( - urlParams.containerMaxWidth + urlParams.containerMaxWidth ?? storedPrefs.containerMaxWidth ); const [platform, setPlatform] = useState( urlParams.platform ?? storedPrefs.platform ?? DEFAULT_PLATFORM @@ -370,6 +442,7 @@ export function useInspectorState({ locale, displayMode, containerMaxHeight, + containerMaxWidth, safeAreaInsets, activeHost, platform, @@ -387,6 +460,7 @@ export function useInspectorState({ locale, displayMode, containerMaxHeight, + containerMaxWidth, safeAreaInsets, activeHost, platform, diff --git a/packages/sunpeak/src/test/setup.ts b/packages/sunpeak/src/test/setup.ts index 142bcaee..e4912e0b 100644 --- a/packages/sunpeak/src/test/setup.ts +++ b/packages/sunpeak/src/test/setup.ts @@ -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(); +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, +});