import { Injector } from '@furystack/inject' import { serializeToQueryString } from '@furystack/rest' import { sleepAsync, usingAsync } from '@furystack/utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { initializeShadeRoot } from './initialize.js' import { createComponent } from './shade-component.js' import { flushUpdates, Shade } from './shade.js' describe('Shades integration tests', () => { beforeEach(() => { document.body.innerHTML = '
' }) afterEach(() => { document.body.innerHTML = '' }) it('Should mount a custom component to a Shade root', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const ExampleComponent = Shade({ render: () =>
Hello
, customElementName: 'shades-example' }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() expect(document.body.innerHTML).toBe('
Hello
') }) }) it('Should mount a custom component with a string render result', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const ExampleComponent = Shade({ render: () => 'Hello', customElementName: 'shades-string-render-result' }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() expect(document.body.innerHTML).toBe( '
Hello
', ) }) }) it('Should mount a custom component with null render result', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const ExampleComponent = Shade({ render: () => null, customElementName: 'shades-null-render-result' }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() expect(document.body.innerHTML).toBe( '
', ) }) }) it('Should mount a custom component with a document fragment render result', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const ExampleComponent = Shade({ render: () => ( <>

1

2

), customElementName: 'shades-fragment-render-result', }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() expect(document.body.innerHTML).toBe( '

1

2

', ) }) }) it('Should mount a custom component with a nested document fragment render result', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const ExampleComponent = Shade({ render: () => (

<>

1

2

), customElementName: 'shades-fragment-render-result-nested', }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() expect(document.body.innerHTML).toBe( '

1

2

', ) }) }) it('Should mount a custom component with a document fragment that contains custom components', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const CustomComponent = Shade({ customElementName: 'shades-fragment-test-custom-component', render: () =>

Hello

, }) const ExampleComponent = Shade({ render: () => ( <> ), customElementName: 'shades-fragment-render-result-2', }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() expect(document.body.innerHTML).toBe( '

Hello

Hello

', ) }) }) it('Should mount nested Shades components', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const ExampleComponent = Shade({ render: ({ children }) =>
{children}
, customElementName: 'shades-example-2', }) const ExampleSubs = Shade<{ no: number }>({ render: ({ props }) =>
{props.no}
, customElementName: 'shades-example-sub', }) initializeShadeRoot({ injector, rootElement, jsxElement: ( ), }) await flushUpdates() expect(document.body.innerHTML).toBe( '
1
2
3
', ) }) }) it('Should execute useDisposable cleanup on component disconnection', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const cleanup = vi.fn() const setup = vi.fn() const ExampleComponent = Shade({ customElementName: 'example-component-1', render: ({ useDisposable }) => { useDisposable('test', () => { setup() return { [Symbol.dispose]: cleanup } }) return
Hello
}, }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() expect(setup).toBeCalled() expect(cleanup).not.toBeCalled() document.body.innerHTML = '' await sleepAsync(10) // Dispose can be async expect(cleanup).toBeCalled() }) }) it('Should update state', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const ExampleComponent = Shade({ customElementName: 'example-component-3', render: ({ useState }) => { const [count, setCount] = useState('count', 0) return (
Count is {count}
) }, }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() const plus = async () => { document.getElementById('plus')?.click() await flushUpdates() } const minus = async () => { document.getElementById('minus')?.click() await flushUpdates() } const expectCount = (count: number) => expect(document.body.innerHTML).toContain(`Count is ${count}`) expectCount(0) await plus() expectCount(1) await plus() expectCount(2) await minus() await minus() expectCount(0) }) }) it('Should update the stored state', async () => { const mockedStorage = new Map() const store: typeof localStorage = { getItem: (key) => { return mockedStorage.get(key) || null }, setItem: (key, value) => { mockedStorage.set(key, value) }, length: 0, clear: () => { mockedStorage.clear() }, key: (index) => { return Array.from(mockedStorage.keys())[index] || null }, removeItem: (key) => { mockedStorage.delete(key) }, } await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const ExampleComponent = Shade({ customElementName: 'example-component-3-stored-state', render: ({ useStoredState }) => { const [count, setCount] = useStoredState('count', 0, store) return (
Count is {count}
) }, }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() const plus = async () => { document.getElementById('plus')?.click() await flushUpdates() } const minus = async () => { document.getElementById('minus')?.click() await flushUpdates() } const expectCount = (count: number) => expect(document.body.innerHTML).toContain(`Count is ${count}`) await flushUpdates() expectCount(0) await flushUpdates() await plus() expectCount(1) expect(store.getItem('count')).toBe('1') await plus() expectCount(2) expect(store.getItem('count')).toBe('2') await minus() await minus() expectCount(0) expect(store.getItem('count')).toBe('0') }) }) it('Should update the search state', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const ExampleComponent = Shade({ customElementName: 'example-component-3-search-state', render: ({ useSearchState }) => { const [count, setCount] = useSearchState('count', 0) return (
Count is {count}
) }, }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() const plus = async () => { document.getElementById('plus')?.click() await flushUpdates() } const minus = async () => { document.getElementById('minus')?.click() await flushUpdates() } const expectCount = (count: number) => expect(document.body.innerHTML).toContain(`Count is ${count}`) expectCount(0) await flushUpdates() await plus() expectCount(1) expect(location.search).toBe(`?${serializeToQueryString({ count: 1 })}`) await plus() expectCount(2) expect(location.search).toBe(`?${serializeToQueryString({ count: 2 })}`) await minus() await minus() expectCount(0) expect(location.search).toBe(`?${serializeToQueryString({ count: 0 })}`) }) }) it('Should allow children update after unmount and remount', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const Parent = Shade({ customElementName: 'shade-remount-parent', render: ({ children, useState }) => { const [areChildrenVisible, setAreChildrenVisible] = useState('areChildrenVisible', true) return (
{areChildrenVisible ? children : null}
) }, }) const Child = Shade({ customElementName: 'example-remount-child', render: ({ useState }) => { const [count, setCount] = useState('count', 0) return (
Count is {`${count}`}
) }, }) initializeShadeRoot({ injector, rootElement, jsxElement: ( ), }) await flushUpdates() const plus = async () => { document.getElementById('plus')?.click() await flushUpdates() } const minus = async () => { document.getElementById('minus')?.click() await flushUpdates() } const expectCount = (count: number) => expect(document.body.innerHTML).toContain(`Count is ${count}`) const toggleChildren = async () => { document.getElementById('showHideChildren')?.click() await flushUpdates() } expectCount(0) await plus() expectCount(1) await toggleChildren() expect(document.getElementById('plus')).toBeNull() await sleepAsync(10) // Dispose can be async await toggleChildren() expect(document.getElementById('plus')).toBeDefined() expectCount(0) await plus() expectCount(1) await minus() expectCount(0) }) }) it('Should propagate changed props from parent to child Shade component', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const ChildComponent = Shade<{ isDisabled: boolean }>({ customElementName: 'shade-prop-child', render: ({ props }) => { return }, }) const ParentComponent = Shade({ customElementName: 'shade-prop-parent', render: ({ useState }) => { const [isDisabled, setIsDisabled] = useState('isDisabled', false) return (
) }, }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() const innerInput = document.querySelector('[data-testid="inner-input"]') as HTMLInputElement expect(innerInput).toBeTruthy() expect(innerInput.disabled).toBe(false) // Click the toggle button const toggleBtn = document.getElementById('toggle-disabled') as HTMLButtonElement toggleBtn.click() await flushUpdates() const updatedInput = document.querySelector('[data-testid="inner-input"]') as HTMLInputElement expect(updatedInput.disabled).toBe(true) }) }) })