UNPKG

@tanstack/devtools

Version:

TanStack Devtools is a set of tools for building advanced devtools for your application.

436 lines (404 loc) 13.5 kB
import { createComponent, createEffect, createRoot, useContext } from 'solid-js' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MAX_ACTIVE_PLUGINS } from '../utils/constants' import { flattenTabs } from '../utils/layout-tree' import { TANSTACK_DEVTOOLS_STATE } from '../utils/storage' import { DevtoolsContext, DevtoolsProvider, getExistingStateFromStorage, getStateFromLocalStorage, } from './devtools-context' import type { TanStackDevtoolsPlugin } from './devtools-context' afterEach(() => vi.restoreAllMocks()) describe('getStateFromLocalStorage', () => { beforeEach(() => { localStorage.clear() }) it('should return undefined when no data in localStorage', () => { const state = getStateFromLocalStorage(undefined) expect(state).toEqual(undefined) }) it('migrates a legacy activePlugins array into a single layout group', () => { const mockState = { activePlugins: ['plugin1'], settings: { theme: 'dark', }, } localStorage.setItem(TANSTACK_DEVTOOLS_STATE, JSON.stringify(mockState)) const state = getStateFromLocalStorage([ { id: 'plugin1', render: () => {}, name: 'Plugin 1', }, ]) // State written before the workspace became a tree reopens as one group, in // the stored order, and the superseded key is dropped. expect(state).toEqual({ layout: { kind: 'group', id: 'g0', tabs: ['plugin1'], active: 0 }, settings: { theme: 'dark' }, }) expect(state).not.toHaveProperty('activePlugins') // The migration is written back, so it only happens once. expect( JSON.parse(localStorage.getItem(TANSTACK_DEVTOOLS_STATE)!), ).not.toHaveProperty('activePlugins') }) it('keeps an existing layout tree instead of rebuilding it', () => { const layout = { kind: 'split', dir: 'row', sizes: [0.5, 0.5], children: [ { kind: 'group', id: 'g0', tabs: ['plugin1'], active: 0 }, { kind: 'group', id: 'g1', tabs: ['plugin2'], active: 0 }, ], } localStorage.setItem( TANSTACK_DEVTOOLS_STATE, JSON.stringify({ layout, activePlugins: ['plugin1'] }), ) const state = getStateFromLocalStorage([ { id: 'plugin1', render: () => {}, name: 'Plugin 1' }, { id: 'plugin2', render: () => {}, name: 'Plugin 2' }, ]) // The tree wins: activePlugins is the superseded key, not a second opinion. expect(state?.layout).toEqual(layout) expect(flattenTabs(state?.layout ?? null)).toEqual(['plugin1', 'plugin2']) }) it('should filter out inactive plugins', () => { const mockState = { activePlugins: ['plugin1', 'plugin2'], settings: { theme: 'dark', }, } localStorage.setItem(TANSTACK_DEVTOOLS_STATE, JSON.stringify(mockState)) const plugins = [{ id: 'plugin1', render: () => {}, name: 'Plugin 1' }] const state = getStateFromLocalStorage(plugins) expect(flattenTabs(state?.layout ?? null)).toEqual(['plugin1']) }) it('should return empty plugin state if all active plugins are invalid', () => { const mockState = { activePlugins: ['plugin1', 'plugin2'], settings: { theme: 'dark', }, } localStorage.setItem(TANSTACK_DEVTOOLS_STATE, JSON.stringify(mockState)) const plugins = [{ id: 'plugin3', render: () => {}, name: 'Plugin 3' }] const state = getStateFromLocalStorage(plugins) expect(flattenTabs(state?.layout ?? null)).toEqual([]) }) it('should handle invalid JSON in localStorage gracefully', () => { localStorage.setItem(TANSTACK_DEVTOOLS_STATE, 'invalid json') const state = getStateFromLocalStorage(undefined) expect(state).toEqual(undefined) }) it('should return undefined when no localStorage state exists (allowing defaultOpen to be applied)', () => { // No existing state in localStorage - this allows defaultOpen logic to trigger const plugins: Array<TanStackDevtoolsPlugin> = [ { id: 'plugin1', render: () => {}, name: 'Plugin 1', defaultOpen: true, }, { id: 'plugin2', render: () => {}, name: 'Plugin 2', defaultOpen: false, }, { id: 'plugin3', render: () => {}, name: 'Plugin 3', defaultOpen: true, }, ] // When undefined is returned, getExistingStateFromStorage will fill activePlugins with defaultOpen plugins const state = getStateFromLocalStorage(plugins) expect(state).toEqual(undefined) }) it('should preserve existing activePlugins from localStorage (defaultOpen should not override)', () => { const mockState = { activePlugins: ['plugin2'], settings: { theme: 'dark', }, } localStorage.setItem(TANSTACK_DEVTOOLS_STATE, JSON.stringify(mockState)) const plugins: Array<TanStackDevtoolsPlugin> = [ { id: 'plugin1', render: () => {}, name: 'Plugin 1', defaultOpen: true, }, { id: 'plugin2', render: () => {}, name: 'Plugin 2', defaultOpen: false, }, ] const state = getStateFromLocalStorage(plugins) // Should keep existing activePlugins - defaultOpen logic won't override in getExistingStateFromStorage expect(flattenTabs(state?.layout ?? null)).toEqual(['plugin2']) }) it('should automatically activate a single plugin when no active plugins exist', () => { // No existing state in localStorage const plugins: Array<TanStackDevtoolsPlugin> = [ { id: 'only-plugin', render: () => {}, name: 'Only Plugin', }, ] const state = getStateFromLocalStorage(plugins) // Should return undefined - the single plugin activation happens in getExistingStateFromStorage expect(state).toEqual(undefined) }) }) describe('getExistingStateFromStorage - integration tests', () => { beforeEach(() => { localStorage.clear() }) it('should automatically activate a single plugin when no localStorage state exists', () => { const plugins: Array<TanStackDevtoolsPlugin> = [ { id: 'only-plugin', render: () => {}, name: 'Only Plugin', }, ] const state = getExistingStateFromStorage(undefined, plugins) expect(flattenTabs(state.state.layout)).toEqual(['only-plugin']) expect(state.plugins).toHaveLength(1) expect(state.plugins![0]?.id).toBe('only-plugin') }) it('should activate plugins with defaultOpen: true when no localStorage state exists', () => { const plugins: Array<TanStackDevtoolsPlugin> = [ { id: 'plugin1', render: () => {}, name: 'Plugin 1', defaultOpen: true, }, { id: 'plugin2', render: () => {}, name: 'Plugin 2', defaultOpen: false, }, { id: 'plugin3', render: () => {}, name: 'Plugin 3', defaultOpen: true, }, ] const state = getExistingStateFromStorage(undefined, plugins) expect(flattenTabs(state.state.layout)).toEqual(['plugin1', 'plugin3']) expect(state.plugins).toHaveLength(3) }) it('opens every defaultOpen plugin that fits under MAX_ACTIVE_PLUGINS', () => { const plugins: Array<TanStackDevtoolsPlugin> = [ { id: 'plugin1', render: () => {}, name: 'Plugin 1', defaultOpen: true, }, { id: 'plugin2', render: () => {}, name: 'Plugin 2', defaultOpen: true, }, { id: 'plugin3', render: () => {}, name: 'Plugin 3', defaultOpen: true, }, { id: 'plugin4', render: () => {}, name: 'Plugin 4', defaultOpen: true, }, { id: 'plugin5', render: () => {}, name: 'Plugin 5', defaultOpen: true, }, ] const state = getExistingStateFromStorage(undefined, plugins) // Five is under the cap now that panes can split and stack, so all five // open. Pinned to the constant rather than a literal. expect(flattenTabs(state.state.layout)).toEqual( plugins.slice(0, MAX_ACTIVE_PLUGINS).map((plugin) => plugin.id), ) expect(flattenTabs(state.state.layout).length).toBeLessThanOrEqual( MAX_ACTIVE_PLUGINS, ) // All 5 plugins should still be in the plugins array expect(state.plugins).toHaveLength(5) }) it('should preserve existing activePlugins from localStorage even when plugins have defaultOpen', () => { const mockState = { activePlugins: ['plugin2', 'plugin4'], settings: { theme: 'dark', }, } localStorage.setItem(TANSTACK_DEVTOOLS_STATE, JSON.stringify(mockState)) const plugins: Array<TanStackDevtoolsPlugin> = [ { id: 'plugin1', render: () => {}, name: 'Plugin 1', defaultOpen: true, }, { id: 'plugin2', render: () => {}, name: 'Plugin 2', defaultOpen: false, }, { id: 'plugin3', render: () => {}, name: 'Plugin 3', defaultOpen: true, }, { id: 'plugin4', render: () => {}, name: 'Plugin 4', defaultOpen: false, }, ] const state = getExistingStateFromStorage(undefined, plugins) // Should preserve the localStorage state, not use defaultOpen expect(flattenTabs(state.state.layout)).toEqual(['plugin2', 'plugin4']) expect(state.plugins).toHaveLength(4) }) it('should return empty activePlugins when no defaultOpen and multiple plugins', () => { const plugins: Array<TanStackDevtoolsPlugin> = [ { id: 'plugin1', render: () => {}, name: 'Plugin 1', }, { id: 'plugin2', render: () => {}, name: 'Plugin 2', }, { id: 'plugin3', render: () => {}, name: 'Plugin 3', }, ] const state = getExistingStateFromStorage(undefined, plugins) expect(flattenTabs(state.state.layout)).toEqual([]) expect(state.plugins).toHaveLength(3) }) it('should handle single plugin with defaultOpen: false by activating it anyway', () => { const plugins: Array<TanStackDevtoolsPlugin> = [ { id: 'only-plugin', render: () => {}, name: 'Only Plugin', defaultOpen: false, }, ] const state = getExistingStateFromStorage(undefined, plugins) // Single plugin should be activated regardless of defaultOpen flag expect(flattenTabs(state.state.layout)).toEqual(['only-plugin']) }) it('should merge config settings into the returned state', () => { const plugins: Array<TanStackDevtoolsPlugin> = [ { id: 'plugin1', render: () => {}, name: 'Plugin 1', }, ] const config = { theme: 'light' as const, } const state = getExistingStateFromStorage(config as any, plugins) expect(state.settings.theme).toBe('light') expect(flattenTabs(state.state.layout)).toEqual(['plugin1']) }) }) describe('provider resilience', () => { beforeEach(() => localStorage.clear()) it('fails loudly for storage reads while tolerating storage quota errors', () => { vi.spyOn(Storage.prototype, 'getItem').mockImplementationOnce(() => { throw new Error('read blocked') }) expect(() => getStateFromLocalStorage(undefined)).toThrow('read blocked') localStorage.setItem( TANSTACK_DEVTOOLS_STATE, JSON.stringify({ activePlugins: ['missing'], settings: { theme: 'dark' }, }), ) vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { throw new Error('quota') }) expect(() => getStateFromLocalStorage([ { id: 'kept', name: 'Kept', render: () => {} }, ]), ).not.toThrow() }) it('generates stable unique IDs while preserving explicit duplicate IDs', () => { const generated = getExistingStateFromStorage(undefined, [ { name: 'Same Name', render: () => {} }, { name: 'Same Name', render: () => {} }, { name: () => {}, render: () => {} }, ]).plugins!.map((entry) => entry.id) expect(generated).toEqual(['same-name-0', 'same-name-1', '2']) const explicit = getExistingStateFromStorage(undefined, [ { id: 'duplicate', name: 'A', render: () => {} }, { id: 'duplicate', name: 'B', render: () => {} }, ]).plugins!.map((entry) => entry.id) expect(explicit).toEqual(['duplicate', 'duplicate']) }) it('reactively replaces plugins through the existing onSetPlugins callback', async () => { let replace!: (plugins: Array<TanStackDevtoolsPlugin>) => void let ids: Array<string> = [] const dispose = createRoot((disposeRoot) => { createComponent(DevtoolsProvider, { plugins: [{ name: 'Old', render: () => {} }], onSetPlugins: (setter) => { replace = setter }, get children() { const context = useContext(DevtoolsContext)! createEffect(() => { ids = context.store.plugins?.map((entry) => entry.id!) ?? [] }) return null }, }) return disposeRoot }) await Promise.resolve() expect(ids).toEqual(['old-0']) replace([{ name: 'New', render: () => {} }]) expect(ids).toEqual(['new-0']) dispose() }) })